| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357 |
- import 'dart:convert';
- import 'dart:math';
- import 'package:shared_preferences/shared_preferences.dart';
- import '../../core/theme/accent_choice.dart';
- import '../../core/theme/theme_preference.dart';
- import '../models/jellyfin_library.dart';
- import '../models/slot_assignment.dart';
- import '../models/slot_type.dart';
- /// Persists launcher configuration. This is the single source of truth for
- /// appearance, home-button visibility, the automatic night window, and pinned
- /// apps as well as the three fixed slots.
- class SettingsRepository {
- SettingsRepository(this._prefs);
- final SharedPreferences _prefs;
- static const _themeKey = 'settings.theme';
- static const _nightStartKey = 'settings.nightStartMinutes';
- static const _nightEndKey = 'settings.nightEndMinutes';
- static const _accentKey = 'settings.accent';
- static const _showBatteryKey = 'settings.showBattery';
- static const _slotPrefix = 'slot';
- static const _pinnedKey = 'home.pinned';
- static const _firstLaunchAtKey = 'onboarding.firstLaunchAt';
- static const _homeRoleHintShownKey = 'onboarding.homeRoleHintShown';
- static const _obdAddressKey = 'obd.adapterAddress';
- static const _weatherTemperatureKey = 'weather.locationTemperature';
- static const _keepScreenOnKey = 'display.keepScreenOn';
- static const _speedEnabledKey = 'carData.speedEnabled';
- static const _speedUseObdKey = 'carData.speedUseObd';
- static const _temperatureEnabledKey = 'carData.temperatureEnabled';
- static const _temperatureUseObdKey = 'carData.temperatureUseObd';
- static const _jellyfinEnabledKey = 'jellyfin.enabled';
- static const _jellyfinServerUrlKey = 'jellyfin.serverUrl';
- static const _jellyfinDeviceIdKey = 'jellyfin.deviceId';
- static const _jellyfinServerNameKey = 'jellyfin.serverName';
- static const _jellyfinUserIdKey = 'jellyfin.userId';
- static const _jellyfinUserNameKey = 'jellyfin.userName';
- static const _jellyfinGenreFamiliesKey = 'jellyfin.genreFamilies';
- ThemePreference get themePreference {
- return ThemePreference.fromStorage(_prefs.getString(_themeKey));
- }
- int get nightStartMinutes => _prefs.getInt(_nightStartKey) ?? 19 * 60;
- int get nightEndMinutes => _prefs.getInt(_nightEndKey) ?? 7 * 60;
- AccentChoice get accent {
- return AccentChoice.fromStorage(_prefs.getString(_accentKey));
- }
- bool get showBattery => _prefs.getBool(_showBatteryKey) ?? true;
- /// When CarDash first ran after this installation. Older installs are
- /// migrated to the epoch so onboarding copy cannot reappear after an update.
- DateTime? get firstLaunchAt {
- final millis = _prefs.getInt(_firstLaunchAtKey);
- return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
- }
- /// Records the first launch once. Existing preferences mean this is an
- /// upgraded install, so the onboarding window is treated as already over.
- Future<DateTime> ensureFirstLaunchAt(DateTime now) async {
- final stored = firstLaunchAt;
- if (stored != null) return stored;
- final first = _prefs.getKeys().isEmpty
- ? now
- : DateTime.fromMillisecondsSinceEpoch(0);
- await _prefs.setInt(_firstLaunchAtKey, first.millisecondsSinceEpoch);
- return first;
- }
- /// Freezes the one-time migration default for temperature. Existing installs
- /// that configured OBD-II or location weather keep the feature; a fresh
- /// install starts off so it cannot fetch weather without an explicit choice.
- Future<void> ensureTelemetryDefaults() async {
- if (_prefs.containsKey(_temperatureEnabledKey)) return;
- final wasUsingCarData =
- (_prefs.getBool(_weatherTemperatureKey) ?? false) ||
- obdAdapterAddress != null;
- await _prefs.setBool(_temperatureEnabledKey, wasUsingCarData);
- }
- bool get homeRoleHintShown {
- return _prefs.getBool(_homeRoleHintShownKey) ?? false;
- }
- /// Whether the speed card may be shown on Home.
- bool get speedEnabled => _prefs.getBool(_speedEnabledKey) ?? true;
- /// OBD-II is preferred for speed when it is connected; GPS is the fallback.
- bool get speedUseObd => _prefs.getBool(_speedUseObdKey) ?? true;
- /// Fresh installs start with temperature off. [ensureTelemetryDefaults]
- /// preserves it for an upgrade that already had OBD or weather configured.
- bool get temperatureEnabled =>
- _prefs.getBool(_temperatureEnabledKey) ?? false;
- /// OBD-II ambient is preferred when available; weather is the fallback.
- bool get temperatureUseObd => _prefs.getBool(_temperatureUseObdKey) ?? true;
- /// Whether the optional Jellyfin integration is enabled. The access token is
- /// kept in secure storage; this flag and the URL are safe in preferences.
- bool get jellyfinEnabled => _prefs.getBool(_jellyfinEnabledKey) ?? false;
- String get jellyfinServerUrl {
- return _prefs.getString(_jellyfinServerUrlKey)?.trim() ?? '';
- }
- String? get jellyfinServerName {
- final value = _prefs.getString(_jellyfinServerNameKey)?.trim();
- return value == null || value.isEmpty ? null : value;
- }
- String? get jellyfinUserId {
- final value = _prefs.getString(_jellyfinUserIdKey)?.trim();
- return value == null || value.isEmpty ? null : value;
- }
- String? get jellyfinUserName {
- final value = _prefs.getString(_jellyfinUserNameKey)?.trim();
- return value == null || value.isEmpty ? null : value;
- }
- String? get obdAdapterAddress {
- final value = _prefs.getString(_obdAddressKey)?.trim();
- return value == null || value.isEmpty ? null : value;
- }
- bool get weatherLocationTemperature {
- return _prefs.getBool(_weatherTemperatureKey) ?? false;
- }
- /// The dashboard keeps the display awake by default.
- bool get keepScreenOn {
- return _prefs.getBool(_keepScreenOnKey) ?? true;
- }
- Future<void> setThemePreference(ThemePreference preference) {
- return _prefs.setString(_themeKey, preference.storageValue);
- }
- Future<void> setAccent(AccentChoice accent) {
- return _prefs.setString(_accentKey, accent.storageValue);
- }
- Future<void> setShowBattery(bool show) {
- return _prefs.setBool(_showBatteryKey, show);
- }
- Future<void> setSpeedEnabled(bool enabled) {
- return _prefs.setBool(_speedEnabledKey, enabled);
- }
- Future<void> setSpeedUseObd(bool enabled) {
- return _prefs.setBool(_speedUseObdKey, enabled);
- }
- Future<void> setTemperatureEnabled(bool enabled) {
- return _prefs.setBool(_temperatureEnabledKey, enabled);
- }
- Future<void> setTemperatureUseObd(bool enabled) {
- return _prefs.setBool(_temperatureUseObdKey, enabled);
- }
- Future<void> setJellyfinEnabled(bool enabled) {
- return _prefs.setBool(_jellyfinEnabledKey, enabled);
- }
- Future<void> setJellyfinServerUrl(String url) {
- final value = url.trim();
- if (value.isEmpty) return _prefs.remove(_jellyfinServerUrlKey);
- return _prefs.setString(_jellyfinServerUrlKey, value);
- }
- Future<void> setJellyfinServerName(String? name) {
- final value = name?.trim();
- if (value == null || value.isEmpty) {
- return _prefs.remove(_jellyfinServerNameKey);
- }
- return _prefs.setString(_jellyfinServerNameKey, value);
- }
- Future<void> setJellyfinUserName(String? name) {
- final value = name?.trim();
- if (value == null || value.isEmpty) {
- return _prefs.remove(_jellyfinUserNameKey);
- }
- return _prefs.setString(_jellyfinUserNameKey, value);
- }
- Future<void> setJellyfinSession({
- required String userId,
- required String userName,
- }) async {
- await _prefs.setString(_jellyfinUserIdKey, userId);
- await _prefs.setString(_jellyfinUserNameKey, userName);
- }
- /// Drops the cached profile but keeps the device id and server URL so the
- /// user can pair again without retyping the address.
- Future<void> clearJellyfinSession() async {
- await _prefs.remove(_jellyfinServerNameKey);
- await _prefs.remove(_jellyfinUserIdKey);
- await _prefs.remove(_jellyfinUserNameKey);
- }
- /// Genres selected for the Random row, stored as `{id,label}` pairs so the
- /// picker and dashboard can show labels while offline. At most five.
- List<JellyfinGenreSelection> get jellyfinGenreSelections {
- final raw = _prefs.getString(_jellyfinGenreFamiliesKey);
- if (raw == null || raw.isEmpty) return const [];
- try {
- final decoded = jsonDecode(raw);
- if (decoded is! List) return const [];
- return decoded
- .whereType<Map>()
- .map(
- (entry) => JellyfinGenreSelection.fromJson(
- Map<String, dynamic>.from(entry),
- ),
- )
- .where((selection) => selection.id.isNotEmpty)
- .take(5)
- .toList(growable: false);
- } catch (_) {
- return const [];
- }
- }
- Future<void> setJellyfinGenreSelections(
- List<JellyfinGenreSelection> selections,
- ) {
- final capped = selections.take(5).toList(growable: false);
- return _prefs.setString(
- _jellyfinGenreFamiliesKey,
- jsonEncode(capped.map((selection) => selection.toJson()).toList()),
- );
- }
- /// A stable per-install device id for Quick Connect. Jellyfin shows it in
- /// its device list, so it must not change between launches.
- Future<String> ensureJellyfinDeviceId() async {
- final existing = _prefs.getString(_jellyfinDeviceIdKey)?.trim();
- if (existing != null && existing.isNotEmpty) return existing;
- final random = Random.secure();
- final id = List.generate(
- 16,
- (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
- ).join();
- await _prefs.setString(_jellyfinDeviceIdKey, id);
- return id;
- }
- Future<void> setHomeRoleHintShown(bool shown) {
- return _prefs.setBool(_homeRoleHintShownKey, shown);
- }
- Future<void> setObdAdapterAddress(String? address) async {
- if (address == null || address.isEmpty) {
- await _prefs.remove(_obdAddressKey);
- } else {
- await _prefs.setString(_obdAddressKey, address);
- }
- }
- Future<void> setWeatherLocationTemperature(bool enabled) {
- return _prefs.setBool(_weatherTemperatureKey, enabled);
- }
- Future<void> setKeepScreenOn(bool enabled) {
- return _prefs.setBool(_keepScreenOnKey, enabled);
- }
- Future<void> setNightWindow({
- required int startMinutes,
- required int endMinutes,
- }) async {
- await _prefs.setInt(_nightStartKey, startMinutes);
- await _prefs.setInt(_nightEndKey, endMinutes);
- }
- bool slotEnabled(SlotType slot) {
- final stored = _prefs.getBool('$_slotPrefix.${slot.key}.enabled');
- if (stored != null) return stored;
- // The three app slots stay visible by default; Jellyfin is opt-in once the
- // integration is paired.
- return slot != SlotType.jellyfin;
- }
- Future<void> setSlotEnabled(SlotType slot, bool enabled) {
- return _prefs.setBool('$_slotPrefix.${slot.key}.enabled', enabled);
- }
- SlotAssignment? slot(SlotType slot) {
- final packageName = _prefs.getString('$_slotPrefix.${slot.key}.package');
- final activityName = _prefs.getString('$_slotPrefix.${slot.key}.activity');
- final label = _prefs.getString('$_slotPrefix.${slot.key}.label');
- if (packageName == null || activityName == null) return null;
- return SlotAssignment(
- packageName: packageName,
- activityName: activityName,
- label: label ?? packageName,
- );
- }
- Future<void> setSlot(SlotType slot, SlotAssignment assignment) async {
- await _prefs.setString(
- '$_slotPrefix.${slot.key}.package',
- assignment.packageName,
- );
- await _prefs.setString(
- '$_slotPrefix.${slot.key}.activity',
- assignment.activityName,
- );
- await _prefs.setString('$_slotPrefix.${slot.key}.label', assignment.label);
- }
- Future<void> clearSlot(SlotType slot) async {
- await _prefs.remove('$_slotPrefix.${slot.key}.package');
- await _prefs.remove('$_slotPrefix.${slot.key}.activity');
- await _prefs.remove('$_slotPrefix.${slot.key}.label');
- }
- /// Apps explicitly pinned to the home screen via All Apps. The three
- /// predefined slots are stored separately.
- List<SlotAssignment> get pinnedApps {
- final raw = _prefs.getString(_pinnedKey);
- if (raw == null || raw.isEmpty) return const <SlotAssignment>[];
- try {
- final decoded = jsonDecode(raw);
- if (decoded is! List) return const <SlotAssignment>[];
- return decoded
- .whereType<Map>()
- .map((entry) => SlotAssignment.fromMap(entry))
- .where(
- (entry) =>
- entry.packageName.isNotEmpty && entry.activityName.isNotEmpty,
- )
- .toList(growable: false);
- } catch (_) {
- return const <SlotAssignment>[];
- }
- }
- Future<void> setPinnedApps(List<SlotAssignment> apps) {
- return _prefs.setString(
- _pinnedKey,
- jsonEncode(apps.map((app) => app.toMap()).toList(growable: false)),
- );
- }
- }
|