import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../../../../core/network/supabase_status.dart'; import '../../../../core/platform/health_gateway.dart'; import '../../../auth/presentation/controllers/auth_controller.dart'; import '../../../nutrition/presentation/controllers/nutrition_controller.dart'; import '../../../recipes/data/datasources/mealie_api_client.dart'; import '../../../recipes/presentation/controllers/mealie_integration_controller.dart'; import '../controllers/integrations_controller.dart'; /// Application settings hub: identity & session, daily tracking targets, /// encrypted-storage status, and health integrations. class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final integrations = ref.watch(integrationsControllerProvider); ref.listen(integrationsControllerProvider, (previous, next) { if (next.errorMessage != null && next.errorMessage != previous?.errorMessage) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(next.errorMessage!), backgroundColor: Theme.of(context).colorScheme.error, ), ); ref.read(integrationsControllerProvider.notifier).clearError(); } }); ref.listen(mealieIntegrationControllerProvider, (previous, next) { final messenger = ScaffoldMessenger.of(context); if (next.errorMessage != null && next.errorMessage != previous?.errorMessage) { messenger.showSnackBar( SnackBar( content: Text(next.errorMessage!), backgroundColor: Theme.of(context).colorScheme.error, ), ); ref.read(mealieIntegrationControllerProvider.notifier).clearMessages(); } else if (next.statusMessage != null && next.statusMessage != previous?.statusMessage) { messenger.showSnackBar(SnackBar(content: Text(next.statusMessage!))); ref.read(mealieIntegrationControllerProvider.notifier).clearMessages(); } }); return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ const _IdentitySection(), const SizedBox(height: 16), const _TargetsSection(), const SizedBox(height: 16), const _SecuritySection(), const SizedBox(height: 16), _IntegrationsSection(state: integrations), const SizedBox(height: 16), const _PrivateRecipeIntegrationsSection(), ], ), ); } } /// Identity: signed-in email, Supabase connection status, and sign-out. class _IdentitySection extends ConsumerWidget { const _IdentitySection(); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final email = _currentEmail(); final authState = ref.watch(authControllerProvider); final isSigningOut = authState.isLoading; return _SettingsCard( title: 'Identity', icon: Icons.person_outline, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.email_outlined, size: 18, color: theme.colorScheme.outline), const SizedBox(width: 8), Expanded( child: Text( email ?? 'Not signed in', style: theme.textTheme.bodyMedium, ), ), ], ), const SizedBox(height: 8), Row( children: [ Icon(Icons.cloud_outlined, size: 18, color: theme.colorScheme.outline), const SizedBox(width: 8), Flexible( child: _StatusBadge( label: isSupabaseAvailable ? 'Supabase connected' : 'Offline', ok: isSupabaseAvailable, ), ), ], ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: FilledButton.tonalIcon( onPressed: email == null || isSigningOut ? null : () => _signOut(ref), icon: isSigningOut ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.logout), label: const Text('Sign Out'), ), ), ], ), ); } /// Authenticated user's email, or `null` when the SDK is unavailable or no /// session exists (e.g. debug dev bypass). String? _currentEmail() { if (!isSupabaseAvailable) return null; return Supabase.instance.client.auth.currentUser?.email; } Future _signOut(WidgetRef ref) => ref.read(authControllerProvider.notifier).signOut(); } /// Targets: editable daily protein (g) and water (oz) goals driving the /// nutrition progress meters and meal-plan rollups. class _TargetsSection extends ConsumerStatefulWidget { const _TargetsSection(); @override ConsumerState<_TargetsSection> createState() => _TargetsSectionState(); } class _TargetsSectionState extends ConsumerState<_TargetsSection> { late final TextEditingController _proteinController; late final TextEditingController _waterController; final GlobalKey _formKey = GlobalKey(); @override void initState() { super.initState(); final nutrition = ref.read(nutritionControllerProvider).valueOrNull; _proteinController = TextEditingController( text: _fmt(nutrition?.proteinTargetGrams ?? NutritionController.defaultProteinTargetGrams), ); _waterController = TextEditingController( text: _fmt(nutrition?.hydrationTargetOz ?? NutritionController.defaultHydrationTargetOz), ); } @override void dispose() { _proteinController.dispose(); _waterController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final nutrition = ref.watch(nutritionControllerProvider); final loaded = nutrition.valueOrNull != null; return _SettingsCard( title: 'Targets', icon: Icons.flag_outlined, child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _proteinController, enabled: loaded, decoration: const InputDecoration( labelText: 'Daily protein goal (g)', border: OutlineInputBorder(), isDense: true, prefixIcon: Icon(Icons.fitness_center), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: _positiveDouble, ), const SizedBox(height: 12), TextFormField( controller: _waterController, enabled: loaded, decoration: const InputDecoration( labelText: 'Daily water goal (oz)', border: OutlineInputBorder(), isDense: true, prefixIcon: Icon(Icons.water_drop_outlined), ), keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: _positiveDouble, ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: FilledButton.icon( onPressed: loaded ? _save : null, icon: const Icon(Icons.save_outlined), label: const Text('Save Targets'), ), ), ], ), ), ); } String? _positiveDouble(String? value) { final parsed = double.tryParse((value ?? '').trim()); if (parsed == null || parsed <= 0) return 'Must be a positive number'; return null; } void _save() { if (!(_formKey.currentState?.validate() ?? false)) return; ref.read(nutritionControllerProvider.notifier).updateTargets( proteinTargetGrams: double.parse(_proteinController.text.trim()), hydrationTargetOz: double.parse(_waterController.text.trim()), ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Targets updated.')), ); } static String _fmt(double value) { final rounded = value.roundToDouble(); return rounded == value ? rounded.toStringAsFixed(0) : value.toStringAsFixed(1); } } /// Security & Storage: SQLCipher encryption status badge. /// /// Native builds open the drift database through SQLCipher with a /// vault-managed AES-256 key; web builds fall back to unencrypted WASM /// SQLite (SQLCipher cannot run in the browser). class _SecuritySection extends StatelessWidget { const _SecuritySection(); @override Widget build(BuildContext context) { final theme = Theme.of(context); const encrypted = !kIsWeb; return _SettingsCard( title: 'Security & Storage', icon: Icons.shield_outlined, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( encrypted ? Icons.lock_outline : Icons.lock_open_outlined, size: 18, color: encrypted ? Colors.green.shade700 : Colors.orange, ), const SizedBox(width: 8), const Flexible( child: _StatusBadge( label: encrypted ? 'SQLCipher AES-256 active' : 'Web storage (unencrypted)', ok: encrypted, ), ), ], ), const SizedBox(height: 8), Text( encrypted ? 'Health markers are persisted in a SQLCipher-encrypted ' 'database; the master key lives in your platform keystore ' '(Keychain / EncryptedSharedPreferences / DPAPI).' : 'SQLCipher cannot run inside the browser — web builds ' 'store data in OPFS/IndexedDB without at-rest ' 'encryption. Use a native app for encrypted storage.', style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.outline), ), ], ), ); } } /// Integrations (Phase 3B): Apple Health / Health Connect (Samsung & Pixel) /// / SMART on FHIR rows with dynamic status indicators and Connect / /// Disconnect authorization buttons. /// /// Providers the current OS cannot host (e.g. HealthKit on Windows/Web) /// render desktop-safe disabled Connect buttons with an "Unsupported" /// badge instead of crashing into dead platform channels. class _IntegrationsSection extends ConsumerWidget { const _IntegrationsSection({required this.state}); final IntegrationsState state; @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final controller = ref.read(integrationsControllerProvider.notifier); return _SettingsCard( title: 'Health Integrations', icon: Icons.cable_outlined, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _IntegrationRow( icon: Icons.apple, title: 'Apple Health', subtitle: state.nativeHealthUnsupported ? 'Weight, active energy & blood glucose (HealthKit) · ' 'Available on iOS/Android — on desktop use Log Weight ' 'on the Nutrition screen' : 'Weight, active energy & blood glucose (HealthKit)', status: state.appleHealthStatus, busy: state.isRequestingPermissions, onConnect: state.isRequestingPermissions ? null : () => controller.connectAppleHealth(), onDisconnect: state.isRequestingPermissions ? null : () => controller .disconnectProvider(HealthProviderType.appleHealth), ), const Divider(), _IntegrationRow( icon: Icons.fingerprint, title: 'Health Connect (Samsung & Pixel)', subtitle: state.nativeHealthUnsupported ? 'Federated biometrics from Samsung Health & Pixel sensors' ' · Available on Android — on desktop use Log Weight ' 'on the Nutrition screen' : 'Federated biometrics from Samsung Health & Pixel sensors', status: state.healthConnectStatus, busy: state.isRequestingPermissions, onConnect: state.isRequestingPermissions ? null : () => controller.connectHealthConnect(), onDisconnect: state.isRequestingPermissions ? null : () => controller .disconnectProvider(HealthProviderType.healthConnect), ), const Divider(), _IntegrationRow( icon: Icons.favorite_outline, title: 'Google Health', subtitle: 'Fitbit, Pixel & Wear OS data (replaces the Fitbit API)', status: state.googleHealthStatus, busy: state.isRequestingPermissions, onConnect: state.isRequestingPermissions ? null : () => controller.connectGoogleHealth(), onDisconnect: state.isRequestingPermissions ? null : () => controller .disconnectProvider(HealthProviderType.googleHealth), ), const Divider(), _IntegrationRow( icon: Icons.cloud_outlined, title: 'SMART on FHIR', subtitle: 'Enterprise lab panels (HbA1c, metabolic markers)', status: state.fhirStatus, busy: false, // The enterprise OAuth2 connector ships in a later phase; the // row surfaces live status but stays inert until provisioned. onConnect: null, onDisconnect: null, ), if (state.isRequestingPermissions) Padding( padding: const EdgeInsets.only(top: 4), child: Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), Text( 'Requesting health permissions…', style: theme.textTheme.labelSmall, ), ], ), ), ], ), ); } } /// A single provider row: icon, title, subtitle, dynamic status badge, and /// a Connect / Disconnect authorization button. class _IntegrationRow extends StatelessWidget { const _IntegrationRow({ required this.icon, required this.title, required this.subtitle, required this.status, required this.busy, required this.onConnect, required this.onDisconnect, }); final IconData icon; final String title; final String subtitle; final HealthConnectionStatus status; final bool busy; final VoidCallback? onConnect; final VoidCallback? onDisconnect; @override Widget build(BuildContext context) { final theme = Theme.of(context); final connected = status == HealthConnectionStatus.connected; final unsupported = status == HealthConnectionStatus.unsupportedPlatform; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text(title, style: theme.textTheme.titleSmall), ), const SizedBox(width: 8), Flexible( child: _StatusBadge( label: _statusLabel(status), ok: connected, neutral: unsupported, ), ), ], ), const SizedBox(height: 2), Text( subtitle, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.outline), ), ], ), ), const SizedBox(width: 12), busy ? const SizedBox( width: 36, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : connected ? OutlinedButton( onPressed: onDisconnect, child: const Text('Disconnect'), ) : FilledButton.tonal( // Desktop-safe disabled state: unsupported platforms // and un-provisioned connectors never fire a dead call. onPressed: unsupported ? null : onConnect, child: const Text('Connect'), ), ], ), ); } static String _statusLabel(HealthConnectionStatus status) { switch (status) { case HealthConnectionStatus.connected: return 'Connected'; case HealthConnectionStatus.notConfigured: return 'Not connected'; case HealthConnectionStatus.permissionDenied: return 'Permission denied'; case HealthConnectionStatus.unsupportedPlatform: return 'Unsupported'; } } } /// Private Recipe Integrations (Phase 4B): a Mealie self-hosted instance /// card with a Connected / Disconnected badge, the connect modal (URL + /// obscured API token verified before persistence), and Sync Now / Disconnect /// actions while paired. class _PrivateRecipeIntegrationsSection extends ConsumerWidget { const _PrivateRecipeIntegrationsSection(); @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final mealie = ref.watch(mealieIntegrationControllerProvider); final controller = ref.read(mealieIntegrationControllerProvider.notifier); return _SettingsCard( title: 'Private Recipe Integrations', icon: Icons.restaurant_menu, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.cloud_outlined, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text( 'Mealie Self-Hosted Instance', style: theme.textTheme.titleSmall, ), ), const SizedBox(width: 8), Flexible( child: _StatusBadge( label: mealie.isConfigured ? 'Connected' : 'Disconnected', ok: mealie.isConfigured, ), ), ], ), const SizedBox(height: 2), Text( mealie.isConfigured ? 'Endpoint: ${mealie.baseUrl}' : 'Sync recipes from your own Mealie server into ' 'the Recipe Vault.', maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.outline), ), ], ), ), ], ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: mealie.isConfigured ? Row( mainAxisSize: MainAxisSize.min, children: [ mealie.isSyncing ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : OutlinedButton.icon( onPressed: controller.syncNow, icon: const Icon(Icons.sync, size: 18), label: const Text('Sync Now'), ), const SizedBox(width: 8), OutlinedButton( onPressed: mealie.isSyncing ? null : controller.disconnect, child: const Text('Disconnect'), ), ], ) : FilledButton.tonalIcon( onPressed: mealie.isLoading ? null : () => _openConnectDialog(context), icon: const Icon(Icons.add_link), label: const Text('Connect Mealie'), ), ), ], ), ); } /// Opens the pairing modal; success is announced by the screen-level /// status listener when the state flips to connected. Future _openConnectDialog(BuildContext context) async { await showDialog( context: context, builder: (_) => const _MealieConnectDialog(), ); } } /// Modal dialog that pairs the app with a Mealie instance: server URL plus /// an obscured API bearer token, verified via "Test & Save" before the /// credentials are written to the platform vault. class _MealieConnectDialog extends ConsumerStatefulWidget { const _MealieConnectDialog(); @override ConsumerState<_MealieConnectDialog> createState() => _MealieConnectDialogState(); } class _MealieConnectDialogState extends ConsumerState<_MealieConnectDialog> { final TextEditingController _urlController = TextEditingController( text: MealieApiClient.defaultBaseUrl, ); final TextEditingController _tokenController = TextEditingController(); bool _testing = false; String? _errorText; @override void dispose() { _urlController.dispose(); _tokenController.dispose(); super.dispose(); } /// Runs the connectivity check; only a verified pairing is vaulted, and /// failures render inline instead of closing the dialog. Future _testAndSave() async { setState(() { _testing = true; _errorText = null; }); final error = await ref .read(mealieIntegrationControllerProvider.notifier) .testAndSave(baseUrl: _urlController.text, token: _tokenController.text); if (!mounted) return; if (error != null) { setState(() { _testing = false; _errorText = error; }); return; } Navigator.of(context).pop(true); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return AlertDialog( title: const Text('Connect Mealie'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _urlController, enabled: !_testing, keyboardType: TextInputType.url, autofocus: true, decoration: const InputDecoration( labelText: 'Server URL', hintText: MealieApiClient.defaultBaseUrl, border: OutlineInputBorder(), isDense: true, prefixIcon: Icon(Icons.dns_outlined), ), ), const SizedBox(height: 12), TextField( controller: _tokenController, enabled: !_testing, obscureText: true, decoration: const InputDecoration( labelText: 'API Bearer Token', hintText: 'Generate in Mealie under User Profile -> API Tokens', border: OutlineInputBorder(), isDense: true, prefixIcon: Icon(Icons.key_outlined), helperMaxLines: 2, helperText: 'Stored only in your platform keystore ' '(Keychain / Keystore / DPAPI).', ), ), if (_errorText != null) ...[ const SizedBox(height: 8), Text( _errorText!, style: TextStyle(color: theme.colorScheme.error, fontSize: 12), ), ], ], ), actions: [ TextButton( onPressed: _testing ? null : () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton.icon( onPressed: _testing ? null : _testAndSave, icon: _testing ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.cloud_done_outlined), label: const Text('Test & Save'), ), ], ); } } /// Shared card chrome for a settings section. class _SettingsCard extends StatelessWidget { const _SettingsCard({ required this.title, required this.icon, required this.child, }); final String title; final IconData icon; final Widget child; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 20, color: theme.colorScheme.primary), const SizedBox(width: 8), // Expanded: the title must wrap (or shrink) instead of // forcing the row past the card width on narrow/mobile // layouts — long section names (e.g. "Private Recipe // Integrations") render wider than the 318 dp content box. Expanded( child: Text( title, style: theme.textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.bold), ), ), ], ), const SizedBox(height: 12), child, ], ), ), ); } } /// Compact colored badge used for capability/status rows. class _StatusBadge extends StatelessWidget { const _StatusBadge({ required this.label, required this.ok, this.neutral = false, }); final String label; final bool ok; /// Renders a grey "not applicable here" tone (e.g. Unsupported on /// desktop) instead of the attention-grabbing orange. final bool neutral; @override Widget build(BuildContext context) { final Color color; final Color textColor; if (neutral) { color = Colors.grey.shade200; textColor = Colors.grey.shade700; } else if (ok) { color = Colors.green.shade100; textColor = Colors.green.shade800; } else { color = Colors.orange.shade100; textColor = Colors.orange.shade900; } return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(8), ), child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: textColor, ), ), ); } }