Files
Tracker/lib/screens/home/home_screen.dart
Sterlen b4b2bfe749 feat: Implement husband features and fix iOS Safari web startup
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.
2025-12-26 22:40:52 -06:00

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'),
),
],
),
);
}
}