settings_repository.dart 12 KB

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