settings_repository.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import 'dart:convert';
  2. import 'dart:math';
  3. import 'package:shared_preferences/shared_preferences.dart';
  4. import '../../core/theme/accent_choice.dart';
  5. import '../../core/theme/theme_preference.dart';
  6. import '../models/jellyfin_library.dart';
  7. import '../models/slot_assignment.dart';
  8. import '../models/slot_type.dart';
  9. /// Persists launcher configuration. This is the single source of truth for
  10. /// appearance, home-button visibility, the automatic night window, and pinned
  11. /// apps as well as the three fixed slots.
  12. class SettingsRepository {
  13. SettingsRepository(this._prefs);
  14. final SharedPreferences _prefs;
  15. static const _themeKey = 'settings.theme';
  16. static const _nightStartKey = 'settings.nightStartMinutes';
  17. static const _nightEndKey = 'settings.nightEndMinutes';
  18. static const _accentKey = 'settings.accent';
  19. static const _showBatteryKey = 'settings.showBattery';
  20. static const _slotPrefix = 'slot';
  21. static const _pinnedKey = 'home.pinned';
  22. static const _firstLaunchAtKey = 'onboarding.firstLaunchAt';
  23. static const _homeRoleHintShownKey = 'onboarding.homeRoleHintShown';
  24. static const _obdAddressKey = 'obd.adapterAddress';
  25. static const _weatherTemperatureKey = 'weather.locationTemperature';
  26. static const _keepScreenOnKey = 'display.keepScreenOn';
  27. static const _speedEnabledKey = 'carData.speedEnabled';
  28. static const _speedUseObdKey = 'carData.speedUseObd';
  29. static const _temperatureEnabledKey = 'carData.temperatureEnabled';
  30. static const _temperatureUseObdKey = 'carData.temperatureUseObd';
  31. static const _jellyfinEnabledKey = 'jellyfin.enabled';
  32. static const _jellyfinServerUrlKey = 'jellyfin.serverUrl';
  33. static const _jellyfinDeviceIdKey = 'jellyfin.deviceId';
  34. static const _jellyfinServerNameKey = 'jellyfin.serverName';
  35. static const _jellyfinUserIdKey = 'jellyfin.userId';
  36. static const _jellyfinUserNameKey = 'jellyfin.userName';
  37. static const _jellyfinGenreFamiliesKey = 'jellyfin.genreFamilies';
  38. static const _jellyfinAudioQualityKey = 'jellyfin.audioQuality';
  39. ThemePreference get themePreference {
  40. return ThemePreference.fromStorage(_prefs.getString(_themeKey));
  41. }
  42. int get nightStartMinutes => _prefs.getInt(_nightStartKey) ?? 19 * 60;
  43. int get nightEndMinutes => _prefs.getInt(_nightEndKey) ?? 7 * 60;
  44. AccentChoice get accent {
  45. return AccentChoice.fromStorage(_prefs.getString(_accentKey));
  46. }
  47. bool get showBattery => _prefs.getBool(_showBatteryKey) ?? true;
  48. /// When CarDash first ran after this installation. Older installs are
  49. /// migrated to the epoch so onboarding copy cannot reappear after an update.
  50. DateTime? get firstLaunchAt {
  51. final millis = _prefs.getInt(_firstLaunchAtKey);
  52. return millis == null ? null : DateTime.fromMillisecondsSinceEpoch(millis);
  53. }
  54. /// Records the first launch once. Existing preferences mean this is an
  55. /// upgraded install, so the onboarding window is treated as already over.
  56. Future<DateTime> ensureFirstLaunchAt(DateTime now) async {
  57. final stored = firstLaunchAt;
  58. if (stored != null) return stored;
  59. final first = _prefs.getKeys().isEmpty
  60. ? now
  61. : DateTime.fromMillisecondsSinceEpoch(0);
  62. await _prefs.setInt(_firstLaunchAtKey, first.millisecondsSinceEpoch);
  63. return first;
  64. }
  65. /// Freezes the one-time migration default for temperature. Existing installs
  66. /// that configured OBD-II or location weather keep the feature; a fresh
  67. /// install starts off so it cannot fetch weather without an explicit choice.
  68. Future<void> ensureTelemetryDefaults() async {
  69. if (_prefs.containsKey(_temperatureEnabledKey)) return;
  70. final wasUsingCarData =
  71. (_prefs.getBool(_weatherTemperatureKey) ?? false) ||
  72. obdAdapterAddress != null;
  73. await _prefs.setBool(_temperatureEnabledKey, wasUsingCarData);
  74. }
  75. bool get homeRoleHintShown {
  76. return _prefs.getBool(_homeRoleHintShownKey) ?? false;
  77. }
  78. /// Whether the speed card may be shown on Home.
  79. bool get speedEnabled => _prefs.getBool(_speedEnabledKey) ?? true;
  80. /// OBD-II is preferred for speed when it is connected; GPS is the fallback.
  81. bool get speedUseObd => _prefs.getBool(_speedUseObdKey) ?? true;
  82. /// Fresh installs start with temperature off. [ensureTelemetryDefaults]
  83. /// preserves it for an upgrade that already had OBD or weather configured.
  84. bool get temperatureEnabled =>
  85. _prefs.getBool(_temperatureEnabledKey) ?? false;
  86. /// OBD-II ambient is preferred when available; weather is the fallback.
  87. bool get temperatureUseObd => _prefs.getBool(_temperatureUseObdKey) ?? true;
  88. /// Whether the optional Jellyfin integration is enabled. The access token is
  89. /// kept in secure storage; this flag and the URL are safe in preferences.
  90. bool get jellyfinEnabled => _prefs.getBool(_jellyfinEnabledKey) ?? false;
  91. String get jellyfinServerUrl {
  92. return _prefs.getString(_jellyfinServerUrlKey)?.trim() ?? '';
  93. }
  94. String? get jellyfinServerName {
  95. final value = _prefs.getString(_jellyfinServerNameKey)?.trim();
  96. return value == null || value.isEmpty ? null : value;
  97. }
  98. String? get jellyfinUserId {
  99. final value = _prefs.getString(_jellyfinUserIdKey)?.trim();
  100. return value == null || value.isEmpty ? null : value;
  101. }
  102. String? get jellyfinUserName {
  103. final value = _prefs.getString(_jellyfinUserNameKey)?.trim();
  104. return value == null || value.isEmpty ? null : value;
  105. }
  106. /// Direct play by default. Bitrate choices request a Jellyfin audio transcode.
  107. JellyfinAudioQuality get jellyfinAudioQuality {
  108. return JellyfinAudioQuality.fromStorage(
  109. _prefs.getString(_jellyfinAudioQualityKey),
  110. );
  111. }
  112. String? get obdAdapterAddress {
  113. final value = _prefs.getString(_obdAddressKey)?.trim();
  114. return value == null || value.isEmpty ? null : value;
  115. }
  116. bool get weatherLocationTemperature {
  117. return _prefs.getBool(_weatherTemperatureKey) ?? false;
  118. }
  119. /// The dashboard keeps the display awake by default.
  120. bool get keepScreenOn {
  121. return _prefs.getBool(_keepScreenOnKey) ?? true;
  122. }
  123. Future<void> setThemePreference(ThemePreference preference) {
  124. return _prefs.setString(_themeKey, preference.storageValue);
  125. }
  126. Future<void> setAccent(AccentChoice accent) {
  127. return _prefs.setString(_accentKey, accent.storageValue);
  128. }
  129. Future<void> setShowBattery(bool show) {
  130. return _prefs.setBool(_showBatteryKey, show);
  131. }
  132. Future<void> setSpeedEnabled(bool enabled) {
  133. return _prefs.setBool(_speedEnabledKey, enabled);
  134. }
  135. Future<void> setSpeedUseObd(bool enabled) {
  136. return _prefs.setBool(_speedUseObdKey, enabled);
  137. }
  138. Future<void> setTemperatureEnabled(bool enabled) {
  139. return _prefs.setBool(_temperatureEnabledKey, enabled);
  140. }
  141. Future<void> setTemperatureUseObd(bool enabled) {
  142. return _prefs.setBool(_temperatureUseObdKey, enabled);
  143. }
  144. Future<void> setJellyfinEnabled(bool enabled) {
  145. return _prefs.setBool(_jellyfinEnabledKey, enabled);
  146. }
  147. Future<void> setJellyfinServerUrl(String url) {
  148. final value = url.trim();
  149. if (value.isEmpty) return _prefs.remove(_jellyfinServerUrlKey);
  150. return _prefs.setString(_jellyfinServerUrlKey, value);
  151. }
  152. Future<void> setJellyfinServerName(String? name) {
  153. final value = name?.trim();
  154. if (value == null || value.isEmpty) {
  155. return _prefs.remove(_jellyfinServerNameKey);
  156. }
  157. return _prefs.setString(_jellyfinServerNameKey, value);
  158. }
  159. Future<void> setJellyfinUserName(String? name) {
  160. final value = name?.trim();
  161. if (value == null || value.isEmpty) {
  162. return _prefs.remove(_jellyfinUserNameKey);
  163. }
  164. return _prefs.setString(_jellyfinUserNameKey, value);
  165. }
  166. Future<void> setJellyfinAudioQuality(JellyfinAudioQuality quality) {
  167. return _prefs.setString(_jellyfinAudioQualityKey, quality.name);
  168. }
  169. Future<void> setJellyfinSession({
  170. required String userId,
  171. required String userName,
  172. }) async {
  173. await _prefs.setString(_jellyfinUserIdKey, userId);
  174. await _prefs.setString(_jellyfinUserNameKey, userName);
  175. }
  176. /// Drops the cached profile but keeps the device id and server URL so the
  177. /// user can pair again without retyping the address.
  178. Future<void> clearJellyfinSession() async {
  179. await _prefs.remove(_jellyfinServerNameKey);
  180. await _prefs.remove(_jellyfinUserIdKey);
  181. await _prefs.remove(_jellyfinUserNameKey);
  182. }
  183. /// Genres selected for the Random row, stored as `{id,label}` pairs so the
  184. /// picker and dashboard can show labels while offline. At most five.
  185. List<JellyfinGenreSelection> get jellyfinGenreSelections {
  186. final raw = _prefs.getString(_jellyfinGenreFamiliesKey);
  187. if (raw == null || raw.isEmpty) return const [];
  188. try {
  189. final decoded = jsonDecode(raw);
  190. if (decoded is! List) return const [];
  191. return decoded
  192. .whereType<Map>()
  193. .map(
  194. (entry) => JellyfinGenreSelection.fromJson(
  195. Map<String, dynamic>.from(entry),
  196. ),
  197. )
  198. .where((selection) => selection.id.isNotEmpty)
  199. .take(5)
  200. .toList(growable: false);
  201. } catch (_) {
  202. return const [];
  203. }
  204. }
  205. Future<void> setJellyfinGenreSelections(
  206. List<JellyfinGenreSelection> selections,
  207. ) {
  208. final capped = selections.take(5).toList(growable: false);
  209. return _prefs.setString(
  210. _jellyfinGenreFamiliesKey,
  211. jsonEncode(capped.map((selection) => selection.toJson()).toList()),
  212. );
  213. }
  214. /// A stable per-install device id for Quick Connect. Jellyfin shows it in
  215. /// its device list, so it must not change between launches.
  216. Future<String> ensureJellyfinDeviceId() async {
  217. final existing = _prefs.getString(_jellyfinDeviceIdKey)?.trim();
  218. if (existing != null && existing.isNotEmpty) return existing;
  219. final random = Random.secure();
  220. final id = List.generate(
  221. 16,
  222. (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
  223. ).join();
  224. await _prefs.setString(_jellyfinDeviceIdKey, id);
  225. return id;
  226. }
  227. Future<void> setHomeRoleHintShown(bool shown) {
  228. return _prefs.setBool(_homeRoleHintShownKey, shown);
  229. }
  230. Future<void> setObdAdapterAddress(String? address) async {
  231. if (address == null || address.isEmpty) {
  232. await _prefs.remove(_obdAddressKey);
  233. } else {
  234. await _prefs.setString(_obdAddressKey, address);
  235. }
  236. }
  237. Future<void> setWeatherLocationTemperature(bool enabled) {
  238. return _prefs.setBool(_weatherTemperatureKey, enabled);
  239. }
  240. Future<void> setKeepScreenOn(bool enabled) {
  241. return _prefs.setBool(_keepScreenOnKey, enabled);
  242. }
  243. Future<void> setNightWindow({
  244. required int startMinutes,
  245. required int endMinutes,
  246. }) async {
  247. await _prefs.setInt(_nightStartKey, startMinutes);
  248. await _prefs.setInt(_nightEndKey, endMinutes);
  249. }
  250. bool slotEnabled(SlotType slot) {
  251. final stored = _prefs.getBool('$_slotPrefix.${slot.key}.enabled');
  252. if (stored != null) return stored;
  253. // The three app slots stay visible by default; Jellyfin is opt-in once the
  254. // integration is paired.
  255. return slot != SlotType.jellyfin;
  256. }
  257. Future<void> setSlotEnabled(SlotType slot, bool enabled) {
  258. return _prefs.setBool('$_slotPrefix.${slot.key}.enabled', enabled);
  259. }
  260. SlotAssignment? slot(SlotType slot) {
  261. final packageName = _prefs.getString('$_slotPrefix.${slot.key}.package');
  262. final activityName = _prefs.getString('$_slotPrefix.${slot.key}.activity');
  263. final label = _prefs.getString('$_slotPrefix.${slot.key}.label');
  264. if (packageName == null || activityName == null) return null;
  265. return SlotAssignment(
  266. packageName: packageName,
  267. activityName: activityName,
  268. label: label ?? packageName,
  269. );
  270. }
  271. Future<void> setSlot(SlotType slot, SlotAssignment assignment) async {
  272. await _prefs.setString(
  273. '$_slotPrefix.${slot.key}.package',
  274. assignment.packageName,
  275. );
  276. await _prefs.setString(
  277. '$_slotPrefix.${slot.key}.activity',
  278. assignment.activityName,
  279. );
  280. await _prefs.setString('$_slotPrefix.${slot.key}.label', assignment.label);
  281. }
  282. Future<void> clearSlot(SlotType slot) async {
  283. await _prefs.remove('$_slotPrefix.${slot.key}.package');
  284. await _prefs.remove('$_slotPrefix.${slot.key}.activity');
  285. await _prefs.remove('$_slotPrefix.${slot.key}.label');
  286. }
  287. /// Apps explicitly pinned to the home screen via All Apps. The three
  288. /// predefined slots are stored separately.
  289. List<SlotAssignment> get pinnedApps {
  290. final raw = _prefs.getString(_pinnedKey);
  291. if (raw == null || raw.isEmpty) return const <SlotAssignment>[];
  292. try {
  293. final decoded = jsonDecode(raw);
  294. if (decoded is! List) return const <SlotAssignment>[];
  295. return decoded
  296. .whereType<Map>()
  297. .map((entry) => SlotAssignment.fromMap(entry))
  298. .where(
  299. (entry) =>
  300. entry.packageName.isNotEmpty && entry.activityName.isNotEmpty,
  301. )
  302. .toList(growable: false);
  303. } catch (_) {
  304. return const <SlotAssignment>[];
  305. }
  306. }
  307. Future<void> setPinnedApps(List<SlotAssignment> apps) {
  308. return _prefs.setString(
  309. _pinnedKey,
  310. jsonEncode(apps.map((app) => app.toMap()).toList(growable: false)),
  311. );
  312. }
  313. }