Implement husband-wife connection dialogue and theme support for learn articles

This commit is contained in:
2026-01-05 17:09:15 -06:00
parent 02d25d0cc7
commit 96655f9a74
36 changed files with 3849 additions and 819 deletions

View File

@@ -9,6 +9,8 @@ import '../../providers/user_provider.dart';
import '../../services/cycle_service.dart';
import '../../theme/app_theme.dart';
import '../log/log_screen.dart';
import 'package:uuid/uuid.dart';
import '../../widgets/protected_wrapper.dart';
class CalendarScreen extends ConsumerStatefulWidget {
final bool readOnly;
@@ -22,38 +24,63 @@ class CalendarScreen extends ConsumerStatefulWidget {
ConsumerState<CalendarScreen> createState() => _CalendarScreenState();
}
enum PredictionMode { short, regular, long }
class _CalendarScreenState extends ConsumerState<CalendarScreen> {
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
CalendarFormat _calendarFormat = CalendarFormat.month;
PredictionMode _predictionMode = PredictionMode.regular;
@override
Widget build(BuildContext context) {
final entries = ref.watch(cycleEntriesProvider);
final user = ref.watch(userProfileProvider);
final cycleLength = user?.averageCycleLength ?? 28;
final isIrregular = user?.isIrregularCycle ?? false;
int cycleLength = user?.averageCycleLength ?? 28;
if (isIrregular) {
if (_predictionMode == PredictionMode.short) {
cycleLength = user?.minCycleLength ?? 25;
} else if (_predictionMode == PredictionMode.long) {
cycleLength = user?.maxCycleLength ?? 35;
}
}
final lastPeriodStart = user?.lastPeriodStartDate;
return SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
return ProtectedContentWrapper(
title: 'Calendar',
isProtected: user?.isCalendarProtected ?? false,
userProfile: user,
child: SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(20),
child: Row(
child: Column(
children: [
Expanded(
child: Text(
'Calendar',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
Row(
children: [
Expanded(
child: Text(
'Calendar',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
),
),
),
),
_buildLegendButton(),
],
),
_buildLegendButton(),
if (isIrregular) ...[
const SizedBox(height: 16),
_buildPredictionToggle(),
],
],
),
),
@@ -242,13 +269,64 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
// Day Info (No longer Expanded)
_buildDayInfo(
_selectedDay!, lastPeriodStart, cycleLength, entries),
_selectedDay!, lastPeriodStart, cycleLength, entries, user),
const SizedBox(height: 40), // Bottom padding
],
],
),
),
));
}
Widget _buildPredictionToggle() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.lightGray.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_buildToggleItem(PredictionMode.short, 'Short (-)', AppColors.menstrualPhase),
_buildToggleItem(PredictionMode.regular, 'Regular', AppColors.sageGreen),
_buildToggleItem(PredictionMode.long, 'Long (+)', AppColors.lutealPhase),
],
),
);
}
Widget _buildToggleItem(PredictionMode mode, String label, Color color) {
final isSelected = _predictionMode == mode;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _predictionMode = mode),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: null,
),
child: Text(
label,
textAlign: TextAlign.center,
style: GoogleFonts.outfit(
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? color : AppColors.warmGray,
),
),
),
),
);
}
@@ -338,7 +416,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
}
Widget _buildDayInfo(DateTime date, DateTime? lastPeriodStart, int cycleLength,
List<CycleEntry> entries) {
List<CycleEntry> entries, UserProfile? user) {
final phase = _getPhaseForDate(date, lastPeriodStart, cycleLength);
final entry = _getEntryForDate(date, entries);
@@ -401,15 +479,21 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
),
),
),
if (entry == null)
if (entry == null) ...[
Text(
phase?.description ?? 'No data for this date',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: AppColors.warmGray),
)
else ...[
),
if (user?.isPadTrackingEnabled == true &&
phase != CyclePhase.menstrual &&
(user?.padSupplies?.any((s) => s.type == PadType.pantyLiner) ?? false)) ...[
const SizedBox(height: 16),
_buildPantylinerPrompt(date, null),
],
] else ...[
// Period Detail
if (entry.isPeriodDay)
_buildDetailRow(Icons.water_drop, 'Period Day',
@@ -436,6 +520,17 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
// Contextual Recommendation
_buildRecommendation(entry),
// Pad Tracking Specifics (Not shared with husband)
if (user?.isPadTrackingEnabled == true) ...[
const SizedBox(height: 16),
if (entry.usedPantyliner)
_buildDetailRow(Icons.layers_outlined, 'Supplies Used', AppColors.menstrualPhase,
value: '${entry.pantylinerCount}'),
if (!entry.usedPantyliner && !entry.isPeriodDay)
_buildPantylinerPrompt(date, entry),
],
// Notes
if (entry.notes?.isNotEmpty == true)
Padding(
@@ -455,6 +550,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
),
),
],
if (user?.isPadTrackingEnabled == true) ...[
const SizedBox(height: 16),
_buildManualSupplyEntryButton(date),
],
const SizedBox(height: 24),
// Action Buttons
@@ -496,6 +597,71 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
);
}
Widget _buildPantylinerPrompt(DateTime date, CycleEntry? entry) {
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.menstrualPhase.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.menstrualPhase.withOpacity(0.2)),
),
child: Row(
children: [
const Icon(Icons.help_outline, color: AppColors.menstrualPhase, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Did you use pantyliners today?',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.charcoal),
),
),
TextButton(
onPressed: () {
if (entry != null) {
ref.read(cycleEntriesProvider.notifier).updateEntry(
entry.copyWith(usedPantyliner: true, pantylinerCount: 1),
);
} else {
final newEntry = CycleEntry(
id: const Uuid().v4(),
date: date,
usedPantyliner: true,
pantylinerCount: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
ref.read(cycleEntriesProvider.notifier).addEntry(newEntry);
}
},
child: Text('Yes', style: GoogleFonts.outfit(color: AppColors.menstrualPhase, fontWeight: FontWeight.bold)),
),
],
),
);
}
Widget _buildManualSupplyEntryButton(DateTime date) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
// Open a simplified version of the supply management or just log a change
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Supply usage recorded manually.')),
);
},
icon: const Icon(Icons.add_shopping_cart, size: 18),
label: const Text('Manual Supply Entry'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.menstrualPhase,
side: const BorderSide(color: AppColors.menstrualPhase),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
);
}
Widget _buildRecommendation(CycleEntry entry) {
final scripture = ScriptureDatabase().getRecommendedScripture(entry);
if (scripture == null) return const SizedBox.shrink();

View File

@@ -8,6 +8,7 @@ import '../../models/cycle_entry.dart';
import '../../theme/app_theme.dart';
import '../../widgets/scripture_card.dart';
import '../../models/user_profile.dart';
import '../../models/teaching_plan.dart';
import '../../providers/scripture_provider.dart'; // Import the new provider
class DevotionalScreen extends ConsumerStatefulWidget {
@@ -345,6 +346,15 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
),
const SizedBox(height: 24),
// Husband's Teaching Plan
if (user != null)
if (user.teachingPlans?.isNotEmpty ?? false)
_buildTeachingPlanCard(context, user.teachingPlans!)
else
_buildSampleTeachingCard(context),
const SizedBox(height: 24),
// Action buttons
Row(
children: [
@@ -424,12 +434,251 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
'Help me to serve with joy and purpose. Amen."';
case CyclePhase.ovulation:
return '"Creator God, I am fearfully and wonderfully made. '
'Thank You for the gift of womanhood. '
'Help me to honor You in all I do today. Amen."';
'Thank You for the gift of womanhood. '
'Help me to honor You in all I do today. Amen."';
case CyclePhase.luteal:
return '"Lord, I bring my anxious thoughts to You. '
'When my emotions feel overwhelming, remind me of Your peace. '
'Help me to be gentle with myself as You are gentle with me. Amen."';
'When my emotions feel overwhelming, remind me of Your peace. '
'Help me to be gentle with myself as You are gentle with me. Amen."';
}
}
Widget _buildTeachingPlanCard(BuildContext context, List<TeachingPlan> plans) {
// Get latest uncompleted plan or just latest
if (plans.isEmpty) return const SizedBox.shrink();
// Sort by date desc
final sorted = List<TeachingPlan>.from(plans)..sort((a,b) => b.date.compareTo(a.date));
final latestPlan = sorted.first;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: AppColors.gold.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.menu_book, color: AppColors.navyBlue),
const SizedBox(width: 8),
Expanded(
child: Text(
'Leading in the Word',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.navyBlue,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.gold.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Husband\'s Sharing',
style: GoogleFonts.outfit(fontSize: 10, color: AppColors.gold, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
Text(
latestPlan.topic,
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
),
),
if (latestPlan.scriptureReference.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
latestPlan.scriptureReference,
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.gold,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 8),
Text(
latestPlan.notes,
style: GoogleFonts.lora(
fontSize: 15,
height: 1.5,
color: AppColors.charcoal.withOpacity(0.9),
),
),
],
),
);
}
Widget _buildSampleTeachingCard(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.warmGray.withOpacity(0.3), style: BorderStyle.solid),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.menu_book, color: AppColors.warmGray),
const SizedBox(width: 12),
Expanded(
child: Text(
'Leading in the Word',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.warmGray,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.warmGray.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Sample',
style: GoogleFonts.outfit(fontSize: 10, color: AppColors.warmGray, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
Text(
'Husband\'s Role in Leading',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.charcoal.withOpacity(0.7),
),
),
const SizedBox(height: 4),
Text(
'Ephesians 5:25',
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.warmGray,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'This is a sample of where your husband\'s teaching plans will appear. Connect with his app to share spiritual growth together.',
style: GoogleFonts.lora(
fontSize: 15,
height: 1.5,
fontStyle: FontStyle.italic,
color: AppColors.charcoal.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Center(
child: OutlinedButton.icon(
onPressed: () => _showShareDialog(context),
icon: const Icon(Icons.link, size: 18),
label: const Text('Connect with Husband'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.navyBlue,
side: const BorderSide(color: AppColors.navyBlue),
),
),
),
],
),
);
}
void _showShareDialog(BuildContext context) {
// Generate a simple pairing code (in a real app, this would be stored/validated)
final userProfile = ref.read(userProfileProvider);
final pairingCode = userProfile?.id?.substring(0, 6).toUpperCase() ?? 'ABC123';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.share_outlined, color: AppColors.navyBlue),
const SizedBox(width: 8),
const Text('Share with Husband'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Share this code with your husband so he can connect to your cycle data:',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.navyBlue.withOpacity(0.3)),
),
child: SelectableText(
pairingCode,
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 4,
color: AppColors.navyBlue,
),
),
),
const SizedBox(height: 16),
Text(
'He can enter this in his app under Settings > Connect with Wife.',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
foregroundColor: Colors.white,
),
child: const Text('Done'),
),
],
),
);
}
}

View File

@@ -12,11 +12,13 @@ import '../devotional/devotional_screen.dart';
import '../settings/appearance_screen.dart';
import '../settings/cycle_settings_screen.dart';
import '../settings/relationship_settings_screen.dart';
import '../settings/goal_settings_screen.dart'; // Add this
import '../settings/goal_settings_screen.dart';
import '../settings/cycle_history_screen.dart';
import '../settings/sharing_settings_screen.dart';
import '../settings/notification_settings_screen.dart';
import '../settings/privacy_settings_screen.dart';
import '../settings/supplies_settings_screen.dart';
import '../settings/export_data_screen.dart';
import '../learn/wife_learn_screen.dart';
import '../../widgets/tip_card.dart';
import '../../widgets/cycle_ring.dart';
@@ -36,19 +38,47 @@ class HomeScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final selectedIndex = ref.watch(navigationProvider);
final isPadTrackingEnabled = ref.watch(userProfileProvider.select((u) => u?.isPadTrackingEnabled ?? false));
final isSingle = ref.watch(userProfileProvider.select((u) => u?.relationshipStatus == RelationshipStatus.single));
final tabs = [
const _DashboardTab(),
const CalendarScreen(),
const LogScreen(),
if (isPadTrackingEnabled) const PadTrackerScreen(),
const DevotionalScreen(),
const WifeLearnScreen(),
_SettingsTab(
onReset: () =>
ref.read(navigationProvider.notifier).setIndex(0)),
];
final List<Widget> tabs;
final List<BottomNavigationBarItem> navBarItems;
if (isPadTrackingEnabled) {
tabs = [
const _DashboardTab(),
const CalendarScreen(),
const PadTrackerScreen(),
const LogScreen(),
const DevotionalScreen(),
const WifeLearnScreen(),
_SettingsTab(onReset: () => ref.read(navigationProvider.notifier).setIndex(0)),
];
navBarItems = [
const BottomNavigationBarItem(icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home), label: 'Home'),
const BottomNavigationBarItem(icon: Icon(Icons.calendar_today_outlined), activeIcon: Icon(Icons.calendar_today), label: 'Calendar'),
const BottomNavigationBarItem(icon: Icon(Icons.inventory_2_outlined), activeIcon: Icon(Icons.inventory_2), label: 'Supplies'),
const BottomNavigationBarItem(icon: Icon(Icons.add_circle_outline), activeIcon: Icon(Icons.add_circle), label: 'Log'),
const BottomNavigationBarItem(icon: Icon(Icons.menu_book_outlined), activeIcon: Icon(Icons.menu_book), label: 'Devotional'),
const BottomNavigationBarItem(icon: Icon(Icons.school_outlined), activeIcon: Icon(Icons.school), label: 'Learn'),
const BottomNavigationBarItem(icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings), label: 'Settings'),
];
} else {
tabs = [
const _DashboardTab(),
const CalendarScreen(),
const DevotionalScreen(),
const LogScreen(),
const WifeLearnScreen(),
_SettingsTab(onReset: () => ref.read(navigationProvider.notifier).setIndex(0)),
];
navBarItems = [
const BottomNavigationBarItem(icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home), label: 'Home'),
const BottomNavigationBarItem(icon: Icon(Icons.calendar_today_outlined), activeIcon: Icon(Icons.calendar_today), label: 'Calendar'),
const BottomNavigationBarItem(icon: Icon(Icons.menu_book_outlined), activeIcon: Icon(Icons.menu_book), label: 'Devotional'),
const BottomNavigationBarItem(icon: Icon(Icons.add_circle_outline), activeIcon: Icon(Icons.add_circle), label: 'Log'),
const BottomNavigationBarItem(icon: Icon(Icons.school_outlined), activeIcon: Icon(Icons.school), label: 'Learn'),
const BottomNavigationBarItem(icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings), label: 'Settings'),
];
}
return Scaffold(
body: IndexedStack(
@@ -73,44 +103,7 @@ class HomeScreen extends ConsumerWidget {
currentIndex: selectedIndex >= tabs.length ? 0 : selectedIndex,
onTap: (index) =>
ref.read(navigationProvider.notifier).setIndex(index),
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home',
),
const BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
activeIcon: Icon(Icons.calendar_today),
label: 'Calendar',
),
const BottomNavigationBarItem(
icon: Icon(Icons.add_circle_outline),
activeIcon: Icon(Icons.add_circle),
label: 'Log',
),
if (isPadTrackingEnabled)
const BottomNavigationBarItem(
icon: Icon(Icons.inventory_2_outlined),
activeIcon: Icon(Icons.inventory_2),
label: 'Supplies',
),
const BottomNavigationBarItem(
icon: Icon(Icons.menu_book_outlined),
activeIcon: Icon(Icons.menu_book),
label: 'Devotional',
),
const BottomNavigationBarItem(
icon: Icon(Icons.school_outlined),
activeIcon: Icon(Icons.school),
label: 'Learn',
),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings',
),
],
items: navBarItems,
),
),
);
@@ -528,7 +521,14 @@ class _SettingsTab extends ConsumerWidget {
'My Favorites',
onTap: () => _showFavoritesDialog(context, ref),
),
_buildSettingsTile(context, Icons.lock_outline, 'Privacy'),
_buildSettingsTile(
context, Icons.security, 'Privacy & Security',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PrivacySettingsScreen()));
}),
if (!isSingle)
_buildSettingsTile(
context,
@@ -538,7 +538,8 @@ class _SettingsTab extends ConsumerWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SharingSettingsScreen()));
builder: (context) =>
const SharingSettingsScreen()));
},
),
]),
@@ -561,7 +562,13 @@ class _SettingsTab extends ConsumerWidget {
builder: (context) => CycleHistoryScreen()));
}),
_buildSettingsTile(
context, Icons.download_outlined, 'Export Data'),
context, Icons.download_outlined, 'Export Data',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ExportDataScreen()));
}),
]),
const SizedBox(height: 16),
_buildSettingsGroup(context, 'Account', [
@@ -605,14 +612,54 @@ class _SettingsTab extends ConsumerWidget {
);
}
void _showFavoritesDialog(BuildContext context, WidgetRef ref) {
Future<bool> _authenticate(BuildContext context, String correctPin) async {
final controller = TextEditingController();
final pin = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Enter PIN'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
obscureText: true,
maxLength: 4,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 24, letterSpacing: 8),
decoration: const InputDecoration(hintText: '....'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Unlock'),
),
],
),
);
return pin == correctPin;
}
void _showFavoritesDialog(BuildContext context, WidgetRef ref) async {
final userProfile = ref.read(userProfileProvider);
if (userProfile == null) return;
if (userProfile.isBioProtected && userProfile.privacyPin != null) {
final granted = await _authenticate(context, userProfile.privacyPin!);
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Incorrect PIN')));
}
return;
}
}
final controller = TextEditingController(
text: userProfile.favoriteFoods?.join(', ') ?? '',
);
if (!context.mounted) return;
showDialog(
context: context,
builder: (context) => AlertDialog(

View File

@@ -0,0 +1,369 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../models/user_profile.dart';
import '../../models/teaching_plan.dart';
import '../../providers/user_provider.dart';
import '../../theme/app_theme.dart';
class HusbandDevotionalScreen extends ConsumerStatefulWidget {
const HusbandDevotionalScreen({super.key});
@override
ConsumerState<HusbandDevotionalScreen> createState() => _HusbandDevotionalScreenState();
}
class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScreen> {
void _showAddTeachingDialog([TeachingPlan? existingPlan]) {
final titleController = TextEditingController(text: existingPlan?.topic);
final scriptureController = TextEditingController(text: existingPlan?.scriptureReference);
final notesController = TextEditingController(text: existingPlan?.notes);
DateTime selectedDate = existingPlan?.date ?? DateTime.now();
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(existingPlan == null ? 'Plan Teaching' : 'Edit Plan'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Topic / Theme',
hintText: 'e.g., Patience, Prayer, Grace',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: scriptureController,
decoration: const InputDecoration(
labelText: 'Scripture Reference',
hintText: 'e.g., Eph 5:25',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: notesController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Notes / Key Points',
hintText: 'What do you want to share?',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
Text('Date: ${DateFormat.yMMMd().format(selectedDate)}'),
const Spacer(),
TextButton(
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
setState(() => selectedDate = picked);
}
},
child: const Text('Change'),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () async {
if (titleController.text.isEmpty) return;
final user = ref.read(userProfileProvider);
if (user == null) return;
TeachingPlan newPlan;
if (existingPlan != null) {
newPlan = existingPlan.copyWith(
topic: titleController.text,
scriptureReference: scriptureController.text,
notes: notesController.text,
date: selectedDate,
);
} else {
newPlan = TeachingPlan.create(
topic: titleController.text,
scriptureReference: scriptureController.text,
notes: notesController.text,
date: selectedDate,
);
}
List<TeachingPlan> updatedList = List.from(user.teachingPlans ?? []);
if (existingPlan != null) {
final index = updatedList.indexWhere((p) => p.id == existingPlan.id);
if (index != -1) updatedList[index] = newPlan;
} else {
updatedList.add(newPlan);
}
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
if (mounted) Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
),
);
}
void _deletePlan(TeachingPlan plan) async {
final user = ref.read(userProfileProvider);
if (user == null || user.teachingPlans == null) return;
final updatedList = user.teachingPlans!.where((p) => p.id != plan.id).toList();
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
}
void _toggleComplete(TeachingPlan plan) async {
final user = ref.read(userProfileProvider);
if (user == null || user.teachingPlans == null) return;
final updatedList = user.teachingPlans!.map((p) {
if (p.id == plan.id) return p.copyWith(isCompleted: !p.isCompleted);
return p;
}).toList();
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userProfileProvider);
final upcomingPlans = user?.teachingPlans ?? [];
upcomingPlans.sort((a,b) => a.date.compareTo(b.date));
return Scaffold(
appBar: AppBar(
title: const Text('Spiritual Leadership'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informational Card (Headship)
_buildHeadshipCard(),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Teaching Plans',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.navyBlue,
),
),
IconButton(
onPressed: () => _showAddTeachingDialog(),
icon: const Icon(Icons.add_circle, color: AppColors.navyBlue, size: 28),
),
],
),
const SizedBox(height: 12),
if (upcomingPlans.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Column(
children: [
const Icon(Icons.edit_note, size: 48, color: Colors.grey),
const SizedBox(height: 12),
Text(
'No teachings planned yet.',
style: GoogleFonts.outfit(color: AppColors.warmGray),
),
TextButton(
onPressed: () => _showAddTeachingDialog(),
child: const Text('Plan one now'),
),
],
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: upcomingPlans.length,
separatorBuilder: (ctx, i) => const SizedBox(height: 12),
itemBuilder: (ctx, index) {
final plan = upcomingPlans[index];
return Dismissible(
key: Key(plan.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red.withOpacity(0.8),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _deletePlan(plan),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
onTap: () => _showAddTeachingDialog(plan),
leading: IconButton(
icon: Icon(
plan.isCompleted ? Icons.check_circle : Icons.circle_outlined,
color: plan.isCompleted ? Colors.green : Colors.grey
),
onPressed: () => _toggleComplete(plan),
),
title: Text(
plan.topic,
style: GoogleFonts.outfit(
fontWeight: FontWeight.w600,
decoration: plan.isCompleted ? TextDecoration.lineThrough : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (plan.scriptureReference.isNotEmpty)
Text(plan.scriptureReference, style: const TextStyle(fontWeight: FontWeight.w500)),
if (plan.notes.isNotEmpty)
Text(
plan.notes,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
DateFormat.yMMMd().format(plan.date),
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
isThreeLine: true,
),
),
);
},
),
const SizedBox(height: 40),
],
),
),
);
}
Widget _buildHeadshipCard() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFFDF8F0), // Warm tone
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE0C097)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.menu_book, color: Color(0xFF8B5E3C)),
const SizedBox(width: 12),
Text(
'Biblical Principles',
style: GoogleFonts.lora(
fontSize: 18,
fontWeight: FontWeight.bold,
color: const Color(0xFF5D4037),
),
),
],
),
const SizedBox(height: 16),
_buildVerseText(
'1 Corinthians 11:3',
'“The head of every man is Christ, the head of a wife is her husband, and the head of Christ is God.”',
'Supports family structure under Christs authority.',
),
const SizedBox(height: 16),
const Divider(height: 1, color: Color(0xFFE0C097)),
const SizedBox(height: 16),
_buildVerseText(
'1 Tim 3:45, 12 & Titus 1:6',
'Qualifications for church elders include managing their own households well.',
'Husbands who lead faithfully at home are seen as candidates for formal spiritual leadership.',
),
],
),
);
}
Widget _buildVerseText(String ref, String text, String context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ref,
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.bold,
color: const Color(0xFF8B5E3C),
),
),
const SizedBox(height: 4),
Text(
text,
style: GoogleFonts.lora(
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.4,
color: const Color(0xFF3E2723),
),
),
const SizedBox(height: 4),
Text(
context,
style: GoogleFonts.outfit(
fontSize: 12,
color: const Color(0xFF6D4C41),
),
),
],
);
}
}

View File

@@ -10,6 +10,7 @@ import '../../services/mock_data_service.dart'; // Import mock service
import '../calendar/calendar_screen.dart'; // Import calendar
import 'husband_notes_screen.dart'; // Import notes screen
import 'learn_article_screen.dart'; // Import learn article screen
import 'husband_devotional_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Husband's companion app main screen
@@ -34,7 +35,7 @@ class _HusbandHomeScreenState extends ConsumerState<HusbandHomeScreen> {
children: [
const _HusbandDashboard(),
const CalendarScreen(readOnly: true), // Reused Calendar
const HusbandNotesScreen(), // Notes Screen
const HusbandDevotionalScreen(), // Devotional & Planning
const _HusbandTipsScreen(),
const _HusbandLearnScreen(),
const _HusbandSettingsScreen(),
@@ -70,9 +71,9 @@ class _HusbandHomeScreenState extends ConsumerState<HusbandHomeScreen> {
label: 'Calendar',
),
BottomNavigationBarItem(
icon: Icon(Icons.note_alt_outlined),
activeIcon: Icon(Icons.note_alt),
label: 'Notes',
icon: Icon(Icons.menu_book_outlined),
activeIcon: Icon(Icons.menu_book),
label: 'Devotion',
),
BottomNavigationBarItem(
icon: Icon(Icons.lightbulb_outline),
@@ -1253,10 +1254,12 @@ class _HusbandSettingsScreen extends ConsumerWidget {
void _showConnectDialog(BuildContext context, WidgetRef ref) {
final codeController = TextEditingController();
bool shareDevotional = true;
showDialog(
context: context,
builder: (context) => AlertDialog(
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Row(
children: [
const Icon(Icons.link, color: AppColors.navyBlue),
@@ -1286,6 +1289,37 @@ class _HusbandSettingsScreen extends ConsumerWidget {
'Your wife can find this code in her Settings under "Share with Husband".',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 24,
width: 24,
child: Checkbox(
value: shareDevotional,
onChanged: (val) => setState(() => shareDevotional = val ?? true),
activeColor: AppColors.navyBlue,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Share Devotional Plans',
style: GoogleFonts.outfit(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.charcoal),
),
Text(
'Allow her to see the teaching plans you create.',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
],
),
),
],
),
],
),
actions: [
@@ -1296,36 +1330,44 @@ class _HusbandSettingsScreen extends ConsumerWidget {
ElevatedButton(
onPressed: () async {
final code = codeController.text.trim();
if (code.isEmpty) return;
// In a real app, this would validate the code against a backend
// For now, we'll just show a success message and simulate pairing
Navigator.pop(context);
// Update preference
final user = ref.read(userProfileProvider);
if (user != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(isDataShared: shareDevotional)
);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connected! Loading wife\'s data...'),
content: Text('Settings updated & Connected!'),
backgroundColor: AppColors.sageGreen,
),
);
// Load demo data as simulation of pairing
final mockService = MockDataService();
final entries = mockService.generateMockCycleEntries();
for (var entry in entries) {
await ref.read(cycleEntriesProvider.notifier).addEntry(entry);
}
final mockWife = mockService.generateMockWifeProfile();
final currentProfile = ref.read(userProfileProvider);
if (currentProfile != null) {
final updatedProfile = currentProfile.copyWith(
partnerName: mockWife.name,
averageCycleLength: mockWife.averageCycleLength,
averagePeriodLength: mockWife.averagePeriodLength,
lastPeriodStartDate: mockWife.lastPeriodStartDate,
favoriteFoods: mockWife.favoriteFoods,
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
if (code.isNotEmpty) {
// Load demo data as simulation
final mockService = MockDataService();
final entries = mockService.generateMockCycleEntries();
for (var entry in entries) {
await ref.read(cycleEntriesProvider.notifier).addEntry(entry);
}
final mockWife = mockService.generateMockWifeProfile();
final currentProfile = ref.read(userProfileProvider);
if (currentProfile != null) {
final updatedProfile = currentProfile.copyWith(
isDataShared: shareDevotional,
partnerName: mockWife.name,
averageCycleLength: mockWife.averageCycleLength,
averagePeriodLength: mockWife.averagePeriodLength,
lastPeriodStartDate: mockWife.lastPeriodStartDate,
favoriteFoods: mockWife.favoriteFoods,
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
}
}
},
style: ElevatedButton.styleFrom(
@@ -1336,6 +1378,7 @@ class _HusbandSettingsScreen extends ConsumerWidget {
),
],
),
),
);
}

View File

@@ -21,12 +21,12 @@ class LearnArticleScreen extends StatelessWidget {
}
return Scaffold(
backgroundColor: AppColors.warmCream,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: AppColors.warmCream,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.navyBlue),
icon: Icon(Icons.arrow_back, color: Theme.of(context).iconTheme.color),
onPressed: () => Navigator.pop(context),
),
title: Text(
@@ -34,7 +34,7 @@ class LearnArticleScreen extends StatelessWidget {
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.warmGray,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
centerTitle: true,
@@ -50,7 +50,7 @@ class LearnArticleScreen extends StatelessWidget {
style: GoogleFonts.outfit(
fontSize: 26,
fontWeight: FontWeight.w700,
color: AppColors.navyBlue,
color: Theme.of(context).textTheme.headlineMedium?.color,
height: 1.2,
),
),
@@ -59,7 +59,7 @@ class LearnArticleScreen extends StatelessWidget {
article.subtitle,
style: GoogleFonts.outfit(
fontSize: 15,
color: AppColors.warmGray,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
const SizedBox(height: 24),
@@ -69,21 +69,21 @@ class LearnArticleScreen extends StatelessWidget {
height: 3,
width: 40,
decoration: BoxDecoration(
color: AppColors.gold,
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 24),
// Sections
...article.sections.map((section) => _buildSection(section)),
...article.sections.map((section) => _buildSection(context, section)),
],
),
),
);
}
Widget _buildSection(LearnSection section) {
Widget _buildSection(BuildContext context, LearnSection section) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
@@ -95,18 +95,18 @@ class LearnArticleScreen extends StatelessWidget {
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.navyBlue,
color: Theme.of(context).textTheme.titleLarge?.color,
),
),
const SizedBox(height: 10),
],
_buildRichText(section.content),
_buildRichText(context, section.content),
],
),
);
}
Widget _buildRichText(String content) {
Widget _buildRichText(BuildContext context, String content) {
// Handle basic markdown-like formatting
final List<InlineSpan> spans = [];
final RegExp boldPattern = RegExp(r'\*\*(.*?)\*\*');
@@ -119,7 +119,7 @@ class LearnArticleScreen extends StatelessWidget {
text: content.substring(currentIndex, match.start),
style: GoogleFonts.outfit(
fontSize: 15,
color: AppColors.charcoal,
color: Theme.of(context).textTheme.bodyLarge?.color,
height: 1.7,
),
));
@@ -130,7 +130,7 @@ class LearnArticleScreen extends StatelessWidget {
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.navyBlue,
color: Theme.of(context).textTheme.titleMedium?.color,
height: 1.7,
),
));
@@ -143,7 +143,7 @@ class LearnArticleScreen extends StatelessWidget {
text: content.substring(currentIndex),
style: GoogleFonts.outfit(
fontSize: 15,
color: AppColors.charcoal,
color: Theme.of(context).textTheme.bodyLarge?.color,
height: 1.7,
),
));

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,16 @@ import '../../models/cycle_entry.dart';
import '../../models/user_profile.dart';
import '../../services/notification_service.dart';
import '../../providers/user_provider.dart';
import '../../widgets/protected_wrapper.dart';
class PadTrackerScreen extends ConsumerStatefulWidget {
const PadTrackerScreen({super.key});
final FlowIntensity? initialFlow;
final bool isSpotting;
const PadTrackerScreen({
super.key,
this.initialFlow,
this.isSpotting = false,
});
@override
ConsumerState<PadTrackerScreen> createState() => _PadTrackerScreenState();
@@ -25,6 +32,10 @@ class _PadTrackerScreenState extends ConsumerState<PadTrackerScreen> {
@override
void initState() {
super.initState();
_selectedFlow = widget.isSpotting
? FlowIntensity.spotting
: widget.initialFlow ?? FlowIntensity.medium;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkInitialPrompt();
});
@@ -125,6 +136,75 @@ class _PadTrackerScreenState extends ConsumerState<PadTrackerScreen> {
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
_updateTimeSinceChange();
_scheduleReminders(time);
}
}
Future<void> _scheduleReminders(DateTime lastChangeTime) async {
final user = ref.read(userProfileProvider);
if (user == null || !user.isPadTrackingEnabled) return;
final service = NotificationService();
// Cancel previous
await service.cancelNotification(200);
await service.cancelNotification(201);
await service.cancelNotification(202);
await service.cancelNotification(203);
// Calculate target
final hours = _recommendedHours;
final changeTime = lastChangeTime.add(Duration(hours: hours));
final now = DateTime.now();
// 2 Hours Before
if (user.notifyPad2Hours) {
final notifyTime = changeTime.subtract(const Duration(hours: 2));
if (notifyTime.isAfter(now)) {
await service.scheduleNotification(
id: 200,
title: 'Upcoming Pad Change',
body: 'Recommended change in 2 hours.',
scheduledDate: notifyTime
);
}
}
// 1 Hour Before
if (user.notifyPad1Hour) {
final notifyTime = changeTime.subtract(const Duration(hours: 1));
if (notifyTime.isAfter(now)) {
await service.scheduleNotification(
id: 201,
title: 'Upcoming Pad Change',
body: 'Recommended change in 1 hour.',
scheduledDate: notifyTime
);
}
}
// 30 Mins Before
if (user.notifyPad30Mins) {
final notifyTime = changeTime.subtract(const Duration(minutes: 30));
if (notifyTime.isAfter(now)) {
await service.scheduleNotification(
id: 202,
title: 'Upcoming Pad Change',
body: 'Recommended change in 30 minutes.',
scheduledDate: notifyTime
);
}
}
// Change Now
if (user.notifyPadNow) {
if (changeTime.isAfter(now)) {
await service.scheduleNotification(
id: 203,
title: 'Time to Change!',
body: 'It has been $hours hours since your last change.',
scheduledDate: changeTime
);
}
}
}
@@ -137,50 +217,53 @@ class _PadTrackerScreenState extends ConsumerState<PadTrackerScreen> {
return user.padSupplies![_activeSupplyIndex!];
}
bool get _shouldShowMismatchWarning {
bool get _shouldShowMismatchWarning {
final supply = _activeSupply;
if (supply == null) return false;
// Spotting is fine with any protection
if (_selectedFlow == FlowIntensity.spotting) return false;
int flowValue = 1;
switch (_selectedFlow) {
case FlowIntensity.light: flowValue = 2; break;
case FlowIntensity.medium: flowValue = 3; break;
case FlowIntensity.heavy: flowValue = 5; break;
default: break;
}
return flowValue > supply.absorbency;
}
int get _recommendedHours {
final supply = _activeSupply;
if (supply == null) return false;
int flowValue = 1;
switch (_selectedFlow) {
case FlowIntensity.spotting: flowValue = 1; break;
case FlowIntensity.light: flowValue = 2; break;
case FlowIntensity.medium: flowValue = 3; break;
case FlowIntensity.heavy: flowValue = 5; break;
if (supply == null) return 6; // Default
final type = supply.type;
if (type == PadType.menstrualCup ||
type == PadType.menstrualDisc ||
type == PadType.periodUnderwear) {
return 12;
}
return flowValue > supply.absorbency;
}
int get _recommendedHours {
final supply = _activeSupply;
if (supply == null) return 6; // Default
final type = supply.type;
if (type == PadType.menstrualCup ||
type == PadType.menstrualDisc ||
type == PadType.periodUnderwear) {
return 12;
}
int baseHours;
switch (_selectedFlow) {
case FlowIntensity.heavy:
baseHours = (type == PadType.super_pad || type == PadType.overnight || type == PadType.tampon_super)
? 4
: 3;
break;
case FlowIntensity.medium:
baseHours = 6;
break;
case FlowIntensity.light:
baseHours = 8;
break;
case FlowIntensity.spotting:
baseHours = 8;
break;
}
int baseHours;
switch (_selectedFlow) {
case FlowIntensity.heavy:
baseHours = (type == PadType.super_pad || type == PadType.overnight || type == PadType.tampon_super)
? 4
: 3;
break;
case FlowIntensity.medium:
baseHours = 6;
break;
case FlowIntensity.light:
baseHours = 8;
break;
case FlowIntensity.spotting:
baseHours = 10; // More generous for spotting
break;
}
int flowValue = 1;
switch (_selectedFlow) {
@@ -221,18 +304,22 @@ class _PadTrackerScreenState extends ConsumerState<PadTrackerScreen> {
final supply = _activeSupply;
final user = ref.watch(userProfileProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Pad Tracker'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Supply Selection at the top as requested
_buildSectionHeader('Current Protection'),
return ProtectedContentWrapper(
title: 'Pad Tracker',
isProtected: user?.isSuppliesProtected ?? false,
userProfile: user,
child: Scaffold(
appBar: AppBar(
title: const Text('Pad Tracker'),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Supply Selection at the top as requested
_buildSectionHeader('Current Protection'),
const SizedBox(height: 12),
GestureDetector(
onTap: _showSupplyPicker,
@@ -467,7 +554,7 @@ class _PadTrackerScreenState extends ConsumerState<PadTrackerScreen> {
],
),
),
);
));
}
String _formatDuration(Duration d, UserProfile user) {

View File

@@ -31,6 +31,9 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
int _averageCycleLength = 28;
DateTime? _lastPeriodStart;
bool _isIrregularCycle = false;
int _minCycleLength = 25;
int _maxCycleLength = 35;
bool _isPadTrackingEnabled = false;
@override
void dispose() {
@@ -121,8 +124,11 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
? _fertilityGoal
: null,
averageCycleLength: _averageCycleLength,
minCycleLength: _minCycleLength,
maxCycleLength: _maxCycleLength,
lastPeriodStartDate: _lastPeriodStart,
isIrregularCycle: _isIrregularCycle,
isPadTrackingEnabled: _isPadTrackingEnabled,
hasCompletedOnboarding: true,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
@@ -704,6 +710,53 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
controlAffinity: ListTileControlAffinity.leading,
),
if (_isIrregularCycle) ...[
const SizedBox(height: 8),
Text('Cycle range (shortest to longest)',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface)),
Row(
children: [
Expanded(
child: RangeSlider(
values: RangeValues(_minCycleLength.toDouble(), _maxCycleLength.toDouble()),
min: 21,
max: 45,
divisions: 24,
activeColor: AppColors.sageGreen,
labels: RangeLabels('$_minCycleLength days', '$_maxCycleLength days'),
onChanged: (values) {
setState(() {
_minCycleLength = values.start.round();
_maxCycleLength = values.end.round();
});
},
),
),
],
),
Center(
child: Text('$_minCycleLength - $_maxCycleLength days',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.sageGreen)),
),
],
// Enable Supply Tracking Checkbox
CheckboxListTile(
title: Text('Enable supply tracking',
style: theme.textTheme.bodyLarge
?.copyWith(color: theme.colorScheme.onSurface)),
value: _isPadTrackingEnabled,
onChanged: (val) =>
setState(() => _isPadTrackingEnabled = val ?? false),
activeColor: AppColors.sageGreen,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 24),
Text('Last period start date',
style: theme.textTheme.titleMedium?.copyWith(

View File

@@ -5,9 +5,16 @@ import 'package:collection/collection.dart';
import '../../models/cycle_entry.dart';
import '../../providers/user_provider.dart';
class CycleHistoryScreen extends ConsumerWidget {
class CycleHistoryScreen extends ConsumerStatefulWidget {
const CycleHistoryScreen({super.key});
@override
ConsumerState<CycleHistoryScreen> createState() => _CycleHistoryScreenState();
}
class _CycleHistoryScreenState extends ConsumerState<CycleHistoryScreen> {
bool _isUnlocked = false;
void _showDeleteAllDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
@@ -42,9 +49,85 @@ class CycleHistoryScreen extends ConsumerWidget {
);
}
Future<void> _authenticate() async {
final user = ref.read(userProfileProvider);
if (user?.privacyPin == null) return;
final controller = TextEditingController();
final pin = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Enter PIN'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
obscureText: true,
maxLength: 4,
style: const TextStyle(fontSize: 24, letterSpacing: 8),
textAlign: TextAlign.center,
decoration: const InputDecoration(
hintText: '....',
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Unlock'),
),
],
),
);
if (pin == user!.privacyPin) {
setState(() {
_isUnlocked = true;
});
} else if (pin != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Incorrect PIN')),
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
final entries = ref.watch(cycleEntriesProvider);
final user = ref.watch(userProfileProvider);
// Privacy Check
final isProtected = user?.isHistoryProtected ?? false;
final hasPin = user?.privacyPin != null && user!.privacyPin!.isNotEmpty;
final isLocked = isProtected && hasPin && !_isUnlocked;
if (isLocked) {
return Scaffold(
appBar: AppBar(title: const Text('Cycle History')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.lock_outline, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text(
'History is Protected',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _authenticate,
icon: const Icon(Icons.key),
label: const Text('Enter PIN to View'),
),
],
),
),
);
}
final groupedEntries = groupBy(
entries,

View File

@@ -18,6 +18,7 @@ class _CycleSettingsScreenState extends ConsumerState<CycleSettingsScreen> {
late TextEditingController _periodLengthController;
DateTime? _lastPeriodStartDate;
bool _isIrregularCycle = false;
bool _isPadTrackingEnabled = false;
@override
void initState() {
@@ -29,6 +30,7 @@ class _CycleSettingsScreenState extends ConsumerState<CycleSettingsScreen> {
text: userProfile?.averagePeriodLength.toString() ?? '5');
_lastPeriodStartDate = userProfile?.lastPeriodStartDate;
_isIrregularCycle = userProfile?.isIrregularCycle ?? false;
_isPadTrackingEnabled = userProfile?.isPadTrackingEnabled ?? false;
}
@override
@@ -47,6 +49,7 @@ class _CycleSettingsScreenState extends ConsumerState<CycleSettingsScreen> {
averagePeriodLength: int.tryParse(_periodLengthController.text) ?? userProfile.averagePeriodLength,
lastPeriodStartDate: _lastPeriodStartDate,
isIrregularCycle: _isIrregularCycle,
isPadTrackingEnabled: _isPadTrackingEnabled,
);
ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
Navigator.of(context).pop();
@@ -130,6 +133,19 @@ class _CycleSettingsScreenState extends ConsumerState<CycleSettingsScreen> {
});
},
),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable Pad Tracking'),
subtitle:
const Text('Track supply usage and receive change reminders'),
value: _isPadTrackingEnabled,
onChanged: (value) {
setState(() {
_isPadTrackingEnabled = value;
});
},
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: _saveSettings,

View File

@@ -43,22 +43,49 @@ class ExportDataScreen extends ConsumerWidget {
},
),
ListTile(
leading: const Icon(Icons.calendar_month),
title: const Text('Export to Calendar File (.ics)'),
subtitle: const Text('Generate a calendar file for your cycle dates.'),
leading: const Icon(Icons.sync),
title: const Text('Sync with Calendar'),
subtitle: const Text('Export to Apple, Google, or Outlook Calendar.'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Show options dialog
final includePredictions = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Calendar Sync Options'),
content: const Text('Would you like to include predicted future periods for the next 12 months?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No, only history'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes, include predictions'),
),
],
),
);
if (includePredictions == null) return; // User cancelled dialog (though I didn't add cancel button, tapping outside returns null)
try {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Generating ICS file...')),
const SnackBar(content: Text('Generating calendar file...')),
);
await IcsService.generateCycleCalendar(cycleEntries);
await IcsService.generateCycleCalendar(
cycleEntries,
user: userProfile,
includePredictions: includePredictions
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ICS file generated successfully!')),
const SnackBar(content: Text('Calendar file generated! Open it to add to your calendar.')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to generate ICS file: $e')),
SnackBar(content: Text('Failed to generate calendar file: $e')),
);
}
},

View File

@@ -56,6 +56,60 @@ class NotificationSettingsScreen extends ConsumerWidget {
.updateProfile(userProfile.copyWith(notifyLowSupply: value));
},
),
if (userProfile.isPadTrackingEnabled) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
'Pad Change Reminders',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
CheckboxListTile(
title: const Text('2 Hours Before'),
value: userProfile.notifyPad2Hours,
onChanged: (value) async {
if (value != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPad2Hours: value));
}
},
),
CheckboxListTile(
title: const Text('1 Hour Before'),
value: userProfile.notifyPad1Hour,
onChanged: (value) async {
if (value != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPad1Hour: value));
}
},
),
CheckboxListTile(
title: const Text('30 Minutes Before'),
value: userProfile.notifyPad30Mins,
onChanged: (value) async {
if (value != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPad30Mins: value));
}
},
),
CheckboxListTile(
title: const Text('Change Now (Time\'s Up)'),
subtitle: const Text('Get notified when it\'s recommended to change.'),
value: userProfile.notifyPadNow,
onChanged: (value) async {
if (value != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPadNow: value));
}
},
),
],
],
),
);

View File

@@ -76,8 +76,6 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Period data synced successfully!')),
);
// Optionally store a flag in userProfile if sync is active
// userProfile.copyWith(syncPeriodToHealth: true)
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to sync period data.')),
@@ -86,8 +84,6 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
}
}
} else {
// Logic to disable sync (e.g., revoke permissions if Health package supports it,
// or just stop writing data in future)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Period data sync disabled.')),
@@ -97,10 +93,71 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
setState(() {}); // Rebuild to update UI
}
Future<void> _setPin() async {
final pin = await _showPinDialog(context, title: 'Set New PIN');
if (pin != null && pin.length >= 4) {
final user = ref.read(userProfileProvider);
if (user != null) {
await ref.read(userProfileProvider.notifier).updateProfile(user.copyWith(privacyPin: pin));
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PIN Set Successfully')));
}
}
}
Future<void> _removePin() async {
final user = ref.read(userProfileProvider);
if (user == null) return;
// Require current PIN
final currentPin = await _showPinDialog(context, title: 'Enter Current PIN');
if (currentPin == user.privacyPin) {
await ref.read(userProfileProvider.notifier).updateProfile(
// To clear fields, copyWith might need to handle nulls explicitly if written that way,
// but here we might pass empty string or handle logic.
// Actually, copyWith signature usually ignores nulls.
// I'll assume updating with empty string or handle it in provider,
// but for now let's just use empty string to signify removal if logic supports it.
// Wait, copyWith `privacyPin: privacyPin ?? this.privacyPin`.
// If I pass null, it keeps existing. I can't clear it via standard copyWith unless I change copyWith logic or pass emptiness.
// I'll update the userProfile object directly and save? No, Hive object.
// For now, let's treat "empty string" as no PIN if I can pass it.
user.copyWith(privacyPin: '', isBioProtected: false, isHistoryProtected: false)
);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PIN Removed')));
} else {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Incorrect PIN')));
}
}
Future<String?> _showPinDialog(BuildContext context, {required String title}) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
obscureText: true,
maxLength: 4,
decoration: const InputDecoration(hintText: 'Enter 4-digit PIN'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
// This value would ideally come from userProfile.syncPeriodToHealth
bool syncPeriodToHealth = _hasPermissions;
final user = ref.watch(userProfileProvider);
final hasPin = user?.privacyPin != null && user!.privacyPin!.isNotEmpty;
return Scaffold(
appBar: AppBar(
@@ -109,17 +166,100 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Security Section
Text('App Security', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary)),
const SizedBox(height: 8),
ListTile(
title: const Text('Health App Integration'),
title: const Text('Privacy PIN'),
subtitle: Text(hasPin ? 'PIN is set' : 'Protect sensitive data with a PIN'),
trailing: hasPin ? const Icon(Icons.lock, color: Colors.green) : const Icon(Icons.lock_open),
onTap: () {
if (hasPin) {
showModalBottomSheet(context: context, builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Change PIN'),
onTap: () {
Navigator.pop(context);
_setPin();
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Remove PIN'),
onTap: () {
Navigator.pop(context);
_removePin();
},
),
],
));
} else {
_setPin();
}
},
),
if (hasPin) ...[
SwitchListTile(
title: const Text('Use Biometrics'),
subtitle: const Text('Unlock with FaceID / Fingerprint'),
value: user?.isBioProtected ?? false,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isBioProtected: val));
},
),
const Divider(),
const Text('Protected Features', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
SwitchListTile(
title: const Text('Daily Logs'),
value: user?.isLogProtected ?? false,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isLogProtected: val));
},
),
SwitchListTile(
title: const Text('Calendar'),
value: user?.isCalendarProtected ?? false,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isCalendarProtected: val));
},
),
SwitchListTile(
title: const Text('Supplies / Pad Tracker'),
value: user?.isSuppliesProtected ?? false,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isSuppliesProtected: val));
},
),
SwitchListTile(
title: const Text('Cycle History'),
value: user?.isHistoryProtected ?? false,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isHistoryProtected: val));
},
),
],
const Divider(height: 32),
// Health Section
Text('Health App Integration', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary)),
const SizedBox(height: 8),
ListTile(
title: const Text('Health Source'),
subtitle: _hasPermissions
? const Text('Connected to Health App. Period data can be synced.')
? const Text('Connected to Health App.')
: const Text('Not connected. Tap to grant access.'),
trailing: _hasPermissions ? const Icon(Icons.check_circle, color: Colors.green) : const Icon(Icons.warning, color: Colors.orange),
onTap: _requestPermissions,
),
SwitchListTile(
title: const Text('Sync Period Days'),
subtitle: const Text('Automatically sync your period start and end dates to your health app.'),
subtitle: const Text('Automatically sync period dates.'),
value: syncPeriodToHealth,
onChanged: _hasPermissions ? (value) async {
if (value) {
@@ -132,7 +272,6 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
});
} : null,
),
// TODO: Add more privacy settings if needed
],
),
);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../models/teaching_plan.dart';
class RelationshipSettingsScreen extends ConsumerWidget {
const RelationshipSettingsScreen({super.key});
@@ -23,6 +24,34 @@ class RelationshipSettingsScreen extends ConsumerWidget {
'Select your current relationship status to customize your experience.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
// Sample Data Button
Center(
child: TextButton.icon(
onPressed: () {
final user = ref.read(userProfileProvider);
if (user != null) {
final samplePlan = TeachingPlan.create(
topic: 'Walking in Love',
scriptureReference: 'Ephesians 5:1-2',
notes: 'As Christ loved us and gave himself up for us, a fragrant offering and sacrifice to God. Let our marriage reflect this sacrificial love.',
date: DateTime.now(),
);
final List<TeachingPlan> updatedPlans = [...(user.teachingPlans ?? []), samplePlan];
ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedPlans)
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sample Teaching Plan Loaded! Check Devotional page.')),
);
}
},
icon: const Icon(Icons.science_outlined),
label: const Text('Load Sample Teaching Plan (Demo)'),
),
),
const SizedBox(height: 24),
_buildOption(
context,

View File

@@ -1,5 +1,7 @@
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 '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
@@ -31,10 +33,7 @@ class SharingSettingsScreen extends ConsumerWidget {
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!')));
},
onTap: () => _showShareDialog(context, ref),
),
const Divider(),
SwitchListTile(
@@ -95,4 +94,66 @@ class SharingSettingsScreen extends ConsumerWidget {
),
);
}
void _showShareDialog(BuildContext context, WidgetRef ref) {
// Generate a simple pairing code
final userProfile = ref.read(userProfileProvider);
final pairingCode = userProfile?.id?.substring(0, 6).toUpperCase() ?? 'ABC123';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.share_outlined, color: AppColors.navyBlue),
const SizedBox(width: 8),
const Text('Share with Husband'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Share this code with your husband so he can connect to your cycle data:',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.navyBlue.withOpacity(0.3)),
),
child: SelectableText(
pairingCode,
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 4,
color: AppColors.navyBlue,
),
),
),
const SizedBox(height: 16),
Text(
'He can enter this in his app under Settings > Connect with Wife.',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
foregroundColor: Colors.white,
),
child: const Text('Done'),
),
],
),
);
}
}

View File

@@ -2,6 +2,7 @@ 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 '../../models/user_profile.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.
@@ -19,16 +20,15 @@ class SuppliesSettingsScreen extends ConsumerStatefulWidget {
}
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;
bool _showPadTimerMinutes = true;
bool _showPadTimerSeconds = false;
final TextEditingController _brandController = TextEditingController();
// Inventory
List<SupplyItem> _supplies = [];
int _lowInventoryThreshold = 5;
@override
void initState() {
@@ -37,43 +37,44 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
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 ?? '';
_lowInventoryThreshold = user.lowInventoryThreshold;
_showPadTimerMinutes = user.showPadTimerMinutes;
_showPadTimerSeconds = user.showPadTimerSeconds;
// Load supplies
if (user.padSupplies != null) {
_supplies = List.from(user.padSupplies!);
}
}
}
@override
void dispose() {
_brandController.dispose();
super.dispose();
}
Future<void> _saveSettings() async {
final user = ref.read(userProfileProvider);
if (user != null) {
// Calculate total inventory count for the legacy field
int totalCount = _supplies.fold(0, (sum, item) => sum + item.count);
final updatedProfile = user.copyWith(
isPadTrackingEnabled: _isTrackingEnabled,
typicalFlowIntensity: _typicalFlow,
isAutoInventoryEnabled: _isAutoInventoryEnabled,
padBrand: _brandController.text.trim().isEmpty ? null : _brandController.text.trim(),
showPadTimerMinutes: _showPadTimerMinutes,
showPadTimerSeconds: _showPadTimerSeconds,
padSupplies: _supplies,
padInventoryCount: totalCount,
lowInventoryThreshold: _lowInventoryThreshold,
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
// Check for Low Supply Alert
if (updatedProfile.notifyLowSupply &&
updatedProfile.padInventoryCount <= updatedProfile.lowInventoryThreshold) {
totalCount <= updatedProfile.lowInventoryThreshold) {
NotificationService().showLocalNotification(
id: 2001,
title: 'Low Pad Supply',
body: 'Your inventory is low (${updatedProfile.padInventoryCount} left). Time to restock!',
body: 'Your inventory is low ($totalCount left). Time to restock!',
);
}
@@ -85,6 +86,24 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
}
}
void _addOrEditSupply({SupplyItem? item, int? index}) {
showDialog(
context: context,
builder: (context) => _SupplyDialog(
initialItem: item,
onSave: (newItem) {
setState(() {
if (index != null) {
_supplies[index] = newItem;
} else {
_supplies.add(newItem);
}
});
},
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -126,6 +145,83 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
if (_isTrackingEnabled) ...[
const Divider(height: 32),
// Inventory Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'My Inventory',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.warmGray,
),
),
TextButton.icon(
onPressed: () => _addOrEditSupply(),
icon: const Icon(Icons.add),
label: const Text('Add Item'),
style: TextButton.styleFrom(foregroundColor: AppColors.menstrualPhase),
),
],
),
const SizedBox(height: 8),
if (_supplies.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'No supplies added yet.\nAdd items to track specific inventory.',
textAlign: TextAlign.center,
style: GoogleFonts.outfit(color: AppColors.warmGray),
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _supplies.length,
separatorBuilder: (c, i) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = _supplies[index];
return ListTile(
tileColor: Theme.of(context).cardTheme.color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.black.withOpacity(0.05)),
),
leading: CircleAvatar(
backgroundColor: AppColors.menstrualPhase.withOpacity(0.1),
child: Text(
item.count.toString(),
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase,
),
),
),
title: Text(item.brand, style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
subtitle: Text(item.type.label, style: GoogleFonts.outfit(fontSize: 12)),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () {
setState(() {
_supplies.removeAt(index);
});
},
),
onTap: () => _addOrEditSupply(item: item, index: index),
);
},
),
const Divider(height: 32),
// Typical Flow
Text(
'Typical Flow Intensity',
@@ -230,3 +326,86 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
);
}
}
class _SupplyDialog extends StatefulWidget {
final SupplyItem? initialItem;
final Function(SupplyItem) onSave;
const _SupplyDialog({this.initialItem, required this.onSave});
@override
State<_SupplyDialog> createState() => _SupplyDialogState();
}
class _SupplyDialogState extends State<_SupplyDialog> {
late TextEditingController _brandController;
late PadType _type;
late int _count;
@override
void initState() {
super.initState();
_brandController = TextEditingController(text: widget.initialItem?.brand ?? '');
_type = widget.initialItem?.type ?? PadType.regular;
_count = widget.initialItem?.count ?? 0;
}
@override
void dispose() {
_brandController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.initialItem == null ? 'Add Supply' : 'Edit Supply'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _brandController,
decoration: const InputDecoration(labelText: 'Brand / Name'),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
DropdownButtonFormField<PadType>(
value: _type,
items: PadType.values.map((t) => DropdownMenuItem(
value: t,
child: Text(t.label),
)).toList(),
onChanged: (val) => setState(() => _type = val!),
decoration: const InputDecoration(labelText: 'Type'),
),
const SizedBox(height: 16),
TextField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Quantity'),
controller: TextEditingController(text: _count.toString()), // Hacky for demo, binding needed properly
onChanged: (val) => _count = int.tryParse(val) ?? 0,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
if (_brandController.text.isEmpty) return;
final newItem = SupplyItem(
brand: _brandController.text.trim(),
type: _type,
absorbency: 3, // Default for now
count: _count,
);
widget.onSave(newItem);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
);
}
}