Implement initial features for husband's companion app, including mock data service and husband notes screen. Refactor scripture and cycle services for improved stability and testability. Address iOS Safari web app startup issue by removing deprecated initialization. - Implemented MockDataService and HusbandNotesScreen. - Converted _DashboardTab and DevotionalScreen to StatefulWidgets for robust scripture provider initialization. - Refactored CycleService to use immutable CycleInfo class, reducing UI rebuilds. - Removed deprecated window.flutterConfiguration from index.html, resolving Flutter web app startup failure on iOS Safari. - Updated and fixed related tests.
626 lines
21 KiB
Dart
626 lines
21 KiB
Dart
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 '../../models/cycle_entry.dart';
|
|
import '../../models/scripture.dart';
|
|
import '../calendar/calendar_screen.dart';
|
|
import '../log/log_screen.dart';
|
|
import '../devotional/devotional_screen.dart';
|
|
import '../../widgets/tip_card.dart';
|
|
import '../../widgets/cycle_ring.dart';
|
|
import '../../widgets/scripture_card.dart';
|
|
import '../../widgets/quick_log_buttons.dart';
|
|
import '../../providers/user_provider.dart';
|
|
import '../../providers/navigation_provider.dart';
|
|
import '../../services/cycle_service.dart';
|
|
import '../../services/bible_utils.dart';
|
|
import '../../providers/scripture_provider.dart'; // Import the new provider
|
|
|
|
class HomeScreen extends ConsumerWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final selectedIndex = ref.watch(navigationProvider);
|
|
|
|
return Scaffold(
|
|
body: IndexedStack(
|
|
index: selectedIndex,
|
|
children: [
|
|
const _DashboardTab(),
|
|
const CalendarScreen(),
|
|
const LogScreen(),
|
|
const DevotionalScreen(),
|
|
_SettingsTab(
|
|
onReset: () =>
|
|
ref.read(navigationProvider.notifier).setIndex(0)),
|
|
],
|
|
),
|
|
bottomNavigationBar: Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: (Theme.of(context).brightness == Brightness.dark
|
|
? Colors.black
|
|
: AppColors.charcoal)
|
|
.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: BottomNavigationBar(
|
|
currentIndex: selectedIndex,
|
|
onTap: (index) =>
|
|
ref.read(navigationProvider.notifier).setIndex(index),
|
|
items: const [
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.home_outlined),
|
|
activeIcon: Icon(Icons.home),
|
|
label: 'Home',
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.calendar_today_outlined),
|
|
activeIcon: Icon(Icons.calendar_today),
|
|
label: 'Calendar',
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.add_circle_outline),
|
|
activeIcon: Icon(Icons.add_circle),
|
|
label: 'Log',
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.menu_book_outlined),
|
|
activeIcon: Icon(Icons.menu_book),
|
|
label: 'Devotional',
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.settings_outlined),
|
|
activeIcon: Icon(Icons.settings),
|
|
label: 'Settings',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashboardTab extends ConsumerStatefulWidget {
|
|
const _DashboardTab({super.key});
|
|
|
|
@override
|
|
ConsumerState<_DashboardTab> createState() => _DashboardTabState();
|
|
}
|
|
|
|
class _DashboardTabState extends ConsumerState<_DashboardTab> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeScripture();
|
|
}
|
|
|
|
// This method initializes the scripture and can react to phase changes.
|
|
// It's called from initState and also when currentCycleInfoProvider changes.
|
|
Future<void> _initializeScripture() async {
|
|
final phase = ref.read(currentCycleInfoProvider).phase;
|
|
await ref.read(scriptureProvider.notifier).initializeScripture(phase);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Listen for changes in the cycle info to re-initialize scripture if needed
|
|
ref.listen<CycleInfo>(currentCycleInfoProvider, (previousCycleInfo, newCycleInfo) {
|
|
if (previousCycleInfo?.phase != newCycleInfo.phase) {
|
|
_initializeScripture();
|
|
}
|
|
});
|
|
|
|
final name =
|
|
ref.watch(userProfileProvider.select((u) => u?.name)) ?? 'Friend';
|
|
final translation =
|
|
ref.watch(userProfileProvider.select((u) => u?.bibleTranslation)) ??
|
|
BibleTranslation.esv;
|
|
final role = ref.watch(userProfileProvider.select((u) => u?.role)) ??
|
|
UserRole.wife;
|
|
final isMarried =
|
|
ref.watch(userProfileProvider.select((u) => u?.isMarried)) ?? false;
|
|
final averageCycleLength =
|
|
ref.watch(userProfileProvider.select((u) => u?.averageCycleLength)) ??
|
|
28;
|
|
|
|
final cycleInfo = ref.watch(currentCycleInfoProvider);
|
|
final phase = cycleInfo.phase;
|
|
final dayOfCycle = cycleInfo.dayOfCycle;
|
|
|
|
// Watch the scripture provider for the current scripture
|
|
final scriptureState = ref.watch(scriptureProvider);
|
|
final scripture = scriptureState.currentScripture;
|
|
final maxIndex = scriptureState.maxIndex;
|
|
|
|
if (scripture == null) {
|
|
return const Center(child: CircularProgressIndicator()); // Or some error message
|
|
}
|
|
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildGreeting(context, name),
|
|
const SizedBox(height: 24),
|
|
Center(
|
|
child: CycleRing(
|
|
dayOfCycle: dayOfCycle,
|
|
totalDays: averageCycleLength,
|
|
phase: phase,
|
|
),
|
|
),
|
|
const SizedBox(height: 32),
|
|
// Main Scripture Card with Navigation
|
|
Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
ScriptureCard(
|
|
verse: scripture.getVerse(translation),
|
|
reference: scripture.reference,
|
|
translation: translation.label,
|
|
phase: phase,
|
|
onTranslationTap: () =>
|
|
BibleUtils.showTranslationPicker(context, ref),
|
|
),
|
|
if (maxIndex != null && maxIndex > 1) ...[
|
|
Positioned(
|
|
left: 0,
|
|
child: IconButton(
|
|
icon: Icon(Icons.arrow_back_ios),
|
|
onPressed: () =>
|
|
ref.read(scriptureProvider.notifier).getPreviousScripture(),
|
|
color: AppColors.charcoal,
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
child: IconButton(
|
|
icon: Icon(Icons.arrow_forward_ios),
|
|
onPressed: () =>
|
|
ref.read(scriptureProvider.notifier).getNextScripture(),
|
|
color: AppColors.charcoal,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (maxIndex != null && maxIndex > 1)
|
|
Center(
|
|
child: TextButton.icon(
|
|
onPressed: () => ref.read(scriptureProvider.notifier).getRandomScripture(),
|
|
icon: const Icon(Icons.shuffle),
|
|
label: const Text('Random Verse'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.sageGreen,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
'Quick Log',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
const QuickLogButtons(),
|
|
const SizedBox(height: 24),
|
|
if (role == UserRole.wife)
|
|
TipCard(phase: phase, isMarried: isMarried),
|
|
const SizedBox(height: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGreeting(BuildContext context, String name) {
|
|
final theme = Theme.of(context);
|
|
final hour = DateTime.now().hour;
|
|
String greeting;
|
|
if (hour < 12) {
|
|
greeting = 'Good morning';
|
|
} else if (hour < 17) {
|
|
greeting = 'Good afternoon';
|
|
} else {
|
|
greeting = 'Good evening';
|
|
}
|
|
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'$greeting,',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
Text(
|
|
name,
|
|
style: theme.textTheme.displaySmall?.copyWith(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primaryContainer.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
Icons.notifications_outlined,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingsTab extends ConsumerWidget {
|
|
final VoidCallback? onReset;
|
|
const _SettingsTab({this.onReset});
|
|
|
|
Widget _buildSettingsTile(BuildContext context, IconData icon, String title,
|
|
{VoidCallback? onTap}) {
|
|
return ListTile(
|
|
leading: Icon(icon,
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8)),
|
|
title: Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
trailing: const Icon(Icons.chevron_right, color: AppColors.lightGray),
|
|
onTap: onTap ??
|
|
() {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Settings coming soon!')),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _resetApp(BuildContext context, WidgetRef ref) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Reset App?'),
|
|
content: const Text(
|
|
'This will clear all data and return you to onboarding. Are you sure?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel')),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Reset', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true) {
|
|
await ref.read(userProfileProvider.notifier).clearProfile();
|
|
await ref.read(cycleEntriesProvider.notifier).clearEntries();
|
|
|
|
if (context.mounted) {
|
|
onReset?.call();
|
|
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final name =
|
|
ref.watch(userProfileProvider.select((u) => u?.name)) ?? 'Guest';
|
|
final roleSymbol =
|
|
ref.watch(userProfileProvider.select((u) => u?.role)) ==
|
|
UserRole.husband
|
|
? 'HUSBAND'
|
|
: null;
|
|
final relationshipStatus = ref.watch(userProfileProvider
|
|
.select((u) => u?.relationshipStatus.name.toUpperCase())) ??
|
|
'SINGLE';
|
|
final translationLabel =
|
|
ref.watch(userProfileProvider.select((u) => u?.bibleTranslation.label)) ??
|
|
'ESV';
|
|
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Settings',
|
|
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).cardTheme.color,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color:
|
|
Theme.of(context).colorScheme.outline.withOpacity(0.05)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 60,
|
|
height: 60,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
AppColors.blushPink,
|
|
AppColors.rose.withOpacity(0.7)
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
name,
|
|
style:
|
|
Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
roleSymbol ?? relationshipStatus,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 12,
|
|
letterSpacing: 1,
|
|
color: AppColors.warmGray,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(Icons.chevron_right, color: AppColors.warmGray),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
_buildSettingsGroup(context, 'Preferences', [
|
|
_buildSettingsTile(
|
|
context, Icons.notifications_outlined, 'Notifications'),
|
|
_buildSettingsTile(
|
|
context,
|
|
Icons.book_outlined,
|
|
'Bible Version ($translationLabel)',
|
|
onTap: () => BibleUtils.showTranslationPicker(context, ref),
|
|
),
|
|
_buildSettingsTile(context, Icons.palette_outlined, 'Appearance'),
|
|
_buildSettingsTile(
|
|
context,
|
|
Icons.favorite_border,
|
|
'My Favorites',
|
|
onTap: () => _showFavoritesDialog(context, ref),
|
|
),
|
|
_buildSettingsTile(context, Icons.lock_outline, 'Privacy'),
|
|
_buildSettingsTile(
|
|
context,
|
|
Icons.share_outlined,
|
|
'Share with Husband',
|
|
onTap: () => _showShareDialog(context, ref),
|
|
),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
_buildSettingsGroup(context, 'Cycle', [
|
|
_buildSettingsTile(
|
|
context, Icons.calendar_today_outlined, 'Cycle Settings'),
|
|
_buildSettingsTile(
|
|
context, Icons.trending_up_outlined, 'Cycle History'),
|
|
_buildSettingsTile(
|
|
context, Icons.download_outlined, 'Export Data'),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
_buildSettingsGroup(context, 'Account', [
|
|
_buildSettingsTile(context, Icons.logout, 'Reset App / Logout',
|
|
onTap: () => _resetApp(context, ref)),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSettingsGroup(
|
|
BuildContext context, String title, List<Widget> tiles) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.warmGray,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).cardTheme.color,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.05)),
|
|
),
|
|
child: Column(
|
|
children: tiles,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showFavoritesDialog(BuildContext context, WidgetRef ref) {
|
|
final userProfile = ref.read(userProfileProvider);
|
|
if (userProfile == null) return;
|
|
|
|
final controller = TextEditingController(
|
|
text: userProfile.favoriteFoods?.join(', ') ?? '',
|
|
);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('My Favorites', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'List your favorite comfort foods, snacks, or flowers so your husband knows what to get you!',
|
|
style: GoogleFonts.outfit(fontSize: 13, color: AppColors.warmGray),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
maxLines: 3,
|
|
decoration: const InputDecoration(
|
|
hintText: 'e.g., Dark Chocolate, Sushi, Sunflowers...',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
final favorites = controller.text
|
|
.split(',')
|
|
.map((e) => e.trim())
|
|
.where((e) => e.isNotEmpty)
|
|
.toList();
|
|
|
|
final updatedProfile = userProfile.copyWith(favoriteFoods: favorites);
|
|
ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showShareDialog(BuildContext context, WidgetRef ref) {
|
|
// 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: [
|
|
const Icon(Icons.share_outlined, color: AppColors.sageGreen),
|
|
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.sageGreen.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColors.sageGreen.withOpacity(0.3)),
|
|
),
|
|
child: SelectableText(
|
|
pairingCode,
|
|
style: GoogleFonts.outfit(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 4,
|
|
color: AppColors.sageGreen,
|
|
),
|
|
),
|
|
),
|
|
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.sageGreen,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Done'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|