conduit/lib/main.dart
cogwheel acdda47c61 Return to chat after signing in from the settings sheet
The native settings sheet's Connect to Open WebUI row pushed the connect
flow over /chat. go_router keeps the base location for pushed routes, so
the redirect still saw /chat after login and never left the Sign in
page. Go to the flow instead, as the chooser and profile page do.
2026-09-28 11:53:37 +05:30

1218 lines
43 KiB
Dart

import 'dart:async';
import 'dart:developer' as developer;
import 'package:conduit_core/conduit_core.dart';
import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart'
show LicenseEntryWithLineBreaks, LicenseRegistry;
import 'package:flutter_driver/driver_extension.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'shared/widgets/error_boundary.dart';
import 'platform/android_ime_inset_resync.dart';
import 'platform/flutter_app_lifecycle.dart';
import 'platform/flutter_clipboard_port.dart';
import 'platform/flutter_connectivity_port.dart';
import 'platform/url_launcher_open_external_url_port.dart';
import 'platform/flutter_cookie_jar.dart';
import 'platform/flutter_flush_scheduler.dart';
import 'platform/flutter_post_frame_scheduler.dart';
import 'platform/ios_display_boost.dart';
import 'platform/mobile_background_execution.dart';
import 'package:conduit_core/services/share_staging_cleanup.dart'
show shareStagingDirectoryName;
import 'platform/ios_share_staging.dart';
import 'platform/just_audio_playback.dart';
import 'platform/record_audio_capture.dart';
import 'platform/flutter_secure_key_value_store.dart';
import 'platform/flutter_key_value_store.dart';
import 'platform/flutter_log_sink.dart';
import 'platform/flutter_worker_port.dart';
import 'platform/flutter_database_opener.dart';
import 'package:conduit_core/providers/app_providers.dart';
import 'package:conduit_core/providers/host_ports.dart';
import 'package:conduit_core/network/conduit_user_agent.dart';
import 'core/persistence/hive_bootstrap.dart';
import 'package:conduit_core/persistence/hive_prefs_migrator.dart';
import 'core/persistence/persistence_migrator.dart';
import 'package:conduit_core/persistence/persistence_providers.dart';
import 'package:conduit_core/persistence/preferences_store.dart';
import 'core/router/app_router.dart';
import 'core/services/native_sheet_bridge.dart';
import 'core/services/native_sheet_hydration_service.dart';
import 'shared/services/navigation_service.dart';
import 'shared/services/raster_media_policy.dart';
import 'platform/carplay_service.dart';
import 'package:conduit_core/services/readiness_gated_secure_storage.dart';
import 'package:conduit_core/services/settings_service.dart';
import 'package:conduit_core/sync/request_completion_runner_provider.dart';
import 'core/utils/tts_voice_utils.dart';
import 'core/utils/current_localizations.dart';
import 'features/chat/services/request_completion_runner.dart';
import 'features/chat/providers/text_to_speech_provider.dart';
import 'features/chat/providers/chat_providers.dart'
show chatWakelockCoordinatorProvider, restoreDefaultModel;
import 'features/release_notes/release_notes_bootstrap.dart';
import 'features/release_notes/release_notes_coordinator.dart';
import 'features/release_notes/data/release_notes_repository.dart';
import 'features/release_notes/release_notes_presenter.dart';
import 'l10n/conduit_localizations.dart';
import 'shared/widgets/legacy_design_compatibility.dart';
import 'package:conduit_core/features/tools/providers/tools_providers.dart';
import 'features/workspace/providers/workspace_capabilities_provider.dart';
import 'features/workspace/workspace_navigation.dart';
import 'package:conduit_core/utils/debug_logger.dart';
import 'core/utils/system_ui_style.dart';
import 'package:conduit_core/models/tool.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'platform/quick_actions_service.dart';
import 'core/providers/app_startup_providers.dart';
import 'features/notifications/services/local_notification_service.dart';
import 'shared/widgets/sign_out_options_dialog.dart';
import 'shared/theme/theme_extensions.dart';
import 'shared/theme/theme_providers.dart';
import 'platform/frame_profiler.dart';
import 'features/direct_connections/providers/apple_pcc_providers.dart';
import 'package:conduit_core/features/direct_connections/providers/direct_connection_providers.dart';
import 'shared/services/app_package_info.dart';
import 'package:conduit_core/features/hermes/providers/hermes_providers.dart';
import 'features/hermes/services/hermes_dashboard_rest_bridge.dart';
const bool _enableFlutterDriverExtension = bool.fromEnvironment(
'ENABLE_FLUTTER_DRIVER_EXTENSION',
defaultValue: false,
);
const _nativeSheetFollowUpDelay = Duration(milliseconds: 700);
Locale? _localeFromNativeTag(String code) {
final normalized = code.replaceAll('_', '-');
final parts = normalized.split('-');
if (parts.isEmpty || parts.first.isEmpty) return null;
final language = parts.first;
String? script;
String? country;
for (var i = 1; i < parts.length; i++) {
final part = parts[i];
if (part.length == 4) {
script = '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}';
} else if (part.length == 2 || part.length == 3) {
country = part.toUpperCase();
}
}
return Locale.fromSubtags(
languageCode: language,
scriptCode: script,
countryCode: country,
);
}
developer.TimelineTask? _startupTimeline;
Future<void> _configureUserAgent() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
ConduitUserAgent.configure(appVersion: packageInfo.version);
_startupTimeline?.instant('user_agent_ready');
} catch (error, stackTrace) {
DebugLogger.error(
'user-agent-version-unavailable',
scope: 'app/startup',
error: error,
stackTrace: stackTrace,
);
}
}
void _registerBundledLicenses() {
LicenseRegistry.addLicense(() async* {
final notice = await rootBundle.loadString('THIRD_PARTY_NOTICES.md');
yield LicenseEntryWithLineBreaks(const ['Open WebUI icon'], notice);
});
}
void main() {
// Diagnostics have no destination until a host gives them one.
// Installed first, so startup itself is logged.
DebugLogger.sink = const FlutterLogSink();
AudioPlaybackPort.hostFactory = JustAudioPlayback.new;
BackgroundExecutionPort.hostDefault = const MobileBackgroundExecution();
DisplayBoostPort.hostDefault = const IosDisplayBoost();
ShareStagingPort.hostDefault = IosShareStaging(
stagingDirectoryName: shareStagingDirectoryName,
);
AudioCapturePort.hostFactory = RecordAudioCapture.new;
// The preference store is a host capability too; installed
// before bootstrap awaits its first synchronous read.
PreferencesStore.installLoader(FlutterKeyValueStore.load);
if (_enableFlutterDriverExtension) {
enableFlutterDriverExtension();
}
runZonedGuarded(
() async {
WidgetsFlutterBinding.ensureInitialized();
// Conduit intentionally owns separate direct-local and per-server files.
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
RasterMediaPolicy.configureGlobalImageCache();
// Measure the complete Dart-side startup path, including the first plugin
// calls. Package metadata is not required to paint the auth/theme shell;
// ConduitUserAgent has a safe fallback until this best-effort update lands.
_startupTimeline = developer.TimelineTask();
_startupTimeline!.start('app_startup');
_startupTimeline!.instant('bindings_initialized');
unawaited(_configureUserAgent());
_registerBundledLicenses();
FrameProfiler.instance.attachFrameTimings();
AndroidImeInsetResync.instance.install();
// Global error handlers
FlutterError.onError = (FlutterErrorDetails details) {
DebugLogger.error(
'flutter-error',
scope: 'app/framework',
error: details.exception,
);
final stack = details.stack;
if (stack != null) {
debugPrintStack(stackTrace: stack);
}
};
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
DebugLogger.error(
'platform-error',
scope: 'app/platform',
error: error,
stackTrace: stack,
);
debugPrintStack(stackTrace: stack);
return true;
};
// Edge-to-edge is now handled natively in MainActivity.kt for Android 15+
// No need for SystemUiMode.edgeToEdge which is deprecated
_startupTimeline?.instant('edge_to_edge_configured');
// Platform options now live inside the adapter, so they cannot drift
// apart from the ones SecureCredentialStorage used to keep separately.
final secureStorage = FlutterSecureKeyValueStore();
// Start independent platform/file work together. Quick Actions still
// completes before runApp so a cold-launch action cannot be lost, while
// storage initialization progresses in parallel with that plugin call.
// Keep the underlying Keychain operation separate from the bounded
// startup wait. `Future.timeout` does not cancel its source; awaiting
// only the wrapper would let auth bootstrap start a second Keychain
// read while the first one was still executing on iOS.
final keychainWarmupRead = secureStorage
.read(key: '_warmup')
.catchError((Object _) => null);
final keychainWarmupBarrier = keychainWarmupRead.then<void>((_) {});
final keychainWarmupDeadline = waitForSecureStorageStartupDeadline(
keychainWarmupBarrier,
);
unawaited(
keychainWarmupRead.then<void>((_) {
_startupTimeline?.instant('secure_storage_ready');
}),
);
final hiveBoxesFuture = HiveBootstrap.instance.ensureInitialized();
final preferencesFuture = PreferencesStore.ensureInitialized();
final platformUiFuture = PlatformUiCapabilities.initialize();
try {
await QuickActionsBootstrap.initialize();
} catch (error, stackTrace) {
DebugLogger.error(
'quick-actions-bootstrap',
scope: 'app/platform',
error: error,
stackTrace: stackTrace,
);
}
// Initialize Hive and preferences concurrently. Preferences must still be
// ready before ProviderContainer construction because theme/locale reads
// are synchronous.
final hiveBoxes = await hiveBoxesFuture;
_startupTimeline?.instant('hive_ready');
await preferencesFuture;
_startupTimeline?.instant('prefs_ready');
await platformUiFuture;
_startupTimeline?.instant('platform_ui_ready');
// Run migration checks (fast-pathed after first run).
final migrator = PersistenceMigrator(hiveBoxes: hiveBoxes);
await migrator.migrateIfNeeded();
// Copy Hive-resident preferences into shared_preferences (PR-1 of the
// Hive removal). Runs once; gated + crash-safe.
await HivePrefsMigrator(hiveBoxes: hiveBoxes).migrateIfNeeded();
await captureReleaseNotesInstallProvenance();
_startupTimeline?.instant('migration_complete');
// Bound time-to-first-paint even if the platform call stalls. Provider
// reads use the original in-flight operation as their barrier below, so
// timing out here never starts a concurrent second Keychain access.
await keychainWarmupDeadline;
// Finish timeline after first frame paints
WidgetsBinding.instance.addPostFrameCallback((_) {
_startupTimeline?.instant('first_frame_rendered');
_startupTimeline?.finish();
_startupTimeline = null;
});
// One WidgetsBindingObserver for the whole app; every engine that
// cares about foreground/background now shares it.
final appLifecycle = FlutterAppLifecycle();
final providerContainer = ProviderContainer(
overrides: [
// Host ports. `lib/core` declares these without a value so it
// stays free of Flutter plugins; each host binds its own.
databaseOpenerProvider.overrideWithValue(
const FlutterDatabaseOpener(),
),
appLifecycleProvider.overrideWithValue(appLifecycle),
workerPortProvider.overrideWithValue(const FlutterWorkerPort()),
connectivityPortProvider.overrideWithValue(FlutterConnectivityPort()),
cookieJarProvider.overrideWithValue(const FlutterCookieJar()),
flushSchedulerProvider.overrideWithValue(
const FlutterFlushScheduler(),
),
postFrameSchedulerProvider.overrideWithValue(
const FlutterPostFrameScheduler(),
),
signOutResetTargetsProvider.overrideWithValue(
themePreferenceResetTargets,
),
hostDirectProviderAdaptersProvider.overrideWith(
(ref) => [ref.watch(applePccAdapterProvider)],
),
hostHermesDashboardBridgeFactoryProvider.overrideWith(
(ref) =>
({required root}) => HermesDashboardRestBridge(
config: ref.read(hermesConfigProvider),
root: root,
),
),
clipboardPortProvider.overrideWithValue(const FlutterClipboardPort()),
openExternalUrlProvider.overrideWithValue(
const UrlLauncherOpenExternalUrlPort(),
),
secureStorageProvider.overrideWithValue(
ReadinessGatedSecureStorage(
delegate: secureStorage,
readiness: keychainWarmupBarrier,
),
),
hiveBoxesProvider.overrideWithValue(hiveBoxes),
// Inversion seam (E3): the core/sync drainer reads the no-op
// RequestCompletionRunner stub; bind it to the chat implementation so
// queued completions re-enter the streaming pipeline.
requestCompletionRunnerProvider.overrideWith(
(ref) => ref.watch(chatRequestCompletionRunnerProvider),
),
],
);
// CarPlay can cold-launch Conduit without a visible Flutter scene, so
// install its method-channel handler before frame-scheduled startup work.
providerContainer.read(carPlayCoordinatorProvider);
installConduitErrorWidgetBuilder();
runApp(
UncontrolledProviderScope(
container: providerContainer,
child: const ConduitApp(),
),
);
developer.Timeline.instantSync('runApp_called');
},
(error, stack) {
DebugLogger.error(
'zone-error',
scope: 'app',
error: error,
stackTrace: stack,
);
debugPrintStack(stackTrace: stack);
},
);
}
class ConduitApp extends ConsumerStatefulWidget {
const ConduitApp({super.key});
@override
ConsumerState<ConduitApp> createState() => _ConduitAppState();
}
class _ConduitAppState extends ConsumerState<ConduitApp> {
Brightness? _lastAppliedOverlayBrightness;
StreamSubscription<NativeSheetEvent>? _nativeSheetSubscription;
final Map<String, String> _nativeSheetDraftValues = {};
Future<void> _nativeSheetControlQueue = Future<void>.value();
@override
void initState() {
super.initState();
ref.read(userScopedProviderCleanupProvider);
ref.read(quickActionsCoordinatorProvider);
ref.read(chatWakelockCoordinatorProvider);
_nativeSheetSubscription = NativeSheetBridge.instance.events.listen(
_handleNativeSheetEvent,
);
// Delay heavy provider initialization until after the first frame so the
// initial paint stays responsive.
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeAppState());
}
void _handleNativeSheetEvent(NativeSheetEvent event) {
switch (event) {
case NativeSheetLogoutRequested():
unawaited(_handleNativeSheetLogoutRequested());
case NativeSheetDismissed():
_nativeSheetDraftValues.clear();
break;
case NativeSheetControlChanged(
id: 'sign-out',
value: final bool keepServerDetails,
):
unawaited(
_handleNativeSheetLogoutRequested(
keepServerDetails: keepServerDetails,
),
);
case NativeSheetControlChanged():
_nativeSheetControlQueue = _nativeSheetControlQueue.then(
(_) => _handleNativeSheetControlChanged(event),
);
case NativeSheetDetailAppeared(:final detailId):
unawaited(
ref.read(nativeSheetHydrationServiceProvider).hydrateDetail(detailId),
);
case NativeEditProfileCommitted():
unawaited(_handleNativeEditProfileCommitted(event));
}
}
Future<void> _handleNativeSheetLogoutRequested({
bool? keepServerDetails,
}) async {
try {
var resolvedKeepServerDetails = keepServerDetails;
if (resolvedKeepServerDetails == null) {
final navigatorContext = NavigationService.context;
if (navigatorContext == null) {
throw StateError('Native sign-out navigator is unavailable.');
}
resolvedKeepServerDetails = await showSignOutOptionsDialog(
navigatorContext,
);
}
if (!mounted || resolvedKeepServerDetails == null) return;
await ref
.read(signOutCoordinatorProvider)
.signOut(keepServerDetails: resolvedKeepServerDetails);
} catch (error, stackTrace) {
DebugLogger.error(
'native-sign-out-failed',
scope: 'native/sheet',
error: error,
stackTrace: stackTrace,
);
}
}
Future<void> _handleNativeEditProfileCommitted(
NativeEditProfileCommitted event,
) async {
try {
final account =
ref.read(accountProfileProvider).asData?.value ??
await ref.read(accountProfileProvider.future);
if (account == null) return;
await ref
.read(accountProfileProvider.notifier)
.save(
name: event.name.trim(),
profileImageUrl: event.profileImageUrl.trim(),
bio: event.bio,
gender: _normalizeOptionalNativeText(event.gender),
dateOfBirth: _normalizeOptionalNativeText(event.dateOfBirth),
timezone: account.timezone,
);
} catch (error, stackTrace) {
DebugLogger.error(
'native-edit-profile-commit-failed',
scope: 'native/sheet',
error: error,
stackTrace: stackTrace,
);
}
}
/// Mirrors the three Open WebUI-aligned notification prefs to the server when
/// toggled from the iOS native sheet. Fire-and-forget: local persistence has
/// already succeeded, so a failed sync only loses cross-device parity.
void _syncNotificationPrefsToServer({
bool? enabled,
bool? sound,
bool? soundAlways,
}) {
final api = ref.read(apiServiceProvider);
if (api == null) return;
unawaited(
api
.updateUserNotificationSettings(
notificationEnabled: enabled,
notificationSound: sound,
notificationSoundAlways: soundAlways,
)
.then(
(_) {},
onError: (Object e, StackTrace st) {
DebugLogger.error(
'failed to sync notification prefs to server',
error: e,
stackTrace: st,
scope: 'notifications/settings',
);
},
),
);
}
Future<void> _handleNativeSheetControlChanged(
NativeSheetControlChanged event,
) async {
final value = event.value;
try {
// Accountless (Hermes, Direct, Apple) "Connect to Open WebUI" row.
// Dismiss the native sheet and route into the OWUI connect flow (the
// router allows the serverConnection route for accountless users).
if (event.id == 'add-owui-server') {
NavigationService.openOpenWebUIConnectFromNativeSheet();
return;
}
if (event.id == NativeSheetRoutes.directConnections) {
final request = directConnectionsNativeSheetNavigationRequest;
unawaited(
NavigationService.router.pushNamed<void>(
request.routeName,
extra: request.extra,
),
);
return;
}
if (event.id == NativeSheetRoutes.releaseNotesManual) {
await _dismissNativeSheetBeforeFollowUp();
final context = NavigationService.context;
if (context == null || !context.mounted) return;
await _showManualReleaseNotes(context);
return;
}
if (event.id.startsWith('tts-voice-pick:')) {
await _handleNativeTtsVoicePick(event);
return;
}
if (event.id == 'tts-voice-picker' && value is String) {
await _handleNativeTtsVoiceSelection(
value == '__default__' ? ttsSystemDefaultVoiceId : value,
);
return;
}
if (event.id.startsWith('memory-save:')) {
final encoded = event.id.substring('memory-save:'.length);
final memoryId = Uri.decodeComponent(encoded);
if (value is String) {
await ref
.read(userMemoriesProvider.notifier)
.updateItem(memoryId, value);
}
return;
}
if (event.id.startsWith('memory-delete:')) {
final encoded = event.id.substring('memory-delete:'.length);
await ref
.read(userMemoriesProvider.notifier)
.deleteItem(Uri.decodeComponent(encoded));
return;
}
if (event.id.startsWith('quick-pill:')) {
final pillId = event.id.substring('quick-pill:'.length);
if (value is! bool) return;
final tools = ref
.read(toolsListProvider)
.maybeWhen(data: (v) => v, orElse: () => const <Tool>[]);
final selectedModel = ref.read(selectedModelProvider);
final allowed = <String>{
'web',
'image',
...tools.map((t) => t.id),
...(selectedModel?.filters ?? const []).map((f) => 'filter:${f.id}'),
};
if (!allowed.contains(pillId)) return;
final current = List<String>.from(
ref.read(appSettingsProvider).quickPills,
);
if (value) {
if (!current.contains(pillId)) current.add(pillId);
} else {
current.remove(pillId);
}
await ref.read(appSettingsProvider.notifier).setQuickPills(current);
return;
}
if (event.id.startsWith('model-system-prompt:')) {
final encoded = event.id.substring('model-system-prompt:'.length);
final modelId = Uri.decodeComponent(encoded);
if (value is! String) return;
final api = ref.read(apiServiceProvider);
if (api == null) return;
await api.updateModelSystemPrompt(modelId, value);
await ref.read(modelsProvider.notifier).refresh();
return;
}
switch (event.id) {
case NativeSheetRoutes.hermes:
unawaited(
NavigationService.router.pushNamed<void>(
RouteNames.hermesSettings,
extra: const NativeSheetNavigationOrigin(),
),
);
case NativeSheetRoutes.workspace:
final capabilities = ref.read(workspaceCapabilitiesProvider).value;
final permitted = capabilities == null
? const <WorkspaceSection>[]
: permittedWorkspaceSections(capabilities);
final destination = permitted.isEmpty
? Routes.workspace
: permitted.first.path;
unawaited(
NavigationService.router.push<void>(
destination,
extra: const NativeSheetNavigationOrigin(),
),
);
case 'default-model':
if (value is String) {
final modelId = value == 'auto-select' ? null : value;
await ref
.read(appSettingsProvider.notifier)
.setDefaultModel(modelId);
await restoreDefaultModel(ref);
}
case 'default-image-generation-model':
if (value is String) {
await ref
.read(appSettingsProvider.notifier)
.setOpenRouterImageGenerationModel(value);
}
case 'voice-barge-in':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setVoiceBargeInEnabled(value);
}
case 'stt-silence-duration':
final ms = switch (value) {
final int i => i,
final double d => d.round(),
_ => int.tryParse('$value'),
};
if (ms != null) {
await ref
.read(appSettingsProvider.notifier)
.setVoiceSilenceDuration(ms);
}
case 'tts-speech-rate':
final rate = switch (value) {
final double d => d,
final int i => i.toDouble(),
_ => double.tryParse('$value'),
};
if (rate != null) {
await ref.read(appSettingsProvider.notifier).setTtsSpeechRate(rate);
}
case 'tts-preview':
final text = value is String ? value : null;
if (text == null || text.isEmpty) return;
final controller = ref.read(textToSpeechControllerProvider.notifier);
final speechState = ref.read(textToSpeechControllerProvider);
if (speechState.isSpeaking || speechState.isBusy) {
await controller.stop();
} else {
await controller.toggleForMessage(
messageId: 'tts_preview',
text: text,
);
}
case 'memory-add-content':
if (value is String && value.trim().isNotEmpty) {
await ref.read(userMemoriesProvider.notifier).add(value.trim());
}
case 'memory-clear-all':
await ref.read(userMemoriesProvider.notifier).clearAll();
case 'memory-enabled':
if (value is bool) {
await ref
.read(personalizationSettingsProvider.notifier)
.setMemoryEnabled(value);
}
case 'system-prompt':
if (value is String) {
await ref
.read(personalizationSettingsProvider.notifier)
.setSystemPrompt(value);
}
case 'stt-engine':
if (value == SttPreference.serverOnly.name) {
await ref
.read(appSettingsProvider.notifier)
.setSttPreference(SttPreference.serverOnly);
await _refreshNativeVoiceDetail();
} else if (value == SttPreference.deviceOnly.name) {
await ref
.read(appSettingsProvider.notifier)
.setSttPreference(SttPreference.deviceOnly);
await _refreshNativeVoiceDetail();
}
case 'stt-language-code':
if (value is String) {
final normalized = SettingsService.normalizeSttLanguageCode(value);
if (normalized != null ||
SettingsService.isSttLanguageAutoInput(value)) {
await ref
.read(appSettingsProvider.notifier)
.setSttLanguageCode(normalized);
await _refreshNativeVoiceDetail();
} else {
DebugLogger.validation(
'Ignoring invalid native STT language code',
scope: 'native/sheet',
data: {'value': value},
);
await _refreshNativeVoiceDetail();
}
}
case 'tts-engine':
final notifier = ref.read(appSettingsProvider.notifier);
if (value == TtsEngine.server.name) {
await notifier.setTtsEngineSelection(TtsEngine.server);
await _refreshNativeVoiceDetail();
} else if (value == TtsEngine.device.name) {
await notifier.setTtsEngineSelection(TtsEngine.device);
await _refreshNativeVoiceDetail();
}
case 'theme-light':
switch (value) {
case 'system':
ref
.read(appThemeModeProvider.notifier)
.setTheme(ThemeMode.system);
case 'light':
ref.read(appThemeModeProvider.notifier).setTheme(ThemeMode.light);
case 'dark':
ref.read(appThemeModeProvider.notifier).setTheme(ThemeMode.dark);
}
case 'language':
if (value == 'system') {
await ref.read(appLocaleProvider.notifier).setLocale(null);
} else if (value is String && value.isNotEmpty) {
final locale = _localeFromNativeTag(value);
if (locale != null) {
await ref.read(appLocaleProvider.notifier).setLocale(locale);
}
}
case 'theme-palette':
if (value is String && value.isNotEmpty) {
await ref.read(appThemePaletteProvider.notifier).setPalette(value);
}
case 'quick-pills-clear':
await ref.read(appSettingsProvider.notifier).setQuickPills(const []);
case 'send-on-enter':
if (value is bool) {
await ref.read(appSettingsProvider.notifier).setSendOnEnter(value);
}
case 'temporary-chat-default':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setTemporaryChatByDefault(value);
}
case 'disable-haptics-streaming':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setDisableHapticsWhileStreaming(value);
}
case 'notifications-enabled':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationsEnabled(value);
_syncNotificationPrefsToServer(enabled: value);
if (value) {
// Best-effort OS permission on opt-in; OS governs delivery.
await ref
.read(localNotificationServiceProvider)
.requestPermissions();
}
}
case 'notification-in-app-banner':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationInAppBanner(value);
}
case 'notification-system':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationSystem(value);
}
case 'notification-sound':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationSound(value);
_syncNotificationPrefsToServer(sound: value);
}
case 'notification-sound-always':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationSoundAlways(value);
_syncNotificationPrefsToServer(soundAlways: value);
}
case 'notification-chat':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationChatEnabled(value);
}
case 'notification-channel':
if (value is bool) {
await ref
.read(appSettingsProvider.notifier)
.setNotificationChannelEnabled(value);
}
case 'transport-auto':
await ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode('auto');
case 'transport-streaming':
await ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode('ws');
case 'transport-mode':
if (value == 'ws' || value == 'streaming') {
await ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode('ws');
} else if (value == 'polling' || value == 'auto') {
await ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode('auto');
}
case 'current-password':
case 'new-password':
case 'confirm-password':
await _saveNativePasswordDraft(event.id, value);
}
} catch (error, stackTrace) {
DebugLogger.error(
'native-sheet-control-failed',
scope: 'native/sheet',
error: error,
stackTrace: stackTrace,
);
}
}
String? _normalizeOptionalNativeText(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
Future<void> _refreshNativeVoiceDetail() {
return ref
.read(nativeSheetHydrationServiceProvider)
.hydrateDetail(NativeSheetRoutes.voice);
}
Future<void> _saveNativePasswordDraft(String id, Object? value) async {
if (value is! String) return;
_nativeSheetDraftValues[id] = value;
final current = _nativeSheetDraftValues['current-password'];
final next = _nativeSheetDraftValues['new-password'];
final confirm = _nativeSheetDraftValues['confirm-password'];
if (current == null ||
current.isEmpty ||
next == null ||
next.isEmpty ||
confirm == null ||
confirm.isEmpty) {
return;
}
if (confirm != next) {
return;
}
await ref
.read(accountProfileProvider.notifier)
.updatePassword(password: current, newPassword: next);
_nativeSheetDraftValues.remove('current-password');
_nativeSheetDraftValues.remove('new-password');
_nativeSheetDraftValues.remove('confirm-password');
}
Future<void> _showManualReleaseNotes(BuildContext context) async {
final packageInfo = await ref.read(packageInfoProvider.future);
if (!context.mounted) return;
final allNotes = await const ReleaseNotesRepository().load(
Localizations.localeOf(context),
);
if (!context.mounted) return;
final notes = latestBundledReleaseNotesForVersion(
currentVersion: packageInfo.version,
notes: allNotes,
);
if (notes.isEmpty) return;
await showReleaseNotesSheet(
context: context,
currentVersion: packageInfo.version,
notes: notes,
);
}
Future<void> _dismissNativeSheetBeforeFollowUp() async {
await NativeSheetBridge.instance.dismiss();
// The platform channel returns before UIKit finishes dismissing the sheet.
// Presenting the next sheet inside that animation window can no-op on iOS.
await Future<void>.delayed(_nativeSheetFollowUpDelay);
}
Future<void> _handleNativeTtsVoicePick(
NativeSheetControlChanged event,
) async {
final encoded = event.id.substring('tts-voice-pick:'.length);
final voiceKey = Uri.decodeComponent(encoded);
final fallbackDisplayName = event.value is String
? event.value as String
: null;
await _handleNativeTtsVoiceSelection(
voiceKey == '__default__' ? ttsSystemDefaultVoiceId : voiceKey,
fallbackDisplayName: fallbackDisplayName,
);
}
Future<void> _handleNativeTtsVoiceSelection(
String voiceKey, {
String? fallbackDisplayName,
}) async {
final settings = ref.read(appSettingsProvider);
final notifier = ref.read(appSettingsProvider.notifier);
if (voiceKey == ttsSystemDefaultVoiceId) {
if (settings.ttsEngine == TtsEngine.server) {
await notifier.setTtsServerVoiceSelection(null, null);
} else {
await notifier.setTtsDeviceVoiceSelection(null, null);
}
await _refreshNativeVoiceDetail();
return;
}
var selectedId = voiceKey;
var displayName = fallbackDisplayName ?? voiceKey;
final l10n = currentAppLocalizations();
try {
final ttsService = ref.read(textToSpeechServiceProvider);
await ttsService.updateSettings(engine: settings.ttsEngine);
final voices = await ttsService.getAvailableVoices();
final selected = findTtsVoiceOption(
l10n,
settings.ttsEngine,
voices,
voiceKey,
);
if (selected != null) {
selectedId = selected.id;
displayName = selected.label;
}
} catch (error, stackTrace) {
DebugLogger.warning(
'native-tts-voice-selection-lookup-failed',
scope: 'native/sheet',
data: {'error': error, 'stackTrace': stackTrace},
);
}
if (settings.ttsEngine == TtsEngine.server) {
await notifier.setTtsServerVoiceSelection(selectedId, displayName);
} else {
await notifier.setTtsDeviceVoiceSelection(selectedId, displayName);
}
await _refreshNativeVoiceDetail();
}
void _initializeAppState() {
DebugLogger.auth('init', scope: 'app');
ref.read(appStartupFlowProvider.notifier).start();
}
@override
void dispose() {
_nativeSheetSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(appThemeModeProvider.select((mode) => mode));
final router = ref.watch(goRouterProvider);
final locale = ref.watch(appLocaleProvider);
final lightTheme = ref.watch(appLightThemeProvider);
final darkTheme = ref.watch(appDarkThemeProvider);
final cupertinoLight = ref.watch(appCupertinoLightThemeProvider);
final cupertinoDark = ref.watch(appCupertinoDarkThemeProvider);
return AdaptiveApp.router(
routerConfig: router,
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
materialLightTheme: lightTheme,
materialDarkTheme: darkTheme,
cupertinoLightTheme: cupertinoLight,
cupertinoDarkTheme: cupertinoDark,
themeMode: themeMode,
locale: locale,
localizationsDelegates: conduitLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: (deviceLocales, supported) {
if (locale != null) return locale;
if (deviceLocales == null || deviceLocales.isEmpty) {
return supported.first;
}
final resolved = _resolveSupportedLocale(deviceLocales, supported);
return resolved ?? supported.first;
},
material: (_, _) =>
const MaterialAppData(debugShowCheckedModeBanner: false),
cupertino: (_, _) =>
const CupertinoAppData(debugShowCheckedModeBanner: false),
builder: (context, child) {
// Resolve brightness from themeMode rather than
// Theme.of(context) — on iOS, CupertinoApp's
// auto-generated Theme may not reflect themeMode.
final Brightness brightness;
switch (themeMode) {
case ThemeMode.dark:
brightness = Brightness.dark;
case ThemeMode.light:
brightness = Brightness.light;
case ThemeMode.system:
brightness = MediaQuery.platformBrightnessOf(context);
}
if (_lastAppliedOverlayBrightness != brightness) {
_lastAppliedOverlayBrightness = brightness;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
applySystemUiOverlayStyleOnce(brightness: brightness);
});
}
final safeChild = child ?? const SizedBox.shrink();
// On iOS, AdaptiveApp creates CupertinoApp which
// doesn't propagate Material ThemeExtensions.
// Wrap with Theme to ensure all custom extensions
// (ConduitThemeExtension, AppColorTokens, etc.)
// are available via Theme.of(context) on every
// platform.
final materialTheme = brightness == Brightness.dark
? darkTheme
: lightTheme;
final nativeTheme = materialTheme.extension<ConduitThemeExtension>();
if (nativeTheme != null) {
// Sheets share the grouped settings colors with the Flutter
// settings screens so both read as one surface system.
final isDarkSheet = brightness == Brightness.dark;
final sheetBackground = nativeTheme.groupedBackground;
final sheetSurface = nativeTheme.groupedSurface;
unawaited(
NativeSheetBridge.instance.syncTheme(
NativeSheetThemeConfig(
isDark: isDarkSheet,
backgroundArgb: sheetBackground.toARGB32(),
surfaceArgb: sheetSurface.toARGB32(),
elevatedSurfaceArgb: nativeTheme.surfaceContainerHighest
.toARGB32(),
inputArgb: nativeTheme.inputBackground.toARGB32(),
foregroundArgb: nativeTheme.textPrimary.toARGB32(),
secondaryForegroundArgb: nativeTheme.textSecondary.toARGB32(),
iconArgb: nativeTheme.iconSecondary.toARGB32(),
borderArgb: nativeTheme.cardBorder.toARGB32(),
accentArgb: nativeTheme.buttonPrimary.toARGB32(),
onAccentArgb: nativeTheme.buttonPrimaryText.toARGB32(),
destructiveArgb: nativeTheme.error.toARGB32(),
),
),
);
}
return Theme(
data: materialTheme,
child: Builder(
builder: (context) => LegacyDesignCompatibility(
child: ReleaseNotesCoordinator(
child: _KeyboardDismissOnScroll(child: safeChild),
),
),
),
);
},
);
}
bool _prefersTraditionalChinese(Locale deviceLocale) {
final script = deviceLocale.scriptCode?.toLowerCase();
if (script == 'hant') return true;
final country = deviceLocale.countryCode?.toUpperCase();
return country == 'TW' || country == 'HK' || country == 'MO';
}
Locale? _resolveSupportedLocale(
List<Locale>? deviceLocales,
Iterable<Locale> supported,
) {
if (deviceLocales == null || deviceLocales.isEmpty) return null;
for (final device in deviceLocales) {
final prefersTraditional = _prefersTraditionalChinese(device);
final deviceLanguage = device.languageCode.toLowerCase();
final deviceScript = device.scriptCode?.toLowerCase();
final deviceCountry = device.countryCode?.toUpperCase();
// Pass 1: match language with script (or preferred Traditional)
for (final loc in supported) {
final languageMatches =
loc.languageCode.toLowerCase() == deviceLanguage;
if (!languageMatches) continue;
final locScript = loc.scriptCode?.toLowerCase();
final scriptMatches =
locScript != null &&
locScript.isNotEmpty &&
(locScript == deviceScript ||
(loc.languageCode == 'zh' &&
locScript == 'hant' &&
prefersTraditional));
if (!scriptMatches) continue;
final locCountry = loc.countryCode?.toUpperCase();
final countryMatches =
locCountry == null ||
locCountry.isEmpty ||
locCountry == deviceCountry;
if (countryMatches) {
return loc;
}
}
// Pass 2: prefer Traditional Chinese when applicable
if (prefersTraditional) {
for (final loc in supported) {
if (loc.languageCode == 'zh' && loc.scriptCode == 'Hant') {
return loc;
}
}
}
// Pass 3: language-only match
for (final loc in supported) {
if (loc.languageCode.toLowerCase() == deviceLanguage) {
return loc;
}
}
}
return null;
}
}
/// Dismisses the soft keyboard whenever the user scrolls.
class _KeyboardDismissOnScroll extends StatelessWidget {
const _KeyboardDismissOnScroll({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return NotificationListener<UserScrollNotification>(
onNotification: (notification) {
if (notification.direction == ScrollDirection.idle) {
return false;
}
final focusedNode = FocusManager.instance.primaryFocus;
if (focusedNode != null && focusedNode.hasFocus) {
focusedNode.unfocus();
}
return false;
},
child: child,
);
}
}