Files
rely/lib/features/settings/presentation/settings_view.dart
T
2026-05-19 19:17:35 +02:00

1303 lines
44 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.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/core/llm/llm_diagnostics_log.dart';
import 'package:relationship_saver/core/llm/llm_service.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: _llmStatusLabel(ref.watch(llmConfigProvider)),
),
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 grounding',
value: digestConfig.enableWebSearch
? 'Google Search enabled'
: 'Model only',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Scheduled 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: () => _showLlmDebug(context),
icon: const Icon(Icons.terminal_rounded),
label: const Text('LLM Debug'),
),
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: () => _toggleWebSearch(context, ref),
icon: Icon(
digestConfig.enableWebSearch
? Icons.public_off_rounded
: Icons.public_rounded,
),
label: Text(
digestConfig.enableWebSearch
? 'Disable Web Grounding'
: 'Enable Web Grounding',
),
),
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 _llmStatusLabel(LlmConfigState state) {
if (!state.isConfigured) {
return 'Not configured';
}
return '${state.provider.label} / ${state.model}';
}
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,
currentBaseUrl: current.baseUrl,
currentModel: current.model,
hasApiKey: current.apiKeyConfigured,
),
);
if (draft == null || !context.mounted) {
return;
}
await ref
.read(llmConfigProvider.notifier)
.saveConfiguration(
provider: draft.provider,
baseUrl: draft.baseUrl,
model: draft.model,
apiKey: draft.apiKey,
clearApiKey: draft.clearApiKey,
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
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> _toggleWebSearch(BuildContext context, WidgetRef ref) async {
final LlmDigestConfigState current = ref.read(llmDigestConfigProvider);
final bool nextEnabled = !current.enableWebSearch;
await ref
.read(llmDigestConfigProvider.notifier)
.setWebSearchEnabled(nextEnabled);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
nextEnabled
? 'Web grounding enabled for private digest.'
: 'Web grounding disabled for private digest.',
),
),
);
}
Future<void> _showLlmDebug(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (BuildContext context) => const _LlmDebugDialog(),
);
}
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,
) => _ShareDiagnosticsEventTile(
event: event,
label:
'${_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'),
),
],
);
}
}
class _ShareDiagnosticsEventTile extends StatelessWidget {
const _ShareDiagnosticsEventTile({required this.event, required this.label});
final ShareIntakeDebugEvent event;
final String label;
@override
Widget build(BuildContext context) {
final String? details = event.details;
if (details == null || details.isEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: SelectableText(label),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(top: 6, bottom: 8),
title: SelectableText(label),
subtitle: Text(
'Tap to show raw shared text (${details.length} chars).',
),
children: <Widget>[
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Align(
alignment: Alignment.centerLeft,
child: SelectableText(details),
),
),
),
],
),
);
}
}
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.baseUrl,
required this.model,
this.apiKey,
this.clearApiKey = false,
});
final LlmProvider provider;
final String baseUrl;
final String model;
final String? apiKey;
final bool clearApiKey;
}
class _LlmConfigDialog extends ConsumerStatefulWidget {
const _LlmConfigDialog({
required this.currentProvider,
required this.currentBaseUrl,
required this.currentModel,
required this.hasApiKey,
});
final LlmProvider currentProvider;
final String currentBaseUrl;
final String currentModel;
final bool hasApiKey;
@override
ConsumerState<_LlmConfigDialog> createState() => _LlmConfigDialogState();
}
class _LlmConfigDialogState extends ConsumerState<_LlmConfigDialog> {
late LlmProvider _selectedProvider;
late final TextEditingController _apiKeyController;
late final TextEditingController _baseUrlController;
late final TextEditingController _modelController;
List<LlmModelInfo> _models = const <LlmModelInfo>[];
bool _loadingModels = false;
String? _modelLoadMessage;
@override
void initState() {
super.initState();
_selectedProvider = widget.currentProvider;
_apiKeyController = TextEditingController();
_baseUrlController = TextEditingController(text: widget.currentBaseUrl);
_modelController = TextEditingController(text: widget.currentModel);
}
@override
void dispose() {
_apiKeyController.dispose();
_baseUrlController.dispose();
_modelController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bool apiKeyVisible =
_selectedProvider.requiresApiKey ||
_selectedProvider == LlmProvider.openaiCompatible;
return AlertDialog(
title: const Text('LLM Connection'),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
DropdownButtonFormField<LlmProvider>(
initialValue: _selectedProvider,
decoration: const InputDecoration(
labelText: 'Provider',
border: OutlineInputBorder(),
),
items: LlmProvider.values
.map(
(LlmProvider provider) => DropdownMenuItem<LlmProvider>(
value: provider,
child: Text(provider.label),
),
)
.toList(growable: false),
onChanged: (LlmProvider? value) {
if (value == null) {
return;
}
setState(() {
_selectedProvider = value;
_baseUrlController.text = value.defaultBaseUrl;
_modelController.text = value.defaultModel;
_models = const <LlmModelInfo>[];
_modelLoadMessage = null;
});
},
),
const SizedBox(height: 12),
TextField(
controller: _baseUrlController,
decoration: InputDecoration(
labelText: _selectedProvider.hasConfigurableBaseUrl
? 'Base URL'
: 'Provider endpoint',
helperText: _selectedProvider == LlmProvider.openaiCompatible
? 'Use the OpenAI-compatible API base, usually ending in /v1.'
: null,
border: const OutlineInputBorder(),
),
),
if (apiKeyVisible) ...<Widget>[
const SizedBox(height: 12),
TextField(
controller: _apiKeyController,
obscureText: true,
decoration: InputDecoration(
labelText: _selectedProvider.requiresApiKey
? 'API key'
: 'API key (optional)',
hintText: widget.hasApiKey
? 'Leave empty to keep current key'
: 'Paste provider key',
border: const OutlineInputBorder(),
),
),
],
const SizedBox(height: 12),
Row(
children: <Widget>[
Expanded(
child: _models.isEmpty
? TextField(
controller: _modelController,
decoration: const InputDecoration(
labelText: 'Model',
border: OutlineInputBorder(),
),
)
: DropdownButtonFormField<String>(
initialValue: _modelController.text.isNotEmpty
? _modelController.text
: _models.first.id,
decoration: const InputDecoration(
labelText: 'Model',
border: OutlineInputBorder(),
),
items: _models
.map(
(LlmModelInfo model) =>
DropdownMenuItem<String>(
value: model.id,
child: Text(model.label),
),
)
.toList(growable: false),
onChanged: (String? value) {
if (value != null) {
_modelController.text = value;
}
},
),
),
const SizedBox(width: 8),
IconButton.outlined(
onPressed: _loadingModels ? null : _loadModels,
icon: _loadingModels
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.cloud_sync_rounded),
tooltip: 'Load models from provider',
),
],
),
if (_modelLoadMessage != null) ...<Widget>[
const SizedBox(height: 8),
Text(
_modelLoadMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
],
const SizedBox(height: 12),
Text(
_selectedProvider == LlmProvider.ollama
? 'Ollama reads installed local models from /api/tags and sends chat requests to /api/chat.'
: 'Model discovery uses this provider connection before saving the selected model.',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.hasApiKey)
TextButton(
onPressed: () {
Navigator.of(context).pop(
_LlmConfigDraft(
provider: _selectedProvider,
baseUrl: _baseUrlController.text.trim(),
model: _modelController.text.trim(),
clearApiKey: true,
),
);
},
child: const Text('Clear Key'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop(
_LlmConfigDraft(
provider: _selectedProvider,
baseUrl: _baseUrlController.text.trim(),
model: _modelController.text.trim(),
apiKey: _apiKeyController.text.trim().isEmpty
? null
: _apiKeyController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
Future<void> _loadModels() async {
setState(() {
_loadingModels = true;
_modelLoadMessage = null;
});
try {
final List<LlmModelInfo> models = await ref
.read(llmServiceProvider)
.listAvailableModels(
provider: _selectedProvider,
apiKey: _apiKeyController.text.trim().isEmpty
? null
: _apiKeyController.text.trim(),
baseUrl: _baseUrlController.text.trim(),
);
if (!mounted) {
return;
}
setState(() {
_models = models;
if (models.isNotEmpty &&
!_models.any(
(LlmModelInfo model) => model.id == _modelController.text,
)) {
_modelController.text = models.first.id;
}
_modelLoadMessage = models.isEmpty
? 'No models were returned by this provider.'
: 'Loaded ${models.length} models.';
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_modelLoadMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_loadingModels = false;
});
}
}
}
}
class _LlmDebugDialog extends ConsumerStatefulWidget {
const _LlmDebugDialog();
@override
ConsumerState<_LlmDebugDialog> createState() => _LlmDebugDialogState();
}
class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> {
late final TextEditingController _promptController;
bool _running = false;
bool _loadingEvents = true;
String? _response;
String? _error;
List<LlmDiagnosticsEvent> _events = const <LlmDiagnosticsEvent>[];
@override
void initState() {
super.initState();
_promptController = TextEditingController(
text: 'Reply with one short sentence confirming the connection works.',
);
_loadDiagnostics();
}
@override
void dispose() {
_promptController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final LlmConfigState config = ref.watch(llmConfigProvider);
return AlertDialog(
title: const Text('LLM Debug'),
content: SizedBox(
width: 560,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_LlmDebugConnectionSummary(config: config),
const SizedBox(height: 12),
TextField(
controller: _promptController,
minLines: 3,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'Test prompt',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
if (_running) const LinearProgressIndicator(),
if (_error != null) ...<Widget>[
const SizedBox(height: 12),
SelectableText(
_error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
if (_response != null) ...<Widget>[
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Align(
alignment: Alignment.centerLeft,
child: SelectableText(_response!),
),
),
),
],
const SizedBox(height: 18),
Row(
children: <Widget>[
Expanded(
child: Text(
'Recent diagnostics',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton.icon(
onPressed: _events.isEmpty ? null : _copyDiagnostics,
icon: const Icon(Icons.copy_rounded),
label: const Text('Copy'),
),
TextButton.icon(
onPressed: _events.isEmpty ? null : _clearDiagnostics,
icon: const Icon(Icons.delete_outline_rounded),
label: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
if (_loadingEvents)
const LinearProgressIndicator()
else if (_events.isEmpty)
Text(
'No LLM diagnostics recorded yet.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
)
else
..._events
.take(8)
.map(
(LlmDiagnosticsEvent event) =>
_LlmDiagnosticsEventTile(event: event),
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: _running ? null : () => Navigator.of(context).pop(),
child: const Text('Close'),
),
FilledButton.icon(
onPressed: !_running && config.isConfigured ? _runDebugPrompt : null,
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Send Test'),
),
],
);
}
Future<void> _loadDiagnostics() async {
final List<LlmDiagnosticsEvent> events = await ref
.read(llmDiagnosticsLogProvider)
.read();
if (!mounted) {
return;
}
setState(() {
_events = events;
_loadingEvents = false;
});
}
Future<void> _copyDiagnostics() async {
final LlmDiagnosticsLog log = ref.read(llmDiagnosticsLogProvider);
await Clipboard.setData(ClipboardData(text: log.exportText(_events)));
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('LLM diagnostics copied.')));
}
Future<void> _clearDiagnostics() async {
await ref.read(llmDiagnosticsLogProvider).clear();
if (!mounted) {
return;
}
setState(() {
_events = const <LlmDiagnosticsEvent>[];
});
}
Future<void> _runDebugPrompt() async {
setState(() {
_running = true;
_response = null;
_error = null;
});
try {
final String response = await ref
.read(llmServiceProvider)
.completeText(
systemPrompt:
'You are a connectivity probe for a relationship app. Answer briefly.',
userPrompt: _promptController.text.trim(),
);
if (!mounted) {
return;
}
setState(() {
_response = response;
});
await _loadDiagnostics();
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_error = error.toString();
});
await _loadDiagnostics();
} finally {
if (mounted) {
setState(() {
_running = false;
});
}
}
}
}
class _LlmDiagnosticsEventTile extends StatelessWidget {
const _LlmDiagnosticsEventTile({required this.event});
final LlmDiagnosticsEvent event;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'${event.stage} · ${event.at.toLocal().toIso8601String()}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
SelectableText(event.message),
if (event.details != null &&
event.details!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 6),
SelectableText(
event.details!,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
],
),
);
}
}
class _LlmDebugConnectionSummary extends StatelessWidget {
const _LlmDebugConnectionSummary({required this.config});
final LlmConfigState config;
@override
Widget build(BuildContext context) {
final TextStyle? labelStyle = Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(config.isConfigured ? 'Configured' : 'Not configured'),
const SizedBox(height: 6),
Text('Provider: ${config.provider.label}', style: labelStyle),
Text(
'Model: ${config.model.isEmpty ? 'Not selected' : config.model}',
style: labelStyle,
),
Text('Endpoint: ${config.baseUrl}', style: labelStyle),
],
);
}
}
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'),
),
],
);
}
}