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

599 lines
19 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 '../../../../core/platform/health_gateway.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 (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';
}
}
}
/// 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,
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,
),
),
);
}
}