Files
rely/lib/features/settings/presentation/settings_view.dart
T
2026-05-18 20:33:54 +02:00

769 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/share_intake/application/share_intake_debug_log.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SettingsView extends ConsumerWidget {
const SettingsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AuthSession? session = ref
.watch(sessionControllerProvider)
.asData
?.value;
final LlmDigestConfigState digestConfig = ref.watch(
llmDigestConfigProvider,
);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Settings',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Environment and integration configuration for this build.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_SettingRow(
compact: compact,
label: 'Signed in as',
value:
session?.user.email ??
session?.user.id ??
'Not signed in',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Gateway mode',
value: AppConfig.useFakeBackend
? 'Fake (offline-safe)'
: 'REST',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Base URL',
value: AppConfig.backendBaseUrl,
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Realtime',
value: 'Planned later (SSE/WebSocket)',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Local notifications',
value: AppConfig.enableLocalNotifications
? 'Enabled'
: 'Disabled',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'LLM AI',
value: ref.watch(llmConfigProvider).isConfigured
? '${_providerLabel(ref.watch(llmConfigProvider).provider)} (configured)'
: 'Not configured',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'AI digest',
value: digestConfig.enabled
? 'Weekly, ${_weekdayLabel(digestConfig.preferredWeekday)} ${digestConfig.preferredHour.toString().padLeft(2, '0')}:00'
: 'Off',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'AI network',
value:
'${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}',
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _configureLlm(context, ref),
icon: const Icon(Icons.smart_toy_rounded),
label: Text(
ref.watch(llmConfigProvider).isConfigured
? 'Configure AI'
: 'Setup AI',
),
),
OutlinedButton.icon(
onPressed: () => _toggleDigest(context, ref),
icon: Icon(
digestConfig.enabled
? Icons.event_busy_rounded
: Icons.event_repeat_rounded,
),
label: Text(
digestConfig.enabled
? 'Disable Weekly AI Digest'
: 'Enable Weekly AI Digest',
),
),
OutlinedButton.icon(
onPressed: ref.watch(llmConfigProvider).isConfigured
? () => _runPrivateDigest(context, ref)
: null,
icon: const Icon(Icons.auto_awesome_rounded),
label: const Text('Run Private Digest Now'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
const AiDigestReviewView(),
),
),
icon: const Icon(Icons.rate_review_rounded),
label: const Text('Open AI Review'),
),
OutlinedButton.icon(
onPressed: session == null
? null
: () => _signOut(context, ref),
icon: const Icon(Icons.logout_rounded),
label: const Text('Sign Out'),
),
OutlinedButton.icon(
onPressed: session == null
? null
: () {
ref
.read(
sessionControllerProvider.notifier,
)
.refreshProfile();
},
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh Profile'),
),
OutlinedButton.icon(
onPressed: AppConfig.enableLocalNotifications
? () => _requestNotificationPermissions(
context,
ref,
)
: null,
icon: const Icon(
Icons.notifications_active_rounded,
),
label: const Text('Request Notification Access'),
),
OutlinedButton.icon(
onPressed: () =>
_simulateShareCapture(context, ref),
icon: const Icon(Icons.message_rounded),
label: const Text('Simulate Share Capture'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
const ShareInboxView(),
),
),
icon: const Icon(Icons.inbox_rounded),
label: const Text('Open Share Inbox'),
),
OutlinedButton.icon(
onPressed: () => _showShareDiagnostics(context),
icon: const Icon(Icons.bug_report_rounded),
label: const Text('Share Diagnostics'),
),
],
),
],
),
),
const SizedBox(height: 16),
FrostedCard(
child: Text(
'Local-first privacy\n• Relationship details stay on this device in the current build.\n• Scheduled AI uses pseudonymous person tokens and a review inbox.\n• Names, aliases, sender names, URLs, and raw shared text are not sent in the digest payload.\n• iOS background digest runs are best-effort; manual runs are available here.\n\nRun options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api',
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
),
);
},
);
}
String _providerLabel(LlmProvider provider) {
switch (provider) {
case LlmProvider.openai:
return 'OpenAI';
case LlmProvider.anthropic:
return 'Anthropic';
case LlmProvider.google:
return 'Google AI';
}
}
String _weekdayLabel(int weekday) {
return switch (weekday) {
DateTime.monday => 'Mon',
DateTime.tuesday => 'Tue',
DateTime.wednesday => 'Wed',
DateTime.thursday => 'Thu',
DateTime.friday => 'Fri',
DateTime.saturday => 'Sat',
DateTime.sunday => 'Sun',
_ => 'Sun',
};
}
Future<void> _configureLlm(BuildContext context, WidgetRef ref) async {
final LlmConfigState current = ref.read(llmConfigProvider);
final _LlmConfigDraft? draft = await showDialog<_LlmConfigDraft>(
context: context,
builder: (BuildContext context) => _LlmConfigDialog(
currentProvider: current.provider,
hasApiKey: current.apiKeyConfigured,
),
);
if (draft == null || !context.mounted) {
return;
}
await ref.read(llmConfigProvider.notifier).setProvider(draft.provider);
if (draft.clearApiKey) {
await ref.read(llmConfigProvider.notifier).clearApiKey();
} else if (draft.apiKey.isNotEmpty) {
await ref.read(llmConfigProvider.notifier).setApiKey(draft.apiKey);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
draft.apiKey.isNotEmpty
? 'AI configuration saved.'
: draft.clearApiKey
? 'AI API key cleared.'
: 'AI configuration saved.',
),
),
);
}
}
Future<void> _toggleDigest(BuildContext context, WidgetRef ref) async {
final LlmDigestConfigState current = ref.read(llmDigestConfigProvider);
final bool nextEnabled = !current.enabled;
await ref.read(llmDigestConfigProvider.notifier).setEnabled(nextEnabled);
await ref
.read(llmDigestBackgroundSchedulerProvider)
.configure(ref.read(llmDigestConfigProvider));
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
nextEnabled
? 'Weekly private AI digest enabled.'
: 'Weekly private AI digest disabled.',
),
),
);
}
Future<void> _runPrivateDigest(BuildContext context, WidgetRef ref) async {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Running private AI digest...')),
);
final LlmDigestRunResult result = await ref
.read(llmDigestOrchestratorProvider)
.runManualDigest();
if (!context.mounted) {
return;
}
messenger.showSnackBar(
SnackBar(
content: Text(
result.completed
? 'Private digest created ${result.createdDraftCount} suggestions.'
: result.reason ?? 'Private digest did not complete.',
),
),
);
}
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
await ref.read(sessionControllerProvider.notifier).signOut();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Signed out.')));
}
}
Future<void> _requestNotificationPermissions(
BuildContext context,
WidgetRef ref,
) async {
final bool granted = await ref
.read(reminderSchedulerProvider)
.requestPermissions();
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
granted
? 'Notification access granted.'
: 'Notification access not granted.',
),
),
);
}
Future<void> _simulateShareCapture(
BuildContext context,
WidgetRef ref,
) async {
final String platformName = Theme.of(context).platform.name;
final _ShareSimulationDraft? draft =
await showDialog<_ShareSimulationDraft>(
context: context,
builder: (BuildContext context) => const _ShareSimulationDialog(),
);
if (draft == null) {
return;
}
if (!context.mounted) {
return;
}
final SharedPayload? payload = buildSharedPayloadFromRawText(
rawText: draft.rawText,
platform: platformName,
sourceAppHint: draft.sourceAppHint,
);
if (payload == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Shared payload is empty.')));
return;
}
final SharedMessageIngestResult? result = await startShareCaptureReview(
context,
ref: ref,
payload: payload,
);
if (!context.mounted || result == null) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(shareCaptureResultMessage(result)),
action: SnackBarAction(
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
onPressed: () =>
openShareCaptureDestination(context, ref: ref, result: result),
),
),
);
}
Future<void> _showShareDiagnostics(BuildContext context) async {
final ShareIntakeDebugLog debugLog = const ShareIntakeDebugLog();
final List<ShareIntakeDebugEvent> events = await debugLog.read();
if (!context.mounted) {
return;
}
await showDialog<void>(
context: context,
builder: (BuildContext context) => _ShareDiagnosticsDialog(
events: events,
onClear: () async {
await debugLog.clear();
if (context.mounted) {
Navigator.of(context).pop();
}
},
),
);
}
}
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
required this.compact,
});
final String label;
final String value;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyLarge),
),
],
);
}
}
class _ShareDiagnosticsDialog extends StatelessWidget {
const _ShareDiagnosticsDialog({required this.events, required this.onClear});
final List<ShareIntakeDebugEvent> events;
final Future<void> Function() onClear;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Share Diagnostics'),
content: SizedBox(
width: 520,
child: events.isEmpty
? const Text(
'No Flutter share events recorded yet. If you tried sharing, the native share extension likely did not open the app or the plugin did not receive the URL callback.',
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: events
.map(
(ShareIntakeDebugEvent event) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SelectableText(
'${_debugTimeLabel(event.at)} ${event.stage}\n${event.message}',
),
),
)
.toList(growable: false),
),
),
),
actions: <Widget>[
TextButton(
onPressed: events.isEmpty ? null : onClear,
child: const Text('Clear'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
}
}
String _debugTimeLabel(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:${value.second.toString().padLeft(2, '0')}';
}
class _LlmConfigDraft {
const _LlmConfigDraft({
required this.provider,
required this.apiKey,
this.clearApiKey = false,
});
final LlmProvider provider;
final String apiKey;
final bool clearApiKey;
}
class _LlmConfigDialog extends StatefulWidget {
const _LlmConfigDialog({
required this.currentProvider,
required this.hasApiKey,
});
final LlmProvider currentProvider;
final bool hasApiKey;
@override
State<_LlmConfigDialog> createState() => _LlmConfigDialogState();
}
class _LlmConfigDialogState extends State<_LlmConfigDialog> {
late LlmProvider _selectedProvider;
late final TextEditingController _apiKeyController;
@override
void initState() {
super.initState();
_selectedProvider = widget.currentProvider;
_apiKeyController = TextEditingController();
}
@override
void dispose() {
_apiKeyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Configure AI Provider'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('Provider'),
const SizedBox(height: 8),
DropdownButtonFormField<LlmProvider>(
initialValue: _selectedProvider,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: const <DropdownMenuItem<LlmProvider>>[
DropdownMenuItem<LlmProvider>(
value: LlmProvider.openai,
child: Text('OpenAI'),
),
DropdownMenuItem<LlmProvider>(
value: LlmProvider.anthropic,
child: Text('Anthropic (Claude)'),
),
DropdownMenuItem<LlmProvider>(
value: LlmProvider.google,
child: Text('Google AI'),
),
],
onChanged: (LlmProvider? value) {
if (value != null) {
setState(() {
_selectedProvider = value;
});
}
},
),
const SizedBox(height: 16),
const Text('API Key'),
const SizedBox(height: 8),
TextField(
controller: _apiKeyController,
obscureText: true,
decoration: InputDecoration(
hintText: widget.hasApiKey
? 'Leave empty to keep current'
: 'Enter your API key',
border: const OutlineInputBorder(),
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.hasApiKey)
TextButton(
onPressed: () {
Navigator.of(context).pop(
_LlmConfigDraft(
provider: _selectedProvider,
apiKey: '',
clearApiKey: true,
),
);
},
child: const Text('Clear Key'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop(
_LlmConfigDraft(
provider: _selectedProvider,
apiKey: _apiKeyController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
}
class _ShareSimulationDraft {
const _ShareSimulationDraft({
required this.rawText,
required this.sourceAppHint,
});
final String rawText;
final String sourceAppHint;
}
class _ShareSimulationDialog extends StatefulWidget {
const _ShareSimulationDialog();
@override
State<_ShareSimulationDialog> createState() => _ShareSimulationDialogState();
}
class _ShareSimulationDialogState extends State<_ShareSimulationDialog> {
late final TextEditingController _controller;
String _selectedSourceApp = 'whatsapp';
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: '[2/18/26, 20:11] Ava Hart: Can we do coffee this Saturday?',
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Simulate Share Capture'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DropdownButtonFormField<String>(
initialValue: _selectedSourceApp,
decoration: const InputDecoration(labelText: 'Source app'),
items: const <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: 'whatsapp',
child: Text('WhatsApp'),
),
DropdownMenuItem<String>(value: 'signal', child: Text('Signal')),
DropdownMenuItem<String>(value: 'notes', child: Text('Notes')),
DropdownMenuItem<String>(
value: 'share_sheet',
child: Text('Generic text/link'),
),
],
onChanged: (String? value) {
if (value == null) {
return;
}
setState(() {
_selectedSourceApp = value;
});
},
),
const SizedBox(height: 12),
TextField(
controller: _controller,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
hintText: 'Paste shared text or URL payload',
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String rawText = _controller.text.trim();
if (rawText.isEmpty) {
return;
}
Navigator.of(context).pop(
_ShareSimulationDraft(
rawText: rawText,
sourceAppHint: _selectedSourceApp,
),
);
},
child: const Text('Import'),
),
],
);
}
}