Refactor: Implement multi-item inventory for Pad Tracker and dynamic navigation

This commit is contained in:
2026-01-02 18:10:50 -06:00
parent 56683f5407
commit 8772b56f36
44 changed files with 3515 additions and 781 deletions

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/pad_settings_dialog.dart';
class AppearanceScreen extends ConsumerWidget {
const AppearanceScreen({super.key});
@@ -23,9 +24,9 @@ class AppearanceScreen extends ConsumerWidget {
_buildThemeModeSelector(context, ref, userProfile.themeMode),
const SizedBox(height: 24),
_buildAccentColorSelector(
context, ref, userProfile.accentColor, AppColors.sageGreen),
const SizedBox(height: 32),
_buildRelationshipStatusSelector(context, ref, userProfile.relationshipStatus),
context, ref, userProfile.accentColor),
const SizedBox(height: 24),
// _buildPadSettings removed as per new design
],
),
);
@@ -77,6 +78,16 @@ class AppearanceScreen extends ConsumerWidget {
Widget _buildAccentColorSelector(BuildContext context, WidgetRef ref,
String currentAccent) {
final accents = [
{'color': AppColors.sageGreen, 'value': '0xFFA8C5A8'},
{'color': AppColors.rose, 'value': '0xFFE8A0B0'},
{'color': AppColors.lavender, 'value': '0xFFD4C4E8'},
{'color': AppColors.info, 'value': '0xFF7BB8E8'},
{'color': AppColors.softGold, 'value': '0xFFD4A574'},
{'color': AppColors.mint, 'value': '0xFF98DDCA'},
{'color': AppColors.teal, 'value': '0xFF5B9AA0'},
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -88,73 +99,44 @@ class AppearanceScreen extends ConsumerWidget {
Wrap(
spacing: 16,
runSpacing: 16,
children: [
GestureDetector(
children: accents.map((accent) {
final color = accent['color'] as Color;
final value = accent['value'] as String;
final isSelected = currentAccent == value;
return GestureDetector(
onTap: () {
ref
.read(userProfileProvider.notifier)
.updateAccentColor('0xFFA8C5A8');
ref.read(userProfileProvider.notifier).updateAccentColor(value);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.sageGreen,
color: color,
shape: BoxShape.circle,
border: Border.all(
color:
Theme.of(context).colorScheme.primary, // Assuming currentAccent is sageGreen
width: 3,
),
border: isSelected
? Border.all(
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: AppColors.charcoal,
width: 3,
)
: null,
boxShadow: [
if (isSelected)
BoxShadow(
color: color.withOpacity(0.4),
blurRadius: 8,
offset: const Offset(0, 4),
)
],
),
child: const Icon(Icons.check, color: Colors.white),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
),
],
),
],
);
}
Widget _buildRelationshipStatusSelector(
BuildContext context, WidgetRef ref, RelationshipStatus currentStatus) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Relationship Status',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
SegmentedButton<RelationshipStatus>(
segments: const [
ButtonSegment(
value: RelationshipStatus.single,
label: Text('Single'),
icon: Icon(Icons.person_outline),
),
ButtonSegment(
value: RelationshipStatus.engaged,
label: Text('Engaged'),
icon: Icon(Icons.favorite_border),
),
ButtonSegment(
value: RelationshipStatus.married,
label: Text('Married'),
icon: Icon(Icons.favorite),
),
],
selected: {currentStatus},
onSelectionChanged: (Set<RelationshipStatus> newSelection) {
if (newSelection.isNotEmpty) {
ref
.read(userProfileProvider.notifier)
.updateRelationshipStatus(newSelection.first);
}
},
style: SegmentedButton.styleFrom(
fixedSize: const Size.fromHeight(48),
)
);
}).toList(),
),
],
);

View File

@@ -112,7 +112,7 @@ class CycleHistoryScreen extends ConsumerWidget {
),
child: ListTile(
title: Text(DateFormat.yMMMMEEEEd().format(entry.date)),
subtitle: Text(_buildEntrySummary(entry)),
subtitle: Text(_buildEntrySummary(entry, ref)),
isThreeLine: true,
),
);
@@ -123,11 +123,47 @@ class CycleHistoryScreen extends ConsumerWidget {
);
}
String _buildEntrySummary(CycleEntry entry) {
String _buildEntrySummary(CycleEntry entry, WidgetRef ref) {
final summary = <String>[];
if (entry.isPeriodDay) {
summary.add('Period');
// Calculate Cycle Day / Phase
// This is a simplified calculation. For accurate phase, we need cycle logic.
// We'll calculate the 'Day of Cycle' by finding the most recent period start before this entry.
final allEntries = ref.read(cycleEntriesProvider);
DateTime? lastPeriodStart;
// Inefficient for large lists but acceptable for now.
// Optimization: Calculate this once or pass cycle context.
final sortedEntries = List<CycleEntry>.from(allEntries)..sort((a,b) => a.date.compareTo(b.date));
for (var e in sortedEntries) {
if (e.date.isAfter(entry.date)) break;
if (e.isPeriodDay) {
// If it's a period day and the previous day wasn't (or gap > 1), it's a start.
// Simplified: Just take the period day closest to entry.
// Actually, if 'entry' IS a period day, then it's Menstrual phase.
// We'll just look for the last period day.
lastPeriodStart = e.date; // continuously update to find the latest one <= entry.date
// But we need the START of that period block.
}
}
// Better Approach: Use CycleService static helper if available, or just check entry props.
if (entry.isPeriodDay) {
summary.add('Menstrual Phase');
} else if (lastPeriodStart != null) {
final day = entry.date.difference(lastPeriodStart).inDays + 1;
// Estimate phase based on standard 28 day. User might want actual phase logic.
// Reusing CycleService logic would be best but requires instantiating it with all data.
String phase = 'Follicular';
if (day > 14) phase = 'Luteal'; // Very rough approximation
if (day == 14) phase = 'Ovulation';
summary.add('Day $day ($phase)');
}
if (entry.mood != null) {
summary.add('Mood: ${entry.mood!.label}');
}
@@ -135,12 +171,12 @@ class CycleHistoryScreen extends ConsumerWidget {
summary.add('${entry.symptomCount} symptom(s)');
}
if (entry.notes != null && entry.notes!.isNotEmpty) {
summary.add('Note');
summary.add('Note: "${entry.notes}"');
}
if (summary.isEmpty) {
return 'No specific data logged.';
}
return summary.join('');
return summary.join('\n'); // Use newline for better readability with notes
}
}

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
class GoalSettingsScreen extends ConsumerWidget {
const GoalSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userProfile = ref.watch(userProfileProvider);
if (userProfile == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('Cycle Goal'),
),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
'What is your current goal?',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Select your primary goal to get personalized insights and predictions.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
_buildGoalOption(
context,
ref,
title: 'Track Cycle Only',
subtitle: 'Monitor period and health without fertility focus',
value: FertilityGoal.justTracking,
groupValue: userProfile.fertilityGoal,
icon: Icons.calendar_today,
),
_buildGoalOption(
context,
ref,
title: 'Achieve Pregnancy',
subtitle: 'Identify fertile window and ovulation',
value: FertilityGoal.tryingToConceive,
groupValue: userProfile.fertilityGoal,
icon: Icons.child_friendly,
),
_buildGoalOption(
context,
ref,
title: 'Avoid Pregnancy',
subtitle: 'Track fertility for natural family planning',
value: FertilityGoal.tryingToAvoid,
groupValue: userProfile.fertilityGoal,
icon: Icons.security,
),
],
),
);
}
Widget _buildGoalOption(
BuildContext context,
WidgetRef ref, {
required String title,
required String subtitle,
required FertilityGoal value,
required FertilityGoal? groupValue,
required IconData icon,
}) {
final isSelected = value == groupValue;
return Card(
elevation: isSelected ? 2 : 0,
margin: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
: BorderSide.none,
),
child: RadioListTile<FertilityGoal>(
value: value,
groupValue: groupValue,
onChanged: (FertilityGoal? newValue) {
if (newValue != null) {
final currentProfile = ref.read(userProfileProvider);
if (currentProfile != null) {
ref.read(userProfileProvider.notifier).updateProfile(
currentProfile.copyWith(fertilityGoal: newValue),
);
}
}
},
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle),
secondary: Icon(icon, color: isSelected ? Theme.of(context).colorScheme.primary : null),
activeColor: Theme.of(context).colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart'; // Import UserProfile
import '../../providers/user_provider.dart';
class NotificationSettingsScreen extends ConsumerWidget {
const NotificationSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userProfile = ref.watch(userProfileProvider);
if (userProfile == null) {
return Scaffold(
appBar: AppBar(title: const Text('Notifications')),
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
SwitchListTile(
title: const Text('Period Estimate'),
subtitle: const Text('Get notified when your period is predicted to start soon.'),
value: userProfile.notifyPeriodEstimate,
onChanged: (value) async {
await ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(notifyPeriodEstimate: value));
},
),
const Divider(),
SwitchListTile(
title: const Text('Period Start'),
subtitle: const Text('Get notified when a period starts (or husband needs to know).'),
value: userProfile.notifyPeriodStart,
onChanged: (value) async {
await ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(notifyPeriodStart: value));
},
),
const Divider(),
SwitchListTile(
title: const Text('Low Supply Alert'),
subtitle: const Text('Get notified when pad inventory is running low.'),
value: userProfile.notifyLowSupply,
onChanged: (value) async {
await ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(notifyLowSupply: value));
},
),
],
),
);
}
}

View File

@@ -121,7 +121,7 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
title: const Text('Sync Period Days'),
subtitle: const Text('Automatically sync your period start and end dates to your health app.'),
value: syncPeriodToHealth,
onChanged: (value) async {
onChanged: _hasPermissions ? (value) async {
if (value) {
await _syncPeriodDays(true);
} else {
@@ -130,8 +130,7 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
setState(() {
syncPeriodToHealth = value; // Update local state for toggle
});
},
enabled: _hasPermissions, // Only enable if connected
} : null,
),
// TODO: Add more privacy settings if needed
],

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
class RelationshipSettingsScreen extends ConsumerWidget {
const RelationshipSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userProfile = ref.watch(userProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Relationship Status'),
),
body: userProfile == null
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
'Select your current relationship status to customize your experience.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
_buildOption(
context,
ref,
title: 'Single',
subtitle: 'Tracking for potential future',
value: RelationshipStatus.single,
groupValue: userProfile.relationshipStatus,
icon: Icons.person_outline,
),
_buildOption(
context,
ref,
title: 'Engaged',
subtitle: 'Preparing for marriage',
value: RelationshipStatus.engaged,
groupValue: userProfile.relationshipStatus,
icon: Icons.favorite_border,
),
_buildOption(
context,
ref,
title: 'Married',
subtitle: 'Tracking together with husband',
value: RelationshipStatus.married,
groupValue: userProfile.relationshipStatus,
icon: Icons.favorite,
),
],
),
);
}
Widget _buildOption(
BuildContext context,
WidgetRef ref, {
required String title,
required String subtitle,
required RelationshipStatus value,
required RelationshipStatus groupValue,
required IconData icon,
}) {
final isSelected = value == groupValue;
return Card(
elevation: isSelected ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
: BorderSide.none,
),
child: RadioListTile<RelationshipStatus>(
value: value,
groupValue: groupValue,
onChanged: (RelationshipStatus? newValue) {
if (newValue != null) {
ref
.read(userProfileProvider.notifier)
.updateRelationshipStatus(newValue);
}
},
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle),
secondary: Icon(icon, color: isSelected ? Theme.of(context).colorScheme.primary : null),
activeColor: Theme.of(context).colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
);
}
}

View File

@@ -26,6 +26,17 @@ class SharingSettingsScreen extends ConsumerWidget {
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
ListTile(
leading: const Icon(Icons.link),
title: const Text('Link with Husband'),
subtitle: Text(userProfile.partnerName != null ? 'Linked to ${userProfile.partnerName}' : 'Not linked'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: Navigate to Link Screen or Show Dialog
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Link feature coming soon!')));
},
),
const Divider(),
SwitchListTile(
title: const Text('Share Moods'),
value: userProfile.shareMoods,

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import '../../providers/user_provider.dart';
import '../../services/notification_service.dart';
import '../../widgets/pad_settings_dialog.dart'; // We can reuse the logic, but maybe embed it directly or just link it.
// Actually, let's rebuild the UI here properly as a screen instead of a dialog,
// or for now, since we already have the dialog logic working well, let's just
// have this screen trigger the dialog or embed the same widgets.
// However, the user asked to "make a new setting page", so a full screen is better.
// I'll copy the logic from the dialog into this screen for a seamless experience.
class SuppliesSettingsScreen extends ConsumerStatefulWidget {
const SuppliesSettingsScreen({super.key});
@override
ConsumerState<SuppliesSettingsScreen> createState() => _SuppliesSettingsScreenState();
}
class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen> {
// Logic from PadSettingsDialog
bool _isTrackingEnabled = false;
int _typicalFlow = 2;
int _padAbsorbency = 3;
int _padInventoryCount = 0;
int _lowInventoryThreshold = 5;
bool _isAutoInventoryEnabled = true;
final TextEditingController _brandController = TextEditingController();
@override
void initState() {
super.initState();
final user = ref.read(userProfileProvider);
if (user != null) {
_isTrackingEnabled = user.isPadTrackingEnabled;
_typicalFlow = user.typicalFlowIntensity ?? 2;
_padAbsorbency = user.padAbsorbency ?? 3;
_padInventoryCount = user.padInventoryCount;
_lowInventoryThreshold = user.lowInventoryThreshold;
_isAutoInventoryEnabled = user.isAutoInventoryEnabled;
_brandController.text = user.padBrand ?? '';
}
}
@override
void dispose() {
_brandController.dispose();
super.dispose();
}
Future<void> _saveSettings() async {
final user = ref.read(userProfileProvider);
if (user != null) {
final updatedProfile = user.copyWith(
isPadTrackingEnabled: _isTrackingEnabled,
typicalFlowIntensity: _typicalFlow,
isAutoInventoryEnabled: _isAutoInventoryEnabled,
padBrand: _brandController.text.trim().isEmpty ? null : _brandController.text.trim(),
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
// Check for Low Supply Alert
if (updatedProfile.notifyLowSupply &&
updatedProfile.padInventoryCount <= updatedProfile.lowInventoryThreshold) {
NotificationService().showLocalNotification(
id: 2001,
title: 'Low Pad Supply',
body: 'Your inventory is low (${updatedProfile.padInventoryCount} left). Time to restock!',
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preferences saved')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Period Supplies'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveSettings,
)
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Toggle
Row(
children: [
Expanded(
child: Text(
'Enable Pad Tracking',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
),
),
),
Switch(
value: _isTrackingEnabled,
onChanged: (val) => setState(() => _isTrackingEnabled = val),
activeColor: AppColors.menstrualPhase,
),
],
),
if (_isTrackingEnabled) ...[
const Divider(height: 32),
// Typical Flow
Text(
'Typical Flow Intensity',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.warmGray,
),
),
const SizedBox(height: 8),
Row(
children: [
Text('Light', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Expanded(
child: Slider(
value: _typicalFlow.toDouble(),
min: 1,
max: 5,
divisions: 4,
activeColor: AppColors.menstrualPhase,
onChanged: (val) => setState(() => _typicalFlow = val.round()),
),
),
Text('Heavy', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
],
),
Center(
child: Text(
'$_typicalFlow/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase
)
),
),
const SizedBox(height: 24),
// Auto Deduct Toggle
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'Auto-deduct on Log',
style: GoogleFonts.outfit(fontWeight: FontWeight.w500, color: AppColors.charcoal),
),
subtitle: Text(
'Reduce count when you log a pad',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
value: _isAutoInventoryEnabled,
onChanged: (val) => setState(() => _isAutoInventoryEnabled = val),
activeColor: AppColors.menstrualPhase,
),
],
],
),
),
);
}
}