settings_repository.dart 11 KB

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