import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:hive_ce/hive.dart'; import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; import 'animation_service.dart'; part 'settings_service.g.dart'; /// Speech-to-text preference selection. enum SttPreference { auto, deviceOnly, serverOnly } /// TTS engine selection enum TtsEngine { auto, device, server } /// Service for managing app-wide settings including accessibility preferences class SettingsService { static const String _reduceMotionKey = PreferenceKeys.reduceMotion; static const String _animationSpeedKey = PreferenceKeys.animationSpeed; static const String _hapticFeedbackKey = PreferenceKeys.hapticFeedback; static const String _highContrastKey = PreferenceKeys.highContrast; static const String _largeTextKey = PreferenceKeys.largeText; static const String _darkModeKey = PreferenceKeys.darkMode; static const String _defaultModelKey = PreferenceKeys.defaultModel; // Voice input settings static const String _voiceLocaleKey = PreferenceKeys.voiceLocaleId; static const String _voiceHoldToTalkKey = PreferenceKeys.voiceHoldToTalk; static const String _voiceAutoSendKey = PreferenceKeys.voiceAutoSendFinal; // Realtime transport preference static const String _socketTransportModeKey = PreferenceKeys.socketTransportMode; // 'polling' or 'ws' // Quick pill visibility selections (max 2) static const String _quickPillsKey = PreferenceKeys .quickPills; // StringList of identifiers e.g. ['web','image','tools'] // Chat input behavior static const String _sendOnEnterKey = PreferenceKeys.sendOnEnterKey; static Box _preferencesBox() => Hive.box(HiveBoxNames.preferences); /// Get reduced motion preference static Future getReduceMotion() { final value = _preferencesBox().get(_reduceMotionKey) as bool?; return Future.value(value ?? false); } /// Set reduced motion preference static Future setReduceMotion(bool value) { return _preferencesBox().put(_reduceMotionKey, value); } /// Get animation speed multiplier (0.5 - 2.0) static Future getAnimationSpeed() { final value = _preferencesBox().get(_animationSpeedKey) as num?; return Future.value((value?.toDouble() ?? 1.0).clamp(0.5, 2.0)); } /// Set animation speed multiplier static Future setAnimationSpeed(double value) { final sanitized = value.clamp(0.5, 2.0).toDouble(); return _preferencesBox().put(_animationSpeedKey, sanitized); } /// Get haptic feedback preference static Future getHapticFeedback() { final value = _preferencesBox().get(_hapticFeedbackKey) as bool?; return Future.value(value ?? true); } /// Set haptic feedback preference static Future setHapticFeedback(bool value) { return _preferencesBox().put(_hapticFeedbackKey, value); } /// Get high contrast preference static Future getHighContrast() { final value = _preferencesBox().get(_highContrastKey) as bool?; return Future.value(value ?? false); } /// Set high contrast preference static Future setHighContrast(bool value) { return _preferencesBox().put(_highContrastKey, value); } /// Get large text preference static Future getLargeText() { final value = _preferencesBox().get(_largeTextKey) as bool?; return Future.value(value ?? false); } /// Set large text preference static Future setLargeText(bool value) { return _preferencesBox().put(_largeTextKey, value); } /// Get dark mode preference static Future getDarkMode() { final value = _preferencesBox().get(_darkModeKey) as bool?; return Future.value(value ?? true); } /// Set dark mode preference static Future setDarkMode(bool value) { return _preferencesBox().put(_darkModeKey, value); } /// Get default model preference static Future getDefaultModel() { final value = _preferencesBox().get(_defaultModelKey) as String?; return Future.value(value); } /// Set default model preference static Future setDefaultModel(String? modelId) { final box = _preferencesBox(); if (modelId != null) { return box.put(_defaultModelKey, modelId); } return box.delete(_defaultModelKey); } /// Load all settings static Future loadSettings() { final box = _preferencesBox(); return Future.value( AppSettings( reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false, animationSpeed: (box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0, hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true, highContrast: (box.get(_highContrastKey) as bool?) ?? false, largeText: (box.get(_largeTextKey) as bool?) ?? false, darkMode: (box.get(_darkModeKey) as bool?) ?? true, defaultModel: box.get(_defaultModelKey) as String?, voiceLocaleId: box.get(_voiceLocaleKey) as String?, voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false, voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false, socketTransportMode: box.get(_socketTransportModeKey, defaultValue: 'ws') as String, quickPills: List.from( (box.get(_quickPillsKey) as List?) ?? const [], ), sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false, ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?, ttsSpeechRate: (box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5, ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0, ttsVolume: (box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0, ttsEngine: _parseTtsEngine( box.get(PreferenceKeys.ttsEngine) as String?, ), ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?, ttsServerVoiceName: box.get(PreferenceKeys.ttsServerVoiceName) as String?, sttPreference: _parseSttPreference( box.get(PreferenceKeys.voiceSttPreference) as String?, ), ), ); } /// Save all settings static Future saveSettings(AppSettings settings) async { final box = _preferencesBox(); final updates = { _reduceMotionKey: settings.reduceMotion, _animationSpeedKey: settings.animationSpeed, _hapticFeedbackKey: settings.hapticFeedback, _highContrastKey: settings.highContrast, _largeTextKey: settings.largeText, _darkModeKey: settings.darkMode, _voiceHoldToTalkKey: settings.voiceHoldToTalk, _voiceAutoSendKey: settings.voiceAutoSendFinal, _socketTransportModeKey: settings.socketTransportMode, _quickPillsKey: settings.quickPills.toList(), _sendOnEnterKey: settings.sendOnEnter, PreferenceKeys.ttsSpeechRate: settings.ttsSpeechRate, PreferenceKeys.ttsPitch: settings.ttsPitch, PreferenceKeys.ttsVolume: settings.ttsVolume, PreferenceKeys.ttsEngine: settings.ttsEngine.name, PreferenceKeys.voiceSttPreference: settings.sttPreference.name, }; await box.putAll(updates); if (settings.defaultModel != null) { await box.put(_defaultModelKey, settings.defaultModel); } else { await box.delete(_defaultModelKey); } if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) { await box.put(_voiceLocaleKey, settings.voiceLocaleId); } else { await box.delete(_voiceLocaleKey); } if (settings.ttsVoice != null && settings.ttsVoice!.isNotEmpty) { await box.put(PreferenceKeys.ttsVoice, settings.ttsVoice); } else { await box.delete(PreferenceKeys.ttsVoice); } // Server-specific voice id and friendly name if (settings.ttsServerVoiceId != null && settings.ttsServerVoiceId!.isNotEmpty) { await box.put(PreferenceKeys.ttsServerVoiceId, settings.ttsServerVoiceId); } else { await box.delete(PreferenceKeys.ttsServerVoiceId); } if (settings.ttsServerVoiceName != null && settings.ttsServerVoiceName!.isNotEmpty) { await box.put( PreferenceKeys.ttsServerVoiceName, settings.ttsServerVoiceName, ); } else { await box.delete(PreferenceKeys.ttsServerVoiceName); } } static TtsEngine _parseTtsEngine(String? raw) { switch ((raw ?? '').toLowerCase()) { case 'auto': case '': return TtsEngine.auto; case 'server': return TtsEngine.server; case 'device': return TtsEngine.device; default: return TtsEngine.auto; } } static SttPreference _parseSttPreference(String? raw) { switch ((raw ?? '').toLowerCase()) { case 'deviceonly': case 'device_only': case 'device': return SttPreference.deviceOnly; case 'serveronly': case 'server_only': case 'server': return SttPreference.serverOnly; case 'auto': default: return SttPreference.auto; } } // Voice input specific settings static Future getVoiceLocaleId() { final value = _preferencesBox().get(_voiceLocaleKey) as String?; return Future.value(value); } static Future setVoiceLocaleId(String? localeId) { final box = _preferencesBox(); if (localeId == null || localeId.isEmpty) { return box.delete(_voiceLocaleKey); } return box.put(_voiceLocaleKey, localeId); } static Future getVoiceHoldToTalk() { final value = _preferencesBox().get(_voiceHoldToTalkKey) as bool?; return Future.value(value ?? false); } static Future setVoiceHoldToTalk(bool value) { return _preferencesBox().put(_voiceHoldToTalkKey, value); } static Future getVoiceAutoSendFinal() { final value = _preferencesBox().get(_voiceAutoSendKey) as bool?; return Future.value(value ?? false); } static Future setVoiceAutoSendFinal(bool value) { return _preferencesBox().put(_voiceAutoSendKey, value); } /// Transport mode: 'polling' (HTTP polling + WebSocket upgrade) or 'ws' static Future getSocketTransportMode() { final raw = _preferencesBox().get(_socketTransportModeKey) as String?; if (raw == null) { return Future.value('ws'); } if (raw == 'auto') { return Future.value('polling'); } if (raw != 'polling' && raw != 'ws') { return Future.value('ws'); } return Future.value(raw); } static Future setSocketTransportMode(String mode) { if (mode == 'auto') { mode = 'polling'; } if (mode != 'polling' && mode != 'ws') { mode = 'polling'; } return _preferencesBox().put(_socketTransportModeKey, mode); } // Quick Pills (visibility) static Future> getQuickPills() { final stored = _preferencesBox().get(_quickPillsKey) as List?; if (stored == null) { return Future.value(const []); } return Future.value(List.from(stored)); } static Future setQuickPills(List pills) { return _preferencesBox().put(_quickPillsKey, pills.toList()); } // Chat input behavior static Future getSendOnEnter() { final value = _preferencesBox().get(_sendOnEnterKey) as bool?; return Future.value(value ?? false); } static Future setSendOnEnter(bool value) { return _preferencesBox().put(_sendOnEnterKey, value); } /// Get effective animation duration considering all settings static Duration getEffectiveAnimationDuration( BuildContext context, Duration defaultDuration, AppSettings settings, ) { // Check system reduced motion first if (MediaQuery.of(context).disableAnimations || settings.reduceMotion) { return Duration.zero; } // Apply user animation speed preference final adjustedMs = (defaultDuration.inMilliseconds / settings.animationSpeed).round(); return Duration(milliseconds: adjustedMs.clamp(50, 1000)); } /// Get text scale factor considering user preferences static double getEffectiveTextScaleFactor( BuildContext context, AppSettings settings, ) { final textScaler = MediaQuery.of(context).textScaler; double baseScale = textScaler.scale(1.0); // Apply large text preference if (settings.largeText) { baseScale *= 1.3; } // Ensure reasonable bounds return baseScale.clamp(0.8, 3.0); } } /// Sentinel class to detect when defaultModel parameter is not provided class _DefaultValue { const _DefaultValue(); } /// Data class for app settings class AppSettings { final bool reduceMotion; final double animationSpeed; final bool hapticFeedback; final bool highContrast; final bool largeText; final bool darkMode; final String? defaultModel; final String? voiceLocaleId; final bool voiceHoldToTalk; final bool voiceAutoSendFinal; final String socketTransportMode; // 'polling' or 'ws' final List quickPills; // e.g., ['web','image'] final bool sendOnEnter; final SttPreference sttPreference; final String? ttsVoice; final double ttsSpeechRate; final double ttsPitch; final double ttsVolume; final TtsEngine ttsEngine; final String? ttsServerVoiceId; final String? ttsServerVoiceName; const AppSettings({ this.reduceMotion = false, this.animationSpeed = 1.0, this.hapticFeedback = true, this.highContrast = false, this.largeText = false, this.darkMode = true, this.defaultModel, this.voiceLocaleId, this.voiceHoldToTalk = false, this.voiceAutoSendFinal = false, this.socketTransportMode = 'ws', this.quickPills = const [], this.sendOnEnter = false, this.sttPreference = SttPreference.auto, this.ttsVoice, this.ttsSpeechRate = 0.5, this.ttsPitch = 1.0, this.ttsVolume = 1.0, this.ttsEngine = TtsEngine.auto, this.ttsServerVoiceId, this.ttsServerVoiceName, }); AppSettings copyWith({ bool? reduceMotion, double? animationSpeed, bool? hapticFeedback, bool? highContrast, bool? largeText, bool? darkMode, Object? defaultModel = const _DefaultValue(), Object? voiceLocaleId = const _DefaultValue(), bool? voiceHoldToTalk, bool? voiceAutoSendFinal, String? socketTransportMode, List? quickPills, bool? sendOnEnter, SttPreference? sttPreference, Object? ttsVoice = const _DefaultValue(), double? ttsSpeechRate, double? ttsPitch, double? ttsVolume, TtsEngine? ttsEngine, Object? ttsServerVoiceId = const _DefaultValue(), Object? ttsServerVoiceName = const _DefaultValue(), }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, animationSpeed: animationSpeed ?? this.animationSpeed, hapticFeedback: hapticFeedback ?? this.hapticFeedback, highContrast: highContrast ?? this.highContrast, largeText: largeText ?? this.largeText, darkMode: darkMode ?? this.darkMode, defaultModel: defaultModel is _DefaultValue ? this.defaultModel : defaultModel as String?, voiceLocaleId: voiceLocaleId is _DefaultValue ? this.voiceLocaleId : voiceLocaleId as String?, voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk, voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal, socketTransportMode: socketTransportMode ?? this.socketTransportMode, quickPills: quickPills ?? this.quickPills, sendOnEnter: sendOnEnter ?? this.sendOnEnter, sttPreference: sttPreference ?? this.sttPreference, ttsVoice: ttsVoice is _DefaultValue ? this.ttsVoice : ttsVoice as String?, ttsSpeechRate: ttsSpeechRate ?? this.ttsSpeechRate, ttsPitch: ttsPitch ?? this.ttsPitch, ttsVolume: ttsVolume ?? this.ttsVolume, ttsEngine: ttsEngine ?? this.ttsEngine, ttsServerVoiceId: ttsServerVoiceId is _DefaultValue ? this.ttsServerVoiceId : ttsServerVoiceId as String?, ttsServerVoiceName: ttsServerVoiceName is _DefaultValue ? this.ttsServerVoiceName : ttsServerVoiceName as String?, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppSettings && other.reduceMotion == reduceMotion && other.animationSpeed == animationSpeed && other.hapticFeedback == hapticFeedback && other.highContrast == highContrast && other.largeText == largeText && other.darkMode == darkMode && other.defaultModel == defaultModel && other.voiceLocaleId == voiceLocaleId && other.voiceHoldToTalk == voiceHoldToTalk && other.voiceAutoSendFinal == voiceAutoSendFinal && other.sttPreference == sttPreference && other.sendOnEnter == sendOnEnter && other.ttsVoice == ttsVoice && other.ttsSpeechRate == ttsSpeechRate && other.ttsPitch == ttsPitch && other.ttsVolume == ttsVolume && other.ttsEngine == ttsEngine && other.ttsServerVoiceId == ttsServerVoiceId && other.ttsServerVoiceName == ttsServerVoiceName && _listEquals(other.quickPills, quickPills); // socketTransportMode intentionally not included in == to avoid frequent rebuilds } @override int get hashCode { return Object.hashAll([ reduceMotion, animationSpeed, hapticFeedback, highContrast, largeText, darkMode, defaultModel, voiceLocaleId, voiceHoldToTalk, voiceAutoSendFinal, sttPreference, socketTransportMode, sendOnEnter, ttsVoice, ttsSpeechRate, ttsPitch, ttsVolume, ttsEngine, ttsServerVoiceId, ttsServerVoiceName, Object.hashAllUnordered(quickPills), ]); } } bool _listEquals(List a, List b) { if (identical(a, b)) return true; if (a.length != b.length) return false; for (int i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } /// Provider for app settings @Riverpod(keepAlive: true) class AppSettingsNotifier extends _$AppSettingsNotifier { bool _initialized = false; @override AppSettings build() { if (!_initialized) { _initialized = true; Future.microtask(_loadSettings); } return const AppSettings(); } Future _loadSettings() async { final settings = await SettingsService.loadSettings(); if (!ref.mounted) { return; } state = settings; } Future setReduceMotion(bool value) async { state = state.copyWith(reduceMotion: value); await SettingsService.setReduceMotion(value); } Future setAnimationSpeed(double value) async { state = state.copyWith(animationSpeed: value); await SettingsService.setAnimationSpeed(value); } Future setHapticFeedback(bool value) async { state = state.copyWith(hapticFeedback: value); await SettingsService.setHapticFeedback(value); } Future setHighContrast(bool value) async { state = state.copyWith(highContrast: value); await SettingsService.setHighContrast(value); } Future setLargeText(bool value) async { state = state.copyWith(largeText: value); await SettingsService.setLargeText(value); } Future setDarkMode(bool value) async { state = state.copyWith(darkMode: value); await SettingsService.setDarkMode(value); } Future setDefaultModel(String? modelId) async { state = state.copyWith(defaultModel: modelId); await SettingsService.setDefaultModel(modelId); } Future setVoiceLocaleId(String? localeId) async { state = state.copyWith(voiceLocaleId: localeId); await SettingsService.setVoiceLocaleId(localeId); } Future setVoiceHoldToTalk(bool value) async { state = state.copyWith(voiceHoldToTalk: value); await SettingsService.setVoiceHoldToTalk(value); } Future setVoiceAutoSendFinal(bool value) async { state = state.copyWith(voiceAutoSendFinal: value); await SettingsService.setVoiceAutoSendFinal(value); } Future setSocketTransportMode(String mode) async { var sanitized = mode; if (sanitized == 'auto') { sanitized = 'polling'; } if (sanitized != 'polling' && sanitized != 'ws') { sanitized = 'polling'; } if (state.socketTransportMode != sanitized) { state = state.copyWith(socketTransportMode: sanitized); } await SettingsService.setSocketTransportMode(sanitized); } Future setQuickPills(List pills) async { // Accept arbitrary server tool IDs plus built-ins // Platform-specific limits are enforced in the UI layer state = state.copyWith(quickPills: pills); await SettingsService.setQuickPills(pills); } Future setSendOnEnter(bool value) async { state = state.copyWith(sendOnEnter: value); await SettingsService.setSendOnEnter(value); } Future setSttPreference(SttPreference preference) async { if (state.sttPreference == preference) { return; } state = state.copyWith(sttPreference: preference); await SettingsService.saveSettings(state); } Future setTtsVoice(String? voice) async { state = state.copyWith(ttsVoice: voice); await SettingsService.saveSettings(state); } Future setTtsSpeechRate(double rate) async { state = state.copyWith(ttsSpeechRate: rate); await SettingsService.saveSettings(state); } Future setTtsPitch(double pitch) async { state = state.copyWith(ttsPitch: pitch); await SettingsService.saveSettings(state); } Future setTtsVolume(double volume) async { state = state.copyWith(ttsVolume: volume); await SettingsService.saveSettings(state); } Future setTtsEngine(TtsEngine engine) async { state = state.copyWith(ttsEngine: engine); await SettingsService.saveSettings(state); } Future setTtsServerVoiceName(String? name) async { state = state.copyWith(ttsServerVoiceName: name); await SettingsService.saveSettings(state); } Future setTtsServerVoiceId(String? id) async { state = state.copyWith(ttsServerVoiceId: id); await SettingsService.saveSettings(state); } Future resetToDefaults() async { const defaultSettings = AppSettings(); await SettingsService.saveSettings(defaultSettings); state = defaultSettings; } } /// Provider for checking if haptic feedback should be enabled final hapticEnabledProvider = Provider((ref) { final settings = ref.watch(appSettingsProvider); return settings.hapticFeedback; }); /// Provider for effective animation settings final effectiveAnimationSettingsProvider = Provider((ref) { final appSettings = ref.watch(appSettingsProvider); return AnimationSettings( reduceMotion: appSettings.reduceMotion, performance: AnimationPerformance.adaptive, animationSpeed: appSettings.animationSpeed, ); });