glp-companion/lib/features/settings/presentation/screens/settings_screen.dart

446 lines
14 KiB
Dart

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 '../../../auth/presentation/controllers/auth_controller.dart';
import '../../../nutrition/presentation/controllers/nutrition_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();
}
});
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),
],
),
);
}
}
/// 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<void> _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<FormState> _formKey = GlobalKey<FormState>();
@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: Apple Health / Health Connect / HealthGateway toggles.
class _IntegrationsSection extends ConsumerWidget {
const _IntegrationsSection({required this.state});
final IntegrationsState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return _SettingsCard(
title: 'Integrations',
icon: Icons.sync_outlined,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.apple),
title: const Text('Apple Health'),
subtitle: const Text('Sync weight & active energy (iOS)'),
value: state.appleHealthEnabled,
onChanged: state.isRequestingPermissions
? null
: (enabled) => ref
.read(integrationsControllerProvider.notifier)
.setAppleHealthEnabled(enabled),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.fingerprint),
title: const Text('Health Connect'),
subtitle: const Text('Sync weight & active energy (Android)'),
value: state.healthConnectEnabled,
onChanged: state.isRequestingPermissions
? null
: (enabled) => ref
.read(integrationsControllerProvider.notifier)
.setHealthConnectEnabled(enabled),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.cloud_sync_outlined),
title: const Text('HealthGateway'),
subtitle: const Text(
'Cloud partner sync (MyFitnessPal / Withings) with '
'manual-entry fallback',
),
value: state.healthGatewayEnabled,
onChanged: (enabled) => ref
.read(integrationsControllerProvider.notifier)
.setHealthGatewayEnabled(enabled),
),
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,
),
],
),
),
],
),
);
}
}
/// 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),
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});
final String label;
final bool ok;
@override
Widget build(BuildContext context) {
final color = ok ? Colors.green.shade100 : Colors.orange.shade100;
final textColor = ok ? Colors.green.shade800 : 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,
),
),
);
}
}