446 lines
14 KiB
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|