577 lines
18 KiB
Dart
577 lines
18 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: '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:
|
|
'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.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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|