854 lines
29 KiB
Dart
854 lines
29 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 '../../../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<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';
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<void> _openConnectDialog(BuildContext context) async {
|
|
await showDialog<bool>(
|
|
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<void> _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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|