@focus8/settings-registry 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,2037 +1,2460 @@
1
- /**
2
- * Settings Registry — single source of truth for ALL FocusPlanner app settings.
3
- *
4
- * This shared package defines every setting key, its category, data type,
5
- * default value, and whether it should be synced to the server.
6
- *
7
- * Used by:
8
- * - The mobile app (local SQLite settings table, React Query hooks)
9
- * - The API (validate & serialize settings profiles)
10
- * - The web portal (render settings viewer/editor)
11
- *
12
- * App-specific defaults (theme, locale, enrolled mode) are parameterized
13
- * via `createRegistry(config)`.
14
- */
15
-
16
- // ---------------------------------------------------------------------------
17
- // Setting types shared across mobile, API, and web
18
- // ---------------------------------------------------------------------------
19
-
20
- export type ThemeSetting = 'light' | 'dark' | 'system';
21
- export type AlarmSound =
22
- | 'none'
23
- | 'alarm1'
24
- | 'alarm2'
25
- | 'alarm3'
26
- | 'alarm4'
27
- | 'alarm5'
28
- | 'alarm6'
29
- | 'alarm7'
30
- | 'alarm8'
31
- | 'alarm9';
32
- export type LocaleCode = 'en' | 'nb';
33
-
34
- export type CalendarType = 'chronological' | 'time-based';
35
- export type ClockType = 'digital' | 'analog';
36
- export type TimePickerMode = 'dials' | 'keypad';
37
- export type AlarmTimeout = 1 | 2 | 3 | 5 | 10;
38
- export const ALARM_TIMEOUTS: AlarmTimeout[] = [1, 2, 3, 5, 10];
39
- export type CalendarViewMode =
40
- | 'day'
41
- | '3-days'
42
- | '5-days'
43
- | '7-days'
44
- | 'week'
45
- | 'month'
46
- | 'overview';
47
- export type CalendarDayViewCellZoom = 15 | 30 | 60;
48
- export type InactivityTimeoutMinutes = 1 | 5 | 10 | 15 | 30 | 45;
49
- export type LockScreenClockDisplay = 'none' | 'digital' | 'analog';
50
- export type LockScreenImageMode = 'none' | 'background' | 'photoFrame';
51
- export type PhotoFrameIntervalSeconds = 30 | 60 | 120 | 300;
52
- export type EventVisibility = 'Public' | 'Private' | 'Custom';
53
- export type SuggestEndTimeUnit = 'minutes' | 'hours';
54
-
55
- export type WeatherLocation = {
56
- address: string;
57
- name?: string;
58
- latitude: number;
59
- longitude: number;
60
- placeId?: string;
61
- };
62
-
63
- export type LockScreenImage = {
64
- uri: string;
65
- filePath: string;
66
- fileName?: string;
67
- fileSize?: number;
68
- width?: number;
69
- height?: number;
70
- order: number;
71
- };
72
-
73
- export const LOCK_SCREEN_MAX_IMAGES = 10;
74
-
75
- export type AllDayPreset = {
76
- daysBefore: number;
77
- time: string;
78
- };
79
-
80
- // ---------------------------------------------------------------------------
81
- // UI type discriminator — determines which component renders each setting
82
- // ---------------------------------------------------------------------------
83
-
84
- export type SettingUiType =
85
- | 'TOGGLE'
86
- | 'SELECT'
87
- | 'VOLUME_SLIDER'
88
- | 'SLIDER'
89
- | 'NUMBER_INPUT'
90
- | 'TEXT_INPUT'
91
- | 'PIN_INPUT'
92
- | 'CUSTOM_THEME_PICKER'
93
- | 'CUSTOM_LANGUAGE_PICKER'
94
- | 'CUSTOM_CALENDAR_TYPE'
95
- | 'CUSTOM_CLOCK_TYPE'
96
- | 'CUSTOM_WEATHER_LOCATION'
97
- | 'CUSTOM_IMAGE'
98
- | 'CUSTOM_IMAGE_ARRAY'
99
- | 'CUSTOM_CALENDAR_IDS'
100
- | 'CUSTOM_REMINDER_PRESETS'
101
- | 'CUSTOM_EVENT_FORM'
102
- | 'CUSTOM_CHRONOLOGICAL_EVENT_FORM'
103
- | 'CUSTOM_DISPLAY_DENSITY'
104
- | 'CUSTOM_BRIGHTNESS'
105
- | 'CUSTOM_SPLIT_VIEW_CONFIG'
106
- | 'HIDDEN';
107
-
108
- export type SettingOption<T = unknown> = {
109
- /** The option value */
110
- value: T;
111
- /** i18n message key for the option label */
112
- labelKey: string;
113
- };
114
-
115
- export type SliderConfig = {
116
- min: number;
117
- max: number;
118
- step: number;
119
- };
120
-
121
- // ---------------------------------------------------------------------------
122
- // Setting categories — used for UI grouping and profile organization
123
- // ---------------------------------------------------------------------------
124
-
125
- export const SETTINGS_CATEGORIES = [
126
- 'appearance',
127
- 'calendarView',
128
- 'calendars',
129
- 'sound',
130
- 'timer',
131
- 'media',
132
- 'lockScreen',
133
- 'touch',
134
- 'device',
135
- 'language',
136
- 'notification',
137
- 'chronological',
138
- 'eventForm',
139
- 'chronologicalEventForm',
140
- ] as const;
141
-
142
- export type SettingsCategory = (typeof SETTINGS_CATEGORIES)[number];
143
-
144
- // ---------------------------------------------------------------------------
145
- // Category display labels — human-readable names for UI rendering
146
- // ---------------------------------------------------------------------------
147
-
148
- export const CATEGORY_LABELS: Record<SettingsCategory, string> = {
149
- appearance: 'Appearance',
150
- calendarView: 'Calendar View',
151
- calendars: 'Calendars',
152
- sound: 'Sound & Alerts',
153
- timer: 'Timer',
154
- media: 'Media',
155
- lockScreen: 'Lock Screen',
156
- touch: 'Touch & Gestures',
157
- device: 'Device',
158
- language: 'Language',
159
- notification: 'Notifications',
160
- chronological: 'Chronological',
161
- eventForm: 'Event Form',
162
- chronologicalEventForm: 'Event Form (Chronological)',
163
- };
164
-
165
- // ---------------------------------------------------------------------------
166
- // Category icons — lucide icon names matching the mobile app
167
- // ---------------------------------------------------------------------------
168
-
169
- export const CATEGORY_ICONS: Record<SettingsCategory, string> = {
170
- appearance: 'Palette',
171
- calendarView: 'Eye',
172
- calendars: 'Calendar',
173
- sound: 'Volume2',
174
- timer: 'Timer',
175
- media: 'Images',
176
- lockScreen: 'Lock',
177
- touch: 'Hand',
178
- device: 'Smartphone',
179
- language: 'Globe',
180
- notification: 'Bell',
181
- chronological: 'List',
182
- eventForm: 'CalendarPlus',
183
- chronologicalEventForm: 'CalendarPlus',
184
- };
185
-
186
- // ---------------------------------------------------------------------------
187
- // Calendar-type filtering — which categories are exclusive to a calendar type
188
- // ---------------------------------------------------------------------------
189
-
190
- /** Categories only shown for chronological calendar type */
191
- export const CHRONOLOGICAL_ONLY_CATEGORIES: ReadonlySet<SettingsCategory> =
192
- new Set<SettingsCategory>(['chronological', 'chronologicalEventForm']);
193
-
194
- /** Categories only shown for time-based calendar type */
195
- export const TIME_BASED_ONLY_CATEGORIES: ReadonlySet<SettingsCategory> =
196
- new Set<SettingsCategory>(['eventForm']);
197
-
198
- /**
199
- * Filter categories based on the active calendar type.
200
- * Categories not exclusive to either type are always included.
201
- */
202
- export function getCategoriesForCalendarType(
203
- calendarType: CalendarType,
204
- ): SettingsCategory[] {
205
- return SETTINGS_CATEGORIES.filter((cat) => {
206
- if (
207
- calendarType === 'time-based' &&
208
- CHRONOLOGICAL_ONLY_CATEGORIES.has(cat)
209
- ) {
210
- return false;
211
- }
212
- if (
213
- calendarType === 'chronological' &&
214
- TIME_BASED_ONLY_CATEGORIES.has(cat)
215
- ) {
216
- return false;
217
- }
218
- return true;
219
- });
220
- }
221
-
222
- // ---------------------------------------------------------------------------
223
- // Settings groups — user-facing groups that reorganize raw categories
224
- // ---------------------------------------------------------------------------
225
-
226
- /**
227
- * Settings group IDs matching the mobile app's settings menu structure.
228
- * These provide user-friendly groupings that may pull settings from multiple
229
- * raw registry categories.
230
- */
231
- export const SETTINGS_GROUP_IDS = [
232
- // Calendar section
233
- 'calendarView',
234
- 'timer',
235
- 'activities',
236
- 'dateWeather',
237
- 'touch',
238
- 'calendarType',
239
- 'chronological',
240
- // Unit section
241
- 'appearance',
242
- 'soundAlerts',
243
- 'lockScreen',
244
- 'language',
245
- 'device',
246
- ] as const;
247
-
248
- export type SettingsGroupId = (typeof SETTINGS_GROUP_IDS)[number];
249
-
250
- export type SettingsGroupDef = {
251
- /** Unique group identifier */
252
- id: SettingsGroupId;
253
- /** i18n message key for the group label */
254
- labelKey: string;
255
- /** Lucide icon name */
256
- icon: string;
257
- /**
258
- * Setting key matchers. A setting is included if its key:
259
- * - equals a matcher exactly, OR
260
- * - starts with a matcher that ends with '.' (prefix match)
261
- */
262
- keys: string[];
263
- /** Only show for this calendar type (undefined = always) */
264
- calendarType?: CalendarType;
265
- /** Only show for this app mode (undefined = always) */
266
- appMode?: 'ENROLLED';
267
- };
268
-
269
- /**
270
- * Group definitions matching the mobile app's settings structure.
271
- *
272
- * ─ i18n keys use the format `Settings.GroupLabel.<Id>` and must exist
273
- * in every consumer's i18n catalog.
274
- */
275
- export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
276
- // ── Calendar section ──────────────────────────────────────────────────
277
- {
278
- id: 'calendarView',
279
- labelKey: 'Settings.CalendarView',
280
- icon: 'Eye',
281
- keys: [
282
- 'calendarView.showCalendarNames',
283
- 'calendarView.splitView',
284
- 'calendarView.dayViewZoom',
285
- 'calendarView.weekViewZoom',
286
- 'calendarView.calendarColumns',
287
- ],
288
- },
289
- {
290
- id: 'timer',
291
- labelKey: 'Settings.TimerTitle',
292
- icon: 'Timer',
293
- keys: [
294
- 'chronological.timer.',
295
- 'timer.',
296
- 'sound.timerAlarmSound',
297
- 'sound.timerAlarmTimeout',
298
- 'sound.timerVolume',
299
- ],
300
- appMode: 'ENROLLED',
301
- },
302
- {
303
- id: 'activities',
304
- labelKey: 'Settings.Activities',
305
- icon: 'CalendarPlus',
306
- keys: ['eventForm.', 'chronologicalEventForm.'],
307
- },
308
- {
309
- id: 'dateWeather',
310
- labelKey: 'Settings.DateWeather',
311
- icon: 'CloudSun',
312
- keys: [
313
- 'calendarView.showWeatherOnTimeline',
314
- 'calendarView.weatherLocation',
315
- 'calendarView.showWeatherOnEvents',
316
- ],
317
- },
318
- {
319
- id: 'touch',
320
- labelKey: 'Settings.FunctionsTitle',
321
- icon: 'Hand',
322
- keys: ['touch.'],
323
- },
324
- {
325
- id: 'calendarType',
326
- labelKey: 'Settings.CalendarType',
327
- icon: 'ArrowLeftRight',
328
- keys: ['calendarView.type'],
329
- },
330
- {
331
- id: 'chronological',
332
- labelKey: 'Settings.Chronological',
333
- icon: 'List',
334
- keys: ['chronological.'],
335
- calendarType: 'chronological',
336
- },
337
- // ── Unit section ──────────────────────────────────────────────────────
338
- {
339
- id: 'appearance',
340
- labelKey: 'Settings.Appearance',
341
- icon: 'Palette',
342
- keys: [
343
- 'appearance.theme',
344
- 'appearance.clockType',
345
- 'appearance.enableDayColors',
346
- ],
347
- },
348
- {
349
- id: 'soundAlerts',
350
- labelKey: 'Settings.SoundAlerts',
351
- icon: 'Volume2',
352
- keys: [
353
- 'sound.reminderVolume',
354
- 'sound.mediaVolume',
355
- 'sound.reminderAlarmSound',
356
- 'sound.startAlarmSound',
357
- 'sound.endAlarmSound',
358
- 'sound.reminderAlarmTimeout',
359
- 'sound.allowCustomReminderSounds',
360
- 'notification.',
361
- 'sound.ttsEnabled',
362
- 'sound.ttsRate',
363
- 'sound.alarmSound',
364
- ],
365
- },
366
- {
367
- id: 'lockScreen',
368
- labelKey: 'Settings.LockScreen',
369
- icon: 'Lock',
370
- keys: ['lockScreen.'],
371
- appMode: 'ENROLLED',
372
- },
373
- {
374
- id: 'language',
375
- labelKey: 'Settings.Language',
376
- icon: 'Globe',
377
- keys: ['language.'],
378
- },
379
- {
380
- id: 'device',
381
- labelKey: 'SettingsDevices.Title',
382
- icon: 'Smartphone',
383
- keys: [
384
- 'device.',
385
- 'calendarView.autoReturnToTodayEnabled',
386
- 'calendarView.autoReturnToTodayTimeoutSeconds',
387
- ],
388
- },
389
- ] as const;
390
-
391
- /**
392
- * Settings excluded from remote editing (web portal / profile management).
393
- * These are device-local or internal-only settings.
394
- */
395
- export const EXCLUDED_DEVICE_SETTINGS: ReadonlySet<string> = new Set([
396
- 'device.id',
397
- 'device.devMenuEnabled',
398
- 'device.authWarningDismissTtlDays',
399
- 'notification.enabled',
400
- 'notification.notifyAllCalendars',
401
- 'notification.enabledCalendarIds',
402
- 'notification.hasBeenPrompted',
403
- ]);
404
-
405
- // ---------------------------------------------------------------------------
406
- // Per-setting i18n label / description keys
407
- // ---------------------------------------------------------------------------
408
-
409
- export type SettingLabelDef = {
410
- /** i18n message key for the setting label */
411
- labelKey: string;
412
- /** Optional i18n message key for a description shown below the label */
413
- descriptionKey?: string;
414
- };
415
-
416
- /**
417
- * Maps each setting key to its i18n label and optional description key.
418
- * These keys must exist in every consumer's i18n catalog.
419
- *
420
- * When a key is missing from this map, consumers should fall back to
421
- * the auto-generated label from `SettingsRegistry.getSettingLabel()`.
422
- */
423
- export const SETTINGS_LABELS: Readonly<Record<string, SettingLabelDef>> = {
424
- // ── Appearance ──────────────────────────────────────────────────────────
425
- 'appearance.theme': { labelKey: 'Settings.SelectTheme' },
426
- 'appearance.clockType': {
427
- labelKey: 'Settings.ClockType',
428
- descriptionKey: 'Settings.ClockTypeDescription',
429
- },
430
- 'appearance.enableDayColors': {
431
- labelKey: 'Settings.EnableDayColors',
432
- descriptionKey: 'Settings.EnableDayColorsDescription',
433
- },
434
-
435
- // ── Calendar type ───────────────────────────────────────────────────────
436
- 'calendarView.type': {
437
- labelKey: 'Settings.CalendarType',
438
- descriptionKey: 'Settings.ModeDescription',
439
- },
440
- 'calendarView.view': { labelKey: 'Settings.Calendar' },
441
-
442
- // ── Calendar view ───────────────────────────────────────────────────────
443
- 'calendarView.showCalendarNames': { labelKey: 'Settings.ShowCalendarNames' },
444
- 'calendarView.splitView': {
445
- labelKey: 'Settings.SplitViewLabel',
446
- descriptionKey: 'Settings.SplitViewDescription',
447
- },
448
- 'calendarView.calendarColumns': {
449
- labelKey: 'Settings.SplitViewConfig',
450
- descriptionKey: 'Settings.SplitViewConfigDescription',
451
- },
452
- 'calendarView.dayViewZoom': {
453
- labelKey: 'Settings.CalendarDayViewIntervalTitle',
454
- descriptionKey: 'Settings.CalendarDayViewIntervalDescription',
455
- },
456
- 'calendarView.weekViewZoom': {
457
- labelKey: 'Settings.CalendarWeekViewIntervalTitle',
458
- descriptionKey: 'Settings.CalendarWeekViewIntervalDescription',
459
- },
460
-
461
- // ── Auto return to today ────────────────────────────────────────────────
462
- 'calendarView.autoReturnToTodayEnabled': {
463
- labelKey: 'Settings.AutoReturnToTodayEnabled',
464
- },
465
- 'calendarView.autoReturnToTodayTimeoutSeconds': {
466
- labelKey: 'Settings.AutoReturnToTodayTimeout',
467
- },
468
-
469
- // ── Weather ─────────────────────────────────────────────────────────────
470
- 'calendarView.weatherLocation': { labelKey: 'Settings.WeatherLocation' },
471
- 'calendarView.showWeatherOnEvents': {
472
- labelKey: 'Settings.ShowWeatherOnEvents',
473
- descriptionKey: 'Settings.ShowWeatherOnEventsDescription',
474
- },
475
- 'calendarView.showWeatherOnTimeline': {
476
- labelKey: 'Settings.ShowWeatherOnTimeline',
477
- descriptionKey: 'Settings.ShowWeatherOnTimelineDescription',
478
- },
479
-
480
- // ── Sound & alerts ──────────────────────────────────────────────────────
481
- 'sound.reminderVolume': {
482
- labelKey: 'Settings.ReminderVolume',
483
- descriptionKey: 'Settings.ReminderVolumeDescription',
484
- },
485
- 'sound.timerVolume': {
486
- labelKey: 'Settings.TimerVolume',
487
- descriptionKey: 'Settings.TimerVolumeDescription',
488
- },
489
- 'sound.mediaVolume': {
490
- labelKey: 'Settings.MediaVolume',
491
- descriptionKey: 'Settings.MediaVolumeDescription',
492
- },
493
- 'sound.alarmSound': { labelKey: 'Settings.AlarmSound' },
494
- 'sound.reminderAlarmSound': { labelKey: 'Settings.ReminderAlarmAsset' },
495
- 'sound.startAlarmSound': { labelKey: 'Settings.StartAlarmSound' },
496
- 'sound.endAlarmSound': { labelKey: 'Settings.EndAlarmSound' },
497
- 'sound.reminderAlarmTimeout': { labelKey: 'Settings.ReminderAlarmTimeout' },
498
- 'sound.timerAlarmSound': { labelKey: 'Settings.TimerAlarmSound' },
499
- 'sound.timerAlarmTimeout': { labelKey: 'Settings.TimerAlarmTimeout' },
500
- 'sound.allowCustomReminderSounds': {
501
- labelKey: 'Settings.AllowCustomReminderSounds',
502
- },
503
- 'sound.ttsEnabled': {
504
- labelKey: 'Settings.EnableSpeech',
505
- descriptionKey: 'Settings.EnableSpeechDescription',
506
- },
507
- 'sound.ttsRate': { labelKey: 'Settings.TtsTitle' },
508
-
509
- // ── Timer ───────────────────────────────────────────────────────────────
510
- 'timer.showTimeRemaining': { labelKey: 'Settings.TimerShowTimeRemaining' },
511
- 'timer.showEndTime': { labelKey: 'Settings.TimerShowEndTime' },
512
- 'timer.showRestartButton': { labelKey: 'Settings.TimerShowRestartButton' },
513
- 'timer.showPauseButton': { labelKey: 'Settings.TimerShowPauseButton' },
514
-
515
- // ── Lock screen ─────────────────────────────────────────────────────────
516
- 'lockScreen.pin': { labelKey: 'Settings.LockScreenTitlePin' },
517
- 'lockScreen.clockDisplay': { labelKey: 'Settings.LockScreenClockTitle' },
518
- 'lockScreen.showDate': { labelKey: 'Settings.LockScreenShowDate' },
519
- 'lockScreen.showHourNumbers': {
520
- labelKey: 'Settings.LockScreenShowHourNumbers',
521
- },
522
- 'lockScreen.imageMode': { labelKey: 'Settings.LockScreenImageModeTitle' },
523
- 'lockScreen.backgroundImage': {
524
- labelKey: 'Settings.LockScreenImageModeBackground',
525
- },
526
- 'lockScreen.photoFrameImages': {
527
- labelKey: 'Settings.LockScreenImageModePhotoFrame',
528
- },
529
- 'lockScreen.photoFrameIntervalSeconds': {
530
- labelKey: 'Settings.LockScreenPhotoFrameIntervalTitle',
531
- },
532
- 'lockScreen.inactivityLockEnabled': {
533
- labelKey: 'SettingsInactivity.EnableLabel',
534
- },
535
- 'lockScreen.inactivityTimeoutMinutes': {
536
- labelKey: 'SettingsInactivity.TimeoutLabel',
537
- },
538
-
539
- // ── Touch / gestures ───────────────────────────────────────────────────
540
- 'touch.enableTapToCreate': {
541
- labelKey: 'Settings.EnableTapToCreate',
542
- descriptionKey: 'Settings.EnableTapToCreateDescription',
543
- },
544
- 'touch.enableDragDrop': {
545
- labelKey: 'Settings.EnableDragDrop',
546
- descriptionKey: 'Settings.EnableDragDropDescription',
547
- },
548
-
549
- // ── Device ──────────────────────────────────────────────────────────────
550
- 'device.timePickerMode': { labelKey: 'Settings.DateTimeTitle' },
551
-
552
- // ── Language ────────────────────────────────────────────────────────────
553
- 'language.locale': { labelKey: 'Settings.SelectLanguage' },
554
-
555
- // ── Notifications ───────────────────────────────────────────────────────
556
- 'notification.enabled': { labelKey: 'Settings.NotificationsEnabled' },
557
- 'notification.notifyAllCalendars': {
558
- labelKey: 'Settings.NotifyAllCalendars',
559
- },
560
- 'notification.enabledCalendarIds': {
561
- labelKey: 'Settings.NotificationsCalendars',
562
- },
563
-
564
- // ── Chronological header ────────────────────────────────────────────────
565
- 'chronological.header.showNavigationArrows': {
566
- labelKey: 'ChronologicalFeatures.NavigationArrows',
567
- },
568
- 'chronological.header.showClock': {
569
- labelKey: 'ChronologicalFeatures.Clock',
570
- },
571
- 'chronological.header.showCurrentYearInDate': {
572
- labelKey: 'ChronologicalFeatures.ShowCurrentYear',
573
- },
574
- 'chronological.header.showTimeOfDay': {
575
- labelKey: 'ChronologicalFeatures.TimeOfDay',
576
- },
577
-
578
- // ── Chronological footer ────────────────────────────────────────────────
579
- 'chronological.footer.showMenuButton': {
580
- labelKey: 'ChronologicalFeatures.MenuButton',
581
- },
582
- 'chronological.footer.showViewSwitcherDay': {
583
- labelKey: 'ChronologicalFeatures.ViewSwitcherDay',
584
- },
585
- 'chronological.footer.showViewSwitcherWeek': {
586
- labelKey: 'ChronologicalFeatures.ViewSwitcherWeek',
587
- },
588
- 'chronological.footer.showViewSwitcherMonth': {
589
- labelKey: 'ChronologicalFeatures.ViewSwitcherMonth',
590
- },
591
- 'chronological.footer.showTimerButton': {
592
- labelKey: 'ChronologicalFeatures.TimerButton',
593
- },
594
- 'chronological.footer.showNewEventButton': {
595
- labelKey: 'ChronologicalFeatures.NewEventButton',
596
- },
597
- 'chronological.footer.showSettingsButton': {
598
- labelKey: 'ChronologicalFeatures.SettingsButton',
599
- },
600
-
601
- // ── Chronological timer ─────────────────────────────────────────────────
602
- 'chronological.timer.showNewCountdown': {
603
- labelKey: 'TimerFeatures.ShowNewCountdown',
604
- },
605
- 'chronological.timer.showFromTemplate': {
606
- labelKey: 'TimerFeatures.ShowFromTemplate',
607
- },
608
- 'chronological.timer.showEditTemplate': {
609
- labelKey: 'TimerFeatures.ShowEditTemplate',
610
- },
611
- 'chronological.timer.showDeleteTemplate': {
612
- labelKey: 'TimerFeatures.ShowDeleteTemplate',
613
- },
614
- 'chronological.timer.showAddTemplate': {
615
- labelKey: 'TimerFeatures.ShowAddTemplate',
616
- },
617
-
618
- // ── Chronological menu ──────────────────────────────────────────────────
619
- 'chronological.menu.showSettingsButton': {
620
- labelKey: 'ChronologicalFeatures.MenuSettingsButton',
621
- },
622
- 'chronological.quickSettings.showTimerVolume': {
623
- labelKey: 'ChronologicalFeatures.QuickSettingsTimerVolume',
624
- },
625
- 'chronological.quickSettings.showReminderVolume': {
626
- labelKey: 'ChronologicalFeatures.QuickSettingsReminderVolume',
627
- },
628
- 'chronological.quickSettings.showMediaVolume': {
629
- labelKey: 'ChronologicalFeatures.QuickSettingsMediaVolume',
630
- },
631
- 'chronological.quickSettings.showBrightness': {
632
- labelKey: 'ChronologicalFeatures.QuickSettingsBrightness',
633
- },
634
- 'chronological.quickSettings.showLockScreen': {
635
- labelKey: 'ChronologicalFeatures.QuickSettingsLockScreen',
636
- },
637
-
638
- // ── Event form (time-based) ─────────────────────────────────────────────
639
- 'eventForm.recurrence': { labelKey: 'Calendar.LabelRecurrence' },
640
- 'eventForm.reminders': { labelKey: 'Calendar.LabelReminders' },
641
- 'eventForm.emailReminders': { labelKey: 'Calendar.LabelEmailReminders' },
642
- 'eventForm.location': { labelKey: 'Common.Location' },
643
- 'eventForm.travelTime': { labelKey: 'TravelTime.Title' },
644
- 'eventForm.description': { labelKey: 'Common.Description' },
645
- 'eventForm.checklist': { labelKey: 'EventChecklist.DefaultName' },
646
- 'eventForm.images': { labelKey: 'EventImageGallery.SectionTitle' },
647
- 'eventForm.audioClips': { labelKey: 'EventAudioGallery.SectionTitle' },
648
- 'eventForm.notificationReceivers': { labelKey: 'EventForm.NotificationReceivers' },
649
- 'eventForm.visibility': { labelKey: 'EventVisibility.Title' },
650
- };
651
-
652
- /**
653
- * Check whether a setting key matches a group's key matchers.
654
- */
655
- function matchesGroup(key: string, group: SettingsGroupDef): boolean {
656
- return group.keys.some(
657
- (matcher) =>
658
- key === matcher || (matcher.endsWith('.') && key.startsWith(matcher)),
659
- );
660
- }
661
-
662
- /** Return the index of the first matching key pattern in the group, for sorting. */
663
- function groupKeyIndex(key: string, keys: readonly string[]): number {
664
- for (let i = 0; i < keys.length; i++) {
665
- const matcher = keys[i];
666
- if (key === matcher || (matcher.endsWith('.') && key.startsWith(matcher))) {
667
- return i;
668
- }
669
- }
670
- return keys.length;
671
- }
672
-
673
- /**
674
- * Lazy-initialized map from setting key → declaration index in buildEntries.
675
- * Used as a tiebreaker when multiple keys share the same prefix group index.
676
- */
677
- let _registryKeyOrder: Map<string, number> | undefined;
678
- function registryKeyOrder(): Map<string, number> {
679
- if (!_registryKeyOrder) {
680
- _registryKeyOrder = new Map();
681
- const keys = Object.keys(buildEntries(DEFAULT_REGISTRY_CONFIG));
682
- for (let i = 0; i < keys.length; i++) {
683
- _registryKeyOrder.set(keys[i], i);
684
- }
685
- }
686
- return _registryKeyOrder;
687
- }
688
-
689
- /**
690
- * Group parsed setting entries into user-facing groups matching the mobile
691
- * app's settings menu structure.
692
- *
693
- * Takes a flat list of parsed settings (from `parseSettingsSnapshot`) and
694
- * buckets them into the standard groups, filtering by calendar type and
695
- * excluded keys.
696
- */
697
- export function groupSettingsForDevice(
698
- allSettings: ParsedSettingEntry[],
699
- calendarType: CalendarType,
700
- appMode?: 'ENROLLED',
701
- ): { id: SettingsGroupId; labelKey: string; icon: string; settings: ParsedSettingEntry[] }[] {
702
- // Filter excluded and hidden settings
703
- const settings = allSettings.filter(
704
- (s) => !EXCLUDED_DEVICE_SETTINGS.has(s.key) && s.uiType !== 'HIDDEN',
705
- );
706
-
707
- // Calendar type filtering for event form keys
708
- const isChronological = calendarType === 'chronological';
709
- const filteredSettings = settings.filter((s) => {
710
- if (isChronological && s.key.startsWith('eventForm.')) {
711
- return false;
712
- }
713
- if (!isChronological && s.key.startsWith('chronologicalEventForm.')) {
714
- return false;
715
- }
716
- // Per-setting calendar type restriction
717
- if (s.calendarType && s.calendarType !== calendarType) {
718
- return false;
719
- }
720
- // Per-setting app mode restriction
721
- if (s.appMode && s.appMode !== appMode) {
722
- return false;
723
- }
724
- return true;
725
- });
726
-
727
- const claimed = new Set<string>();
728
- const result: {
729
- id: SettingsGroupId;
730
- labelKey: string;
731
- icon: string;
732
- settings: ParsedSettingEntry[];
733
- }[] = [];
734
-
735
- for (const group of SETTINGS_GROUPS) {
736
- // Skip groups not relevant for current calendar type
737
- if (group.calendarType && group.calendarType !== calendarType) {
738
- continue;
739
- }
740
-
741
- // Skip groups not relevant for current app mode
742
- if (group.appMode && group.appMode !== appMode) {
743
- continue;
744
- }
745
-
746
- const matched = filteredSettings.filter(
747
- (s) => !claimed.has(s.key) && matchesGroup(s.key, group),
748
- );
749
- if (matched.length === 0) {
750
- continue;
751
- }
752
-
753
- // Sort matched settings by the order defined in group.keys,
754
- // with registry declaration order as tiebreaker for prefix matches
755
- const order = registryKeyOrder();
756
- matched.sort((a, b) => {
757
- const aIdx = groupKeyIndex(a.key, group.keys);
758
- const bIdx = groupKeyIndex(b.key, group.keys);
759
- if (aIdx !== bIdx) {
760
- return aIdx - bIdx;
761
- }
762
- return (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity);
763
- });
764
-
765
- for (const s of matched) {
766
- claimed.add(s.key);
767
- }
768
-
769
- result.push({
770
- id: group.id,
771
- labelKey: group.labelKey,
772
- icon: group.icon,
773
- settings: matched,
774
- });
775
- }
776
-
777
- return result;
778
- }
779
-
780
- // ---------------------------------------------------------------------------
781
- // Data type discriminator — stored alongside each setting in the DB
782
- // ---------------------------------------------------------------------------
783
-
784
- export type SettingsDataType = 'string' | 'number' | 'boolean' | 'json';
785
-
786
- // ---------------------------------------------------------------------------
787
- // Single registry entry definition
788
- // ---------------------------------------------------------------------------
789
-
790
- export type SettingDef<T = unknown> = {
791
- category: SettingsCategory;
792
- type: SettingsDataType;
793
- default: T;
794
- /** Whether this setting should be synced to the server. Default true. */
795
- sync: boolean;
796
- /** UI component type for rendering this setting */
797
- uiType: SettingUiType;
798
- /** Available options for SELECT-type settings */
799
- options?: readonly SettingOption<T>[];
800
- /** Slider configuration for SLIDER-type settings */
801
- sliderConfig?: SliderConfig;
802
- /** Only show this setting for enrolled/kiosk devices (undefined = always) */
803
- appMode?: 'ENROLLED';
804
- /** Only show this setting for a specific calendar type (undefined = always) */
805
- calendarType?: CalendarType;
806
- };
807
-
808
- // ---------------------------------------------------------------------------
809
- // Configuration for app-specific defaults
810
- // ---------------------------------------------------------------------------
811
-
812
- export type RegistryConfig = {
813
- /** Whether the device is in enrolled/kiosk mode */
814
- isEnrolled: boolean;
815
- /** Default theme code */
816
- defaultTheme: ThemeSetting;
817
- /** Default locale code */
818
- defaultLocale: LocaleCode;
819
- };
820
-
821
- /** Default config for non-enrolled mode */
822
- export const DEFAULT_REGISTRY_CONFIG: RegistryConfig = {
823
- isEnrolled: false,
824
- defaultTheme: 'system',
825
- defaultLocale: 'nb',
826
- };
827
-
828
- // ---------------------------------------------------------------------------
829
- // Shared option arrays (reused across multiple settings)
830
- // ---------------------------------------------------------------------------
831
-
832
- const ALARM_SOUND_OPTIONS: readonly SettingOption<AlarmSound>[] = [
833
- { value: 'none', labelKey: 'Settings.Option.AlarmSound.None' },
834
- { value: 'alarm1', labelKey: 'Settings.Option.AlarmSound.Alarm1' },
835
- { value: 'alarm2', labelKey: 'Settings.Option.AlarmSound.Alarm2' },
836
- { value: 'alarm3', labelKey: 'Settings.Option.AlarmSound.Alarm3' },
837
- { value: 'alarm4', labelKey: 'Settings.Option.AlarmSound.Alarm4' },
838
- { value: 'alarm5', labelKey: 'Settings.Option.AlarmSound.Alarm5' },
839
- { value: 'alarm6', labelKey: 'Settings.Option.AlarmSound.Alarm6' },
840
- { value: 'alarm7', labelKey: 'Settings.Option.AlarmSound.Alarm7' },
841
- { value: 'alarm8', labelKey: 'Settings.Option.AlarmSound.Alarm8' },
842
- { value: 'alarm9', labelKey: 'Settings.Option.AlarmSound.Alarm9' },
843
- ];
844
-
845
- const ALARM_TIMEOUT_OPTIONS: readonly SettingOption<AlarmTimeout>[] = [
846
- { value: 1, labelKey: 'Settings.Option.AlarmTimeout.1min' },
847
- { value: 2, labelKey: 'Settings.Option.AlarmTimeout.2min' },
848
- { value: 3, labelKey: 'Settings.Option.AlarmTimeout.3min' },
849
- { value: 5, labelKey: 'Settings.Option.AlarmTimeout.5min' },
850
- { value: 10, labelKey: 'Settings.Option.AlarmTimeout.10min' },
851
- ];
852
-
853
- const DAY_VIEW_ZOOM_OPTIONS: readonly SettingOption<CalendarDayViewCellZoom>[] = [
854
- { value: 15, labelKey: 'Settings.Option.DayViewZoom.15min' },
855
- { value: 30, labelKey: 'Settings.Option.DayViewZoom.30min' },
856
- { value: 60, labelKey: 'Settings.Option.DayViewZoom.1hour' },
857
- ];
858
-
859
- const TIME_OF_DAY_SLIDER: SliderConfig = { min: 0, max: 23, step: 1 };
860
-
861
- // ---------------------------------------------------------------------------
862
- // Helper
863
- // ---------------------------------------------------------------------------
864
-
865
- function def<T>(
866
- category: SettingsCategory,
867
- type: SettingsDataType,
868
- defaultValue: T,
869
- uiType: SettingUiType,
870
- extra?: {
871
- sync?: boolean;
872
- options?: readonly SettingOption<T>[];
873
- sliderConfig?: SliderConfig;
874
- appMode?: 'ENROLLED';
875
- calendarType?: CalendarType;
876
- },
877
- ): SettingDef<T> {
878
- const result: SettingDef<T> = {
879
- category,
880
- type,
881
- default: defaultValue,
882
- sync: extra?.sync ?? true,
883
- uiType,
884
- };
885
- if (extra?.options) {
886
- result.options = extra.options;
887
- }
888
- if (extra?.sliderConfig) {
889
- result.sliderConfig = extra.sliderConfig;
890
- }
891
- if (extra?.appMode) {
892
- result.appMode = extra.appMode;
893
- }
894
- if (extra?.calendarType) {
895
- result.calendarType = extra.calendarType;
896
- }
897
- return result;
898
- }
899
-
900
- // ---------------------------------------------------------------------------
901
- // Entry builder (internal — used by SettingsRegistry class and factory)
902
- // ---------------------------------------------------------------------------
903
-
904
- function buildEntries(config: RegistryConfig) {
905
- return {
906
- // ═══════════════════════════════════════════════════════════════════════
907
- // Appearance
908
- // ═══════════════════════════════════════════════════════════════════════
909
- 'appearance.theme': def<ThemeSetting>(
910
- 'appearance',
911
- 'string',
912
- config.defaultTheme,
913
- 'CUSTOM_THEME_PICKER',
914
- {
915
- options: [
916
- { value: 'light', labelKey: 'Settings.Option.Theme.Light' },
917
- { value: 'dark', labelKey: 'Settings.Option.Theme.Dark' },
918
- { value: 'system', labelKey: 'Settings.Option.Theme.System' },
919
- ],
920
- },
921
- ),
922
- 'appearance.clockType': def<ClockType>(
923
- 'appearance',
924
- 'string',
925
- 'digital',
926
- 'CUSTOM_CLOCK_TYPE',
927
- {
928
- options: [
929
- { value: 'digital', labelKey: 'Settings.Option.ClockType.Digital' },
930
- { value: 'analog', labelKey: 'Settings.Option.ClockType.Analog' },
931
- ],
932
- appMode: 'ENROLLED',
933
- },
934
- ),
935
- 'appearance.enableDayColors': def<boolean>(
936
- 'appearance',
937
- 'boolean',
938
- false,
939
- 'TOGGLE',
940
- { calendarType: 'chronological' },
941
- ),
942
-
943
- // ═══════════════════════════════════════════════════════════════════════
944
- // Calendar view
945
- // ═══════════════════════════════════════════════════════════════════════
946
- 'calendarView.type': def<CalendarType>(
947
- 'calendarView',
948
- 'string',
949
- 'time-based',
950
- 'CUSTOM_CALENDAR_TYPE',
951
- {
952
- options: [
953
- {
954
- value: 'chronological',
955
- labelKey: 'Settings.Option.CalendarType.Chronological',
956
- },
957
- {
958
- value: 'time-based',
959
- labelKey: 'Settings.Option.CalendarType.TimeBased',
960
- },
961
- ],
962
- },
963
- ),
964
- 'calendarView.view': def<CalendarViewMode>(
965
- 'calendarView',
966
- 'string',
967
- 'day',
968
- 'SELECT',
969
- {
970
- options: [
971
- { value: 'day', labelKey: 'Settings.Option.CalendarView.Day' },
972
- {
973
- value: '3-days',
974
- labelKey: 'Settings.Option.CalendarView.3Days',
975
- },
976
- {
977
- value: '5-days',
978
- labelKey: 'Settings.Option.CalendarView.5Days',
979
- },
980
- {
981
- value: '7-days',
982
- labelKey: 'Settings.Option.CalendarView.7Days',
983
- },
984
- { value: 'week', labelKey: 'Settings.Option.CalendarView.Week' },
985
- {
986
- value: 'month',
987
- labelKey: 'Settings.Option.CalendarView.Month',
988
- },
989
- {
990
- value: 'overview',
991
- labelKey: 'Settings.Option.CalendarView.Overview',
992
- },
993
- ],
994
- },
995
- ),
996
- 'calendarView.dayViewZoom': def<CalendarDayViewCellZoom>(
997
- 'calendarView',
998
- 'number',
999
- 60,
1000
- 'SELECT',
1001
- { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
1002
- ),
1003
- 'calendarView.weekViewZoom': def<CalendarDayViewCellZoom>(
1004
- 'calendarView',
1005
- 'number',
1006
- 60,
1007
- 'SELECT',
1008
- { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
1009
- ),
1010
- 'calendarView.splitView': def<boolean>(
1011
- 'calendarView',
1012
- 'boolean',
1013
- false,
1014
- 'TOGGLE',
1015
- ),
1016
- 'calendarView.showCalendarNames': def<boolean>(
1017
- 'calendarView',
1018
- 'boolean',
1019
- true,
1020
- 'TOGGLE',
1021
- ),
1022
- 'calendarView.calendarColumns': def<unknown[]>(
1023
- 'calendarView',
1024
- 'json',
1025
- [],
1026
- 'CUSTOM_SPLIT_VIEW_CONFIG',
1027
- ),
1028
- 'calendarView.autoReturnToTodayEnabled': def<boolean>(
1029
- 'calendarView',
1030
- 'boolean',
1031
- config.isEnrolled,
1032
- 'TOGGLE',
1033
- ),
1034
- 'calendarView.autoReturnToTodayTimeoutSeconds': def<number>(
1035
- 'calendarView',
1036
- 'number',
1037
- 300,
1038
- 'SLIDER',
1039
- { sliderConfig: { min: 30, max: 600, step: 30 } },
1040
- ),
1041
- 'calendarView.showWeatherOnEvents': def<boolean>(
1042
- 'calendarView',
1043
- 'boolean',
1044
- false,
1045
- 'TOGGLE',
1046
- ),
1047
- 'calendarView.showWeatherOnTimeline': def<boolean>(
1048
- 'calendarView',
1049
- 'boolean',
1050
- false,
1051
- 'TOGGLE',
1052
- ),
1053
- 'calendarView.weatherLocation': def<WeatherLocation | null>(
1054
- 'calendarView',
1055
- 'json',
1056
- null,
1057
- 'CUSTOM_WEATHER_LOCATION',
1058
- ),
1059
-
1060
- // ═══════════════════════════════════════════════════════════════════════
1061
- // Event form field visibility (time-based)
1062
- // ═══════════════════════════════════════════════════════════════════════
1063
- 'eventForm.recurrence': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1064
- 'eventForm.location': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1065
- 'eventForm.travelTime': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1066
- 'eventForm.reminders': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1067
- 'eventForm.emailReminders': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1068
- 'eventForm.description': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1069
- 'eventForm.checklist': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1070
- 'eventForm.images': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1071
- 'eventForm.audioClips': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1072
- 'eventForm.notificationReceivers': def<boolean>(
1073
- 'eventForm',
1074
- 'boolean',
1075
- true,
1076
- 'TOGGLE',
1077
- ),
1078
- 'eventForm.visibility': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1079
-
1080
- // ═══════════════════════════════════════════════════════════════════════
1081
- // Sound & alerts
1082
- // ═══════════════════════════════════════════════════════════════════════
1083
- 'sound.timerVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER'),
1084
- 'sound.reminderVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
1085
- 'sound.mediaVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
1086
- 'sound.alarmSound': def<AlarmSound>(
1087
- 'sound',
1088
- 'string',
1089
- 'alarm1',
1090
- 'SELECT',
1091
- { options: ALARM_SOUND_OPTIONS },
1092
- ),
1093
- 'sound.reminderAlarmSound': def<AlarmSound>(
1094
- 'sound',
1095
- 'string',
1096
- 'alarm1',
1097
- 'SELECT',
1098
- { options: ALARM_SOUND_OPTIONS },
1099
- ),
1100
- 'sound.startAlarmSound': def<AlarmSound>(
1101
- 'sound',
1102
- 'string',
1103
- 'none',
1104
- 'SELECT',
1105
- { options: ALARM_SOUND_OPTIONS, appMode: 'ENROLLED' },
1106
- ),
1107
- 'sound.endAlarmSound': def<AlarmSound>(
1108
- 'sound',
1109
- 'string',
1110
- 'none',
1111
- 'SELECT',
1112
- { options: ALARM_SOUND_OPTIONS, appMode: 'ENROLLED' },
1113
- ),
1114
- 'sound.timerAlarmSound': def<AlarmSound>(
1115
- 'sound',
1116
- 'string',
1117
- 'alarm1',
1118
- 'SELECT',
1119
- { options: ALARM_SOUND_OPTIONS },
1120
- ),
1121
- 'sound.timerAlarmTimeout': def<AlarmTimeout>(
1122
- 'sound',
1123
- 'number',
1124
- 3,
1125
- 'SELECT',
1126
- { options: ALARM_TIMEOUT_OPTIONS },
1127
- ),
1128
- 'sound.reminderAlarmTimeout': def<AlarmTimeout>(
1129
- 'sound',
1130
- 'number',
1131
- 3,
1132
- 'SELECT',
1133
- { options: ALARM_TIMEOUT_OPTIONS },
1134
- ),
1135
- 'sound.allowCustomReminderSounds': def<boolean>(
1136
- 'sound',
1137
- 'boolean',
1138
- false,
1139
- 'TOGGLE',
1140
- ),
1141
- 'sound.ttsEnabled': def<boolean>(
1142
- 'sound',
1143
- 'boolean',
1144
- config.isEnrolled,
1145
- 'TOGGLE',
1146
- { appMode: 'ENROLLED' },
1147
- ),
1148
- 'sound.ttsRate': def<number>(
1149
- 'sound',
1150
- 'number',
1151
- 1.0,
1152
- 'SLIDER',
1153
- { sliderConfig: { min: 0.5, max: 2, step: 0.1 }, appMode: 'ENROLLED' },
1154
- ),
1155
-
1156
- // ═══════════════════════════════════════════════════════════════════════
1157
- // Timer
1158
- // ═══════════════════════════════════════════════════════════════════════
1159
- 'timer.showTimeRemaining': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1160
- 'timer.showEndTime': def<boolean>('timer', 'boolean', true, 'TOGGLE', { calendarType: 'time-based' }),
1161
- 'timer.showRestartButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1162
- 'timer.showPauseButton': def<boolean>('timer', 'boolean', true, 'TOGGLE', { calendarType: 'time-based' }),
1163
-
1164
- // ═══════════════════════════════════════════════════════════════════════
1165
- // Lock screen
1166
- // ═══════════════════════════════════════════════════════════════════════
1167
- 'lockScreen.inactivityLockEnabled': def<boolean>(
1168
- 'lockScreen',
1169
- 'boolean',
1170
- false,
1171
- 'TOGGLE',
1172
- ),
1173
- 'lockScreen.inactivityTimeoutMinutes': def<InactivityTimeoutMinutes>(
1174
- 'lockScreen',
1175
- 'number',
1176
- 5,
1177
- 'SELECT',
1178
- {
1179
- options: [
1180
- { value: 1, labelKey: 'Settings.Option.InactivityTimeout.1min' },
1181
- { value: 5, labelKey: 'Settings.Option.InactivityTimeout.5min' },
1182
- { value: 10, labelKey: 'Settings.Option.InactivityTimeout.10min' },
1183
- { value: 15, labelKey: 'Settings.Option.InactivityTimeout.15min' },
1184
- { value: 30, labelKey: 'Settings.Option.InactivityTimeout.30min' },
1185
- { value: 45, labelKey: 'Settings.Option.InactivityTimeout.45min' },
1186
- ],
1187
- },
1188
- ),
1189
- 'lockScreen.pin': def<string>('lockScreen', 'string', '', 'PIN_INPUT'),
1190
- 'lockScreen.clockDisplay': def<LockScreenClockDisplay>(
1191
- 'lockScreen',
1192
- 'string',
1193
- 'digital',
1194
- 'SELECT',
1195
- {
1196
- options: [
1197
- { value: 'none', labelKey: 'Settings.Option.ClockDisplay.None' },
1198
- {
1199
- value: 'digital',
1200
- labelKey: 'Settings.Option.ClockDisplay.Digital',
1201
- },
1202
- {
1203
- value: 'analog',
1204
- labelKey: 'Settings.Option.ClockDisplay.Analog',
1205
- },
1206
- ],
1207
- },
1208
- ),
1209
- 'lockScreen.showHourNumbers': def<boolean>(
1210
- 'lockScreen',
1211
- 'boolean',
1212
- true,
1213
- 'TOGGLE',
1214
- ),
1215
- 'lockScreen.showDate': def<boolean>(
1216
- 'lockScreen',
1217
- 'boolean',
1218
- true,
1219
- 'TOGGLE',
1220
- ),
1221
- 'lockScreen.imageMode': def<LockScreenImageMode>(
1222
- 'lockScreen',
1223
- 'string',
1224
- 'none',
1225
- 'SELECT',
1226
- {
1227
- options: [
1228
- { value: 'none', labelKey: 'Settings.Option.ImageMode.None' },
1229
- {
1230
- value: 'background',
1231
- labelKey: 'Settings.Option.ImageMode.Background',
1232
- },
1233
- {
1234
- value: 'photoFrame',
1235
- labelKey: 'Settings.Option.ImageMode.PhotoFrame',
1236
- },
1237
- ],
1238
- },
1239
- ),
1240
- 'lockScreen.backgroundImage': def<string | null>(
1241
- 'lockScreen',
1242
- 'json',
1243
- null,
1244
- 'CUSTOM_IMAGE',
1245
- ),
1246
- 'lockScreen.photoFrameIntervalSeconds': def<PhotoFrameIntervalSeconds>(
1247
- 'lockScreen',
1248
- 'number',
1249
- 60,
1250
- 'SELECT',
1251
- {
1252
- options: [
1253
- { value: 30, labelKey: 'Settings.Option.PhotoFrameInterval.30sec' },
1254
- { value: 60, labelKey: 'Settings.Option.PhotoFrameInterval.1min' },
1255
- { value: 120, labelKey: 'Settings.Option.PhotoFrameInterval.2min' },
1256
- { value: 300, labelKey: 'Settings.Option.PhotoFrameInterval.5min' },
1257
- ],
1258
- },
1259
- ),
1260
-
1261
- 'lockScreen.photoFrameImages': def<LockScreenImage[]>(
1262
- 'lockScreen',
1263
- 'json',
1264
- [],
1265
- 'CUSTOM_IMAGE_ARRAY',
1266
- ),
1267
-
1268
- // ═══════════════════════════════════════════════════════════════════════
1269
- // Touch / gestures
1270
- // ═══════════════════════════════════════════════════════════════════════
1271
- 'touch.enableTapToCreate': def<boolean>('touch', 'boolean', false, 'TOGGLE'),
1272
- 'touch.enableDragDrop': def<boolean>('touch', 'boolean', false, 'TOGGLE'),
1273
-
1274
- // ═══════════════════════════════════════════════════════════════════════
1275
- // Device (not synced unless noted)
1276
- // ═══════════════════════════════════════════════════════════════════════
1277
- 'device.id': def<string>('device', 'string', '', 'HIDDEN', { sync: false }),
1278
- 'device.timePickerMode': def<TimePickerMode>(
1279
- 'device',
1280
- 'string',
1281
- 'dials',
1282
- 'SELECT',
1283
- {
1284
- options: [
1285
- { value: 'dials', labelKey: 'Settings.Option.TimePickerMode.Dials' },
1286
- {
1287
- value: 'keypad',
1288
- labelKey: 'Settings.Option.TimePickerMode.Keypad',
1289
- },
1290
- ],
1291
- },
1292
- ),
1293
- 'device.devMenuEnabled': def<boolean>(
1294
- 'device',
1295
- 'boolean',
1296
- false,
1297
- 'HIDDEN',
1298
- { sync: false },
1299
- ),
1300
- 'device.authWarningDismissTtlDays': def<number>(
1301
- 'device',
1302
- 'number',
1303
- 3,
1304
- 'HIDDEN',
1305
- { sync: false },
1306
- ),
1307
-
1308
- // ═══════════════════════════════════════════════════════════════════════
1309
- // Language
1310
- // ═══════════════════════════════════════════════════════════════════════
1311
- 'language.locale': def<LocaleCode>(
1312
- 'language',
1313
- 'string',
1314
- config.defaultLocale,
1315
- 'CUSTOM_LANGUAGE_PICKER',
1316
- {
1317
- options: [
1318
- { value: 'nb', labelKey: 'Settings.Option.Language.Norwegian' },
1319
- { value: 'en', labelKey: 'Settings.Option.Language.English' },
1320
- ],
1321
- },
1322
- ),
1323
-
1324
- // ═══════════════════════════════════════════════════════════════════════
1325
- // Notifications
1326
- // ═══════════════════════════════════════════════════════════════════════
1327
- 'notification.enabled': def<boolean>(
1328
- 'notification',
1329
- 'boolean',
1330
- false,
1331
- 'TOGGLE',
1332
- ),
1333
- 'notification.notifyAllCalendars': def<boolean>(
1334
- 'notification',
1335
- 'boolean',
1336
- true,
1337
- 'TOGGLE',
1338
- ),
1339
- 'notification.enabledCalendarIds': def<string[]>(
1340
- 'notification',
1341
- 'json',
1342
- [],
1343
- 'CUSTOM_CALENDAR_IDS',
1344
- ),
1345
- 'notification.hasBeenPrompted': def<boolean>(
1346
- 'notification',
1347
- 'boolean',
1348
- false,
1349
- 'HIDDEN',
1350
- { sync: false },
1351
- ),
1352
-
1353
- // ═══════════════════════════════════════════════════════════════════════
1354
- // Chronological features (header, footer, menu, quick settings, timer)
1355
- // ═══════════════════════════════════════════════════════════════════════
1356
- // Header
1357
- 'chronological.header.showNavigationArrows': def<boolean>(
1358
- 'chronological',
1359
- 'boolean',
1360
- true,
1361
- 'TOGGLE',
1362
- ),
1363
- 'chronological.header.showCurrentYearInDate': def<boolean>(
1364
- 'chronological',
1365
- 'boolean',
1366
- false,
1367
- 'TOGGLE',
1368
- ),
1369
- 'chronological.header.showClock': def<boolean>(
1370
- 'chronological',
1371
- 'boolean',
1372
- true,
1373
- 'TOGGLE',
1374
- ),
1375
- 'chronological.header.showTimeOfDay': def<boolean>(
1376
- 'chronological',
1377
- 'boolean',
1378
- false,
1379
- 'TOGGLE',
1380
- ),
1381
- // Footer
1382
- 'chronological.footer.showMenuButton': def<boolean>(
1383
- 'chronological',
1384
- 'boolean',
1385
- true,
1386
- 'TOGGLE',
1387
- ),
1388
- 'chronological.footer.showViewSwitcherDay': def<boolean>(
1389
- 'chronological',
1390
- 'boolean',
1391
- true,
1392
- 'TOGGLE',
1393
- ),
1394
- 'chronological.footer.showViewSwitcherWeek': def<boolean>(
1395
- 'chronological',
1396
- 'boolean',
1397
- true,
1398
- 'TOGGLE',
1399
- ),
1400
- 'chronological.footer.showViewSwitcherMonth': def<boolean>(
1401
- 'chronological',
1402
- 'boolean',
1403
- true,
1404
- 'TOGGLE',
1405
- ),
1406
- 'chronological.footer.showTimerButton': def<boolean>(
1407
- 'chronological',
1408
- 'boolean',
1409
- true,
1410
- 'TOGGLE',
1411
- ),
1412
- 'chronological.footer.showNewEventButton': def<boolean>(
1413
- 'chronological',
1414
- 'boolean',
1415
- true,
1416
- 'TOGGLE',
1417
- ),
1418
- 'chronological.footer.showNowButton': def<boolean>(
1419
- 'chronological',
1420
- 'boolean',
1421
- true,
1422
- 'TOGGLE',
1423
- ),
1424
- 'chronological.footer.showSettingsButton': def<boolean>(
1425
- 'chronological',
1426
- 'boolean',
1427
- true,
1428
- 'TOGGLE',
1429
- ),
1430
- // Timer features
1431
- 'chronological.timer.showNewCountdown': def<boolean>(
1432
- 'chronological',
1433
- 'boolean',
1434
- true,
1435
- 'TOGGLE',
1436
- ),
1437
- 'chronological.timer.showFromTemplate': def<boolean>(
1438
- 'chronological',
1439
- 'boolean',
1440
- true,
1441
- 'TOGGLE',
1442
- ),
1443
- 'chronological.timer.showAddTemplate': def<boolean>(
1444
- 'chronological',
1445
- 'boolean',
1446
- true,
1447
- 'TOGGLE',
1448
- ),
1449
- 'chronological.timer.showEditTemplate': def<boolean>(
1450
- 'chronological',
1451
- 'boolean',
1452
- true,
1453
- 'TOGGLE',
1454
- ),
1455
- 'chronological.timer.showDeleteTemplate': def<boolean>(
1456
- 'chronological',
1457
- 'boolean',
1458
- true,
1459
- 'TOGGLE',
1460
- ),
1461
- // Menu
1462
- 'chronological.menu.showSettingsButton': def<boolean>(
1463
- 'chronological',
1464
- 'boolean',
1465
- true,
1466
- 'TOGGLE',
1467
- ),
1468
- // Quick settings
1469
- 'chronological.quickSettings.showTimerVolume': def<boolean>(
1470
- 'chronological',
1471
- 'boolean',
1472
- true,
1473
- 'TOGGLE',
1474
- ),
1475
- 'chronological.quickSettings.showReminderVolume': def<boolean>(
1476
- 'chronological',
1477
- 'boolean',
1478
- true,
1479
- 'TOGGLE',
1480
- ),
1481
- 'chronological.quickSettings.showMediaVolume': def<boolean>(
1482
- 'chronological',
1483
- 'boolean',
1484
- true,
1485
- 'TOGGLE',
1486
- ),
1487
- 'chronological.quickSettings.showBrightness': def<boolean>(
1488
- 'chronological',
1489
- 'boolean',
1490
- true,
1491
- 'TOGGLE',
1492
- ),
1493
- 'chronological.quickSettings.showLockScreen': def<boolean>(
1494
- 'chronological',
1495
- 'boolean',
1496
- true,
1497
- 'TOGGLE',
1498
- ),
1499
- // Time-of-day periods
1500
- 'chronological.timeOfDay.morningStart': def<number>(
1501
- 'chronological',
1502
- 'number',
1503
- 6,
1504
- 'SLIDER',
1505
- { sliderConfig: TIME_OF_DAY_SLIDER },
1506
- ),
1507
- 'chronological.timeOfDay.forenoonStart': def<number>(
1508
- 'chronological',
1509
- 'number',
1510
- 9,
1511
- 'SLIDER',
1512
- { sliderConfig: TIME_OF_DAY_SLIDER },
1513
- ),
1514
- 'chronological.timeOfDay.afternoonStart': def<number>(
1515
- 'chronological',
1516
- 'number',
1517
- 12,
1518
- 'SLIDER',
1519
- { sliderConfig: TIME_OF_DAY_SLIDER },
1520
- ),
1521
- 'chronological.timeOfDay.eveningStart': def<number>(
1522
- 'chronological',
1523
- 'number',
1524
- 18,
1525
- 'SLIDER',
1526
- { sliderConfig: TIME_OF_DAY_SLIDER },
1527
- ),
1528
- 'chronological.timeOfDay.nightStart': def<number>(
1529
- 'chronological',
1530
- 'number',
1531
- 0,
1532
- 'SLIDER',
1533
- { sliderConfig: TIME_OF_DAY_SLIDER },
1534
- ),
1535
-
1536
- // ═══════════════════════════════════════════════════════════════════════
1537
- // Chronological event form
1538
- // ═══════════════════════════════════════════════════════════════════════
1539
- // Fixed fields
1540
- 'chronologicalEventForm.fixedField.allDay': def<boolean>(
1541
- 'chronologicalEventForm',
1542
- 'boolean',
1543
- true,
1544
- 'TOGGLE',
1545
- ),
1546
- 'chronologicalEventForm.fixedField.endTime': def<boolean>(
1547
- 'chronologicalEventForm',
1548
- 'boolean',
1549
- true,
1550
- 'TOGGLE',
1551
- ),
1552
- 'chronologicalEventForm.fixedField.alarm': def<boolean>(
1553
- 'chronologicalEventForm',
1554
- 'boolean',
1555
- true,
1556
- 'TOGGLE',
1557
- ),
1558
- 'chronologicalEventForm.fixedField.visibility': def<boolean>(
1559
- 'chronologicalEventForm',
1560
- 'boolean',
1561
- true,
1562
- 'TOGGLE',
1563
- ),
1564
- // Toggleable fields
1565
- 'chronologicalEventForm.field.acknowledge': def<boolean>(
1566
- 'chronologicalEventForm',
1567
- 'boolean',
1568
- true,
1569
- 'TOGGLE',
1570
- ),
1571
- 'chronologicalEventForm.field.description': def<boolean>(
1572
- 'chronologicalEventForm',
1573
- 'boolean',
1574
- true,
1575
- 'TOGGLE',
1576
- ),
1577
- 'chronologicalEventForm.field.recurrence': def<boolean>(
1578
- 'chronologicalEventForm',
1579
- 'boolean',
1580
- true,
1581
- 'TOGGLE',
1582
- ),
1583
- 'chronologicalEventForm.field.checklist': def<boolean>(
1584
- 'chronologicalEventForm',
1585
- 'boolean',
1586
- true,
1587
- 'TOGGLE',
1588
- ),
1589
- 'chronologicalEventForm.field.extraImages': def<boolean>(
1590
- 'chronologicalEventForm',
1591
- 'boolean',
1592
- true,
1593
- 'TOGGLE',
1594
- ),
1595
- 'chronologicalEventForm.field.reminders': def<boolean>(
1596
- 'chronologicalEventForm',
1597
- 'boolean',
1598
- true,
1599
- 'TOGGLE',
1600
- ),
1601
- 'chronologicalEventForm.field.audioClips': def<boolean>(
1602
- 'chronologicalEventForm',
1603
- 'boolean',
1604
- true,
1605
- 'TOGGLE',
1606
- ),
1607
- // Suggest end time
1608
- 'chronologicalEventForm.suggestEndTime.enabled': def<boolean>(
1609
- 'chronologicalEventForm',
1610
- 'boolean',
1611
- false,
1612
- 'TOGGLE',
1613
- ),
1614
- 'chronologicalEventForm.suggestEndTime.value': def<number>(
1615
- 'chronologicalEventForm',
1616
- 'number',
1617
- 30,
1618
- 'SLIDER',
1619
- { sliderConfig: { min: 5, max: 480, step: 5 } },
1620
- ),
1621
- 'chronologicalEventForm.suggestEndTime.unit': def<SuggestEndTimeUnit>(
1622
- 'chronologicalEventForm',
1623
- 'string',
1624
- 'minutes',
1625
- 'SELECT',
1626
- {
1627
- options: [
1628
- {
1629
- value: 'minutes',
1630
- labelKey: 'Settings.Option.SuggestEndTimeUnit.Minutes',
1631
- },
1632
- {
1633
- value: 'hours',
1634
- labelKey: 'Settings.Option.SuggestEndTimeUnit.Hours',
1635
- },
1636
- ],
1637
- },
1638
- ),
1639
- // Default visibility
1640
- 'chronologicalEventForm.defaultVisibility': def<EventVisibility>(
1641
- 'chronologicalEventForm',
1642
- 'string',
1643
- 'Private',
1644
- 'SELECT',
1645
- {
1646
- options: [
1647
- { value: 'Public', labelKey: 'Settings.Option.Visibility.Public' },
1648
- { value: 'Private', labelKey: 'Settings.Option.Visibility.Private' },
1649
- { value: 'Custom', labelKey: 'Settings.Option.Visibility.Custom' },
1650
- ],
1651
- },
1652
- ),
1653
- // Reminder presets
1654
- 'chronologicalEventForm.reminderPresets.timed': def<number[]>(
1655
- 'chronologicalEventForm',
1656
- 'json',
1657
- [5, 15, 30, 60, 120, 1440],
1658
- 'CUSTOM_REMINDER_PRESETS',
1659
- ),
1660
- 'chronologicalEventForm.reminderPresets.allDay': def<AllDayPreset[]>(
1661
- 'chronologicalEventForm',
1662
- 'json',
1663
- [
1664
- { daysBefore: 0, time: '09:00' },
1665
- { daysBefore: 1, time: '18:00' },
1666
- { daysBefore: 2, time: '09:00' },
1667
- ],
1668
- 'CUSTOM_REMINDER_PRESETS',
1669
- ),
1670
- } as const satisfies Record<string, SettingDef>;
1671
- }
1672
-
1673
- // ---------------------------------------------------------------------------
1674
- // Derived types (based on entry shape — all configs produce the same keys)
1675
- // ---------------------------------------------------------------------------
1676
-
1677
- type SettingsEntries = ReturnType<typeof buildEntries>;
1678
-
1679
- /** Union type of every valid setting key */
1680
- export type SettingKey = keyof SettingsEntries;
1681
-
1682
- /** Type-safe value type for a given setting key */
1683
- export type SettingValue<K extends SettingKey> = SettingsEntries[K]['default'];
1684
-
1685
- /** Complete settings map — all keys resolved to their value types */
1686
- export type SettingsMap = {
1687
- [K in SettingKey]: SettingValue<K>;
1688
- };
1689
-
1690
- // ---------------------------------------------------------------------------
1691
- // Registry class
1692
- // ---------------------------------------------------------------------------
1693
-
1694
- export class SettingsRegistry {
1695
- /** The raw setting definitions (category, type, default, sync) */
1696
- readonly entries: SettingsEntries;
1697
-
1698
- /** All setting keys as an array */
1699
- readonly keys: SettingKey[];
1700
-
1701
- constructor(config: Partial<RegistryConfig> = {}) {
1702
- const full: RegistryConfig = { ...DEFAULT_REGISTRY_CONFIG, ...config };
1703
- this.entries = buildEntries(full);
1704
- this.keys = Object.keys(this.entries) as SettingKey[];
1705
- }
1706
-
1707
- /** Get the default value for a setting */
1708
- getDefault<K extends SettingKey>(key: K): SettingValue<K> {
1709
- return this.entries[key].default as SettingValue<K>;
1710
- }
1711
-
1712
- /** Get a complete map of all default values */
1713
- getAllDefaults(): SettingsMap {
1714
- const defaults = {} as Record<string, unknown>;
1715
- for (const key of this.keys) {
1716
- defaults[key] = this.entries[key].default;
1717
- }
1718
- return defaults as SettingsMap;
1719
- }
1720
-
1721
- /** Get the data type for a setting key */
1722
- getDataType(key: SettingKey): SettingsDataType {
1723
- return this.entries[key].type;
1724
- }
1725
-
1726
- /** Check whether a setting should be synced */
1727
- isSynced(key: SettingKey): boolean {
1728
- return this.entries[key].sync;
1729
- }
1730
-
1731
- /** Get all setting keys for a category */
1732
- getByCategory(category: SettingsCategory): SettingKey[] {
1733
- return this.keys.filter((key) => this.entries[key].category === category);
1734
- }
1735
-
1736
- /** Serialize a setting value to a string for DB storage */
1737
- serialize(key: SettingKey, value: unknown): string {
1738
- const dataType = this.getDataType(key);
1739
- switch (dataType) {
1740
- case 'string':
1741
- return String(value ?? '');
1742
- case 'number':
1743
- return String(value ?? 0);
1744
- case 'boolean':
1745
- if (typeof value === 'string') {
1746
- return value === 'true' ? 'true' : 'false';
1747
- }
1748
- return value ? 'true' : 'false';
1749
- case 'json':
1750
- // If already a serialized JSON string, return as-is to avoid
1751
- // double-wrapping (e.g. JSON.stringify("[]") → "\"[]\"")
1752
- if (typeof value === 'string') {
1753
- try {
1754
- JSON.parse(value);
1755
- return value;
1756
- } catch {
1757
- return JSON.stringify(value);
1758
- }
1759
- }
1760
- return JSON.stringify(value);
1761
- }
1762
- }
1763
-
1764
- /** Deserialize a DB string back to a typed setting value */
1765
- deserialize<K extends SettingKey>(
1766
- key: K,
1767
- raw: string | null,
1768
- ): SettingValue<K> {
1769
- if (raw === null || raw === undefined) {
1770
- return this.getDefault(key);
1771
- }
1772
-
1773
- const dataType = this.getDataType(key);
1774
- switch (dataType) {
1775
- case 'string':
1776
- return raw as SettingValue<K>;
1777
- case 'number':
1778
- return Number(raw) as SettingValue<K>;
1779
- case 'boolean':
1780
- return (raw === 'true') as SettingValue<K>;
1781
- case 'json':
1782
- try {
1783
- let result: unknown = JSON.parse(raw);
1784
- // Unwrap multiply-escaped JSON strings caused by repeated
1785
- // double-serialization (each push/pull cycle added a layer)
1786
- while (typeof result === 'string') {
1787
- try {
1788
- result = JSON.parse(result);
1789
- } catch {
1790
- break;
1791
- }
1792
- }
1793
- return result as SettingValue<K>;
1794
- } catch {
1795
- return this.getDefault(key);
1796
- }
1797
- }
1798
- }
1799
-
1800
- /**
1801
- * Get the category for a setting key.
1802
- * Returns undefined if the key is not recognized.
1803
- */
1804
- getCategory(key: string): SettingsCategory | undefined {
1805
- if (key in this.entries) {
1806
- return this.entries[key as SettingKey].category;
1807
- }
1808
- // Fallback: extract category from dot-prefix
1809
- const dotIndex = key.indexOf('.');
1810
- if (dotIndex > 0) {
1811
- const prefix = key.substring(0, dotIndex);
1812
- if (SETTINGS_CATEGORIES.includes(prefix as SettingsCategory)) {
1813
- return prefix as SettingsCategory;
1814
- }
1815
- }
1816
- return undefined;
1817
- }
1818
-
1819
- /**
1820
- * Get a human-readable label for a setting key.
1821
- * Strips the category prefix and formats the remaining path.
1822
- * Example: 'lockScreen.inactivityTimeoutMinutes' → 'Inactivity Timeout Minutes'
1823
- */
1824
- getSettingLabel(key: string): string {
1825
- const dotIndex = key.indexOf('.');
1826
- const name = dotIndex > 0 ? key.substring(dotIndex + 1) : key;
1827
- // Convert camelCase / dot-separated path to Title Case words
1828
- return name
1829
- .replace(/\./g, ' › ')
1830
- .replace(/([a-z])([A-Z])/g, '$1 $2')
1831
- .replace(/^./, (c) => c.toUpperCase());
1832
- }
1833
- }
1834
-
1835
- // ---------------------------------------------------------------------------
1836
- // Snapshot parsing — parse a JSON settings snapshot into categorized groups
1837
- // ---------------------------------------------------------------------------
1838
-
1839
- export type ParsedSettingEntry = {
1840
- /** The full setting key (e.g. 'lockScreen.inactivityTimeoutMinutes') */
1841
- key: string;
1842
- /** The setting name without category prefix (e.g. 'inactivityTimeoutMinutes') */
1843
- name: string;
1844
- /** Human-readable label (e.g. 'Inactivity Timeout Minutes') */
1845
- label: string;
1846
- /** i18n message key for the label (from SETTINGS_LABELS) */
1847
- labelKey?: string;
1848
- /** i18n message key for a description (from SETTINGS_LABELS) */
1849
- descriptionKey?: string;
1850
- /** The setting value */
1851
- value: unknown;
1852
- /** UI component type for rendering this setting */
1853
- uiType: SettingUiType;
1854
- /** Available options for SELECT-type settings */
1855
- options?: readonly SettingOption[];
1856
- /** Slider configuration for SLIDER-type settings */
1857
- sliderConfig?: SliderConfig;
1858
- /** Only show this setting for enrolled/kiosk devices (undefined = always) */
1859
- appMode?: 'ENROLLED';
1860
- /** Only show this setting for a specific calendar type (undefined = always) */
1861
- calendarType?: CalendarType;
1862
- };
1863
-
1864
- export type ParsedSettingsGroup = {
1865
- /** The category key */
1866
- category: SettingsCategory;
1867
- /** Human-readable category label */
1868
- label: string;
1869
- /** Settings in this category */
1870
- settings: ParsedSettingEntry[];
1871
- };
1872
-
1873
- /**
1874
- * Parse a settings JSON snapshot into categorized groups, optionally filtered
1875
- * by calendar type. This is the single function both web and mobile should use
1876
- * to display a settings snapshot.
1877
- *
1878
- * @param json The raw JSON string from the settings snapshot
1879
- * @param calendarType Optional calendar type filter — hides irrelevant categories
1880
- * @param registry Optional registry instance (uses defaultRegistry if omitted)
1881
- */
1882
- export function parseSettingsSnapshot(
1883
- json: string | Record<string, unknown>,
1884
- calendarType?: CalendarType,
1885
- registry: SettingsRegistry = defaultRegistry,
1886
- ): ParsedSettingsGroup[] {
1887
- let parsed: Record<string, unknown>;
1888
- if (typeof json === 'string') {
1889
- try {
1890
- parsed = JSON.parse(json);
1891
- } catch {
1892
- return [];
1893
- }
1894
- } else {
1895
- parsed = json;
1896
- }
1897
-
1898
- const groups = new Map<SettingsCategory, ParsedSettingEntry[]>();
1899
-
1900
- for (const [key, rawValue] of Object.entries(parsed)) {
1901
- const category = registry.getCategory(key);
1902
- if (!category) {
1903
- continue;
1904
- }
1905
-
1906
- // Deserialize string values to their native types using the registry
1907
- let value: unknown = rawValue;
1908
- if (typeof rawValue === 'string' && key in registry.entries) {
1909
- value = registry.deserialize(key as SettingKey, rawValue);
1910
- }
1911
-
1912
- const dotIndex = key.indexOf('.');
1913
- const name = dotIndex > 0 ? key.substring(dotIndex + 1) : key;
1914
-
1915
- if (!groups.has(category)) {
1916
- groups.set(category, []);
1917
- }
1918
-
1919
- const labelDef = SETTINGS_LABELS[key];
1920
- const entryDef = key in registry.entries
1921
- ? registry.entries[key as SettingKey]
1922
- : undefined;
1923
- groups.get(category)!.push({
1924
- key,
1925
- name,
1926
- label: registry.getSettingLabel(key),
1927
- labelKey: labelDef?.labelKey,
1928
- descriptionKey: labelDef?.descriptionKey,
1929
- value,
1930
- uiType: entryDef?.uiType ?? 'TEXT_INPUT',
1931
- ...(entryDef?.options && { options: entryDef.options as readonly SettingOption[] }),
1932
- ...(entryDef?.sliderConfig && { sliderConfig: entryDef.sliderConfig }),
1933
- ...(entryDef?.appMode && { appMode: entryDef.appMode }),
1934
- ...(entryDef?.calendarType && { calendarType: entryDef.calendarType }),
1935
- });
1936
- }
1937
-
1938
- // Determine which categories to show
1939
- const allowedCategories = calendarType
1940
- ? getCategoriesForCalendarType(calendarType)
1941
- : [...SETTINGS_CATEGORIES];
1942
-
1943
- return allowedCategories
1944
- .filter((cat) => groups.has(cat))
1945
- .map((category) => ({
1946
- category,
1947
- label: CATEGORY_LABELS[category],
1948
- settings: groups.get(category)!,
1949
- }));
1950
- }
1951
-
1952
- /**
1953
- * Format a setting value for display.
1954
- * Handles booleans, numbers, strings, arrays, objects, and null/undefined.
1955
- */
1956
- export function formatSettingValue(value: unknown): string {
1957
- if (value === null || value === undefined) {
1958
- return '—';
1959
- }
1960
- if (typeof value === 'boolean') {
1961
- return value ? 'true' : 'false';
1962
- }
1963
- if (typeof value === 'number') {
1964
- return String(value);
1965
- }
1966
- if (typeof value === 'string') {
1967
- return value || '""';
1968
- }
1969
- if (Array.isArray(value)) {
1970
- return `[${value.length} items]`;
1971
- }
1972
- if (typeof value === 'object') {
1973
- return JSON.stringify(value);
1974
- }
1975
- return String(value);
1976
- }
1977
-
1978
- /**
1979
- * Serialize a settings object (with mixed native types) into a
1980
- * string-values-only snapshot suitable for pushing to a mobile device.
1981
- *
1982
- * Uses the registry's `serialize()` method for known keys so that
1983
- * booleans become "true"/"false", numbers become digit strings, etc.
1984
- * Unknown keys are converted with `String(value)`.
1985
- */
1986
- export function serializeSettingsSnapshot(
1987
- settings: Record<string, unknown>,
1988
- registry: SettingsRegistry,
1989
- ): Record<string, string> {
1990
- const result: Record<string, string> = {};
1991
- for (const [key, value] of Object.entries(settings)) {
1992
- if (key in registry.entries) {
1993
- result[key] = registry.serialize(key as SettingKey, value);
1994
- } else {
1995
- result[key] = String(value ?? '');
1996
- }
1997
- }
1998
- return result;
1999
- }
2000
-
2001
- /**
2002
- * Deserialize a settings snapshot (string values from the server/DB) into
2003
- * native-typed values using the registry.
2004
- *
2005
- * This ensures local state always contains native types (boolean, number, etc.)
2006
- * so that subsequent `serializeSettingsSnapshot` calls produce correct results.
2007
- */
2008
- export function deserializeSettingsSnapshot(
2009
- snapshot: Record<string, unknown>,
2010
- registry: SettingsRegistry = defaultRegistry,
2011
- ): Record<string, unknown> {
2012
- const result: Record<string, unknown> = {};
2013
- for (const [key, value] of Object.entries(snapshot)) {
2014
- if (typeof value === 'string' && key in registry.entries) {
2015
- result[key] = registry.deserialize(key as SettingKey, value);
2016
- } else {
2017
- result[key] = value;
2018
- }
2019
- }
2020
- return result;
2021
- }
2022
-
2023
- // ---------------------------------------------------------------------------
2024
- // Default registry instance (non-enrolled, system theme, nb locale)
2025
- // ---------------------------------------------------------------------------
2026
-
2027
- export const defaultRegistry = new SettingsRegistry();
2028
-
2029
- // ---------------------------------------------------------------------------
2030
- // Factory function — returns raw entry map for local type derivation.
2031
- // Consumers that need precise generic inference (e.g. useQuery<K>) can use
2032
- // this to anchor types on a local const variable.
2033
- // ---------------------------------------------------------------------------
2034
-
2035
- export function createSettingsRegistry(config: Partial<RegistryConfig> = {}) {
2036
- return buildEntries({ ...DEFAULT_REGISTRY_CONFIG, ...config });
2037
- }
1
+ /**
2
+ * Settings Registry — single source of truth for ALL FocusPlanner app settings.
3
+ *
4
+ * This shared package defines every setting key, its category, data type,
5
+ * default value, and whether it should be synced to the server.
6
+ *
7
+ * Used by:
8
+ * - The mobile app (local SQLite settings table, React Query hooks)
9
+ * - The API (validate & serialize settings profiles)
10
+ * - The web portal (render settings viewer/editor)
11
+ *
12
+ * App-specific defaults (theme, locale, enrolled mode) are parameterized
13
+ * via `createRegistry(config)`.
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Setting types shared across mobile, API, and web
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export type ThemeSetting = 'light' | 'dark' | 'system';
21
+ export type AlarmSound =
22
+ | 'none'
23
+ | 'alarm1'
24
+ | 'alarm2'
25
+ | 'alarm3'
26
+ | 'alarm4'
27
+ | 'alarm5'
28
+ | 'alarm6'
29
+ | 'alarm7'
30
+ | 'alarm8'
31
+ | 'alarm9';
32
+ export type LocaleCode = 'en' | 'nb';
33
+
34
+ export type CalendarType = 'chronological' | 'time-based';
35
+ export type ClockType = 'digital' | 'analog';
36
+ export type TimePickerMode = 'dials' | 'keypad';
37
+ export type AlarmTimeout = 5 | 30 | 60 | 120 | 180 | 300 | 600;
38
+ export const ALARM_TIMEOUTS: AlarmTimeout[] = [5, 30, 60, 120, 180, 300, 600];
39
+ export type CalendarViewMode =
40
+ | 'day'
41
+ | '3-days'
42
+ | '5-days'
43
+ | '7-days'
44
+ | 'week'
45
+ | 'month'
46
+ | 'overview';
47
+ export type CalendarDayViewCellZoom = 15 | 30 | 60;
48
+ export type InactivityTimeoutMinutes = 1 | 5 | 10 | 15 | 30 | 45;
49
+ export type LockScreenClockDisplay = 'none' | 'digital' | 'analog';
50
+ export type LockScreenImageMode = 'none' | 'background' | 'photoFrame';
51
+ export type PhotoFrameIntervalSeconds = 30 | 60 | 120 | 300;
52
+ export type EventVisibility = 'Public' | 'Private' | 'Custom';
53
+ export type SuggestEndTimeUnit = 'minutes' | 'hours';
54
+ export type ChronologicalDayViewDisplayMode = 'list' | 'timeline';
55
+
56
+ export type WeatherLocation = {
57
+ address: string;
58
+ name?: string;
59
+ latitude: number;
60
+ longitude: number;
61
+ placeId?: string;
62
+ };
63
+
64
+ export type LockScreenImage = {
65
+ uri: string;
66
+ filePath: string;
67
+ fileName?: string;
68
+ fileSize?: number;
69
+ width?: number;
70
+ height?: number;
71
+ order: number;
72
+ };
73
+
74
+ export const LOCK_SCREEN_MAX_IMAGES = 10;
75
+
76
+ export type AllDayPreset = {
77
+ daysBefore: number;
78
+ time: string;
79
+ };
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // UI type discriminator — determines which component renders each setting
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export type SettingUiType =
86
+ | 'TOGGLE'
87
+ | 'SELECT'
88
+ | 'VOLUME_SLIDER'
89
+ | 'SLIDER'
90
+ | 'NUMBER_INPUT'
91
+ | 'TEXT_INPUT'
92
+ | 'PIN_INPUT'
93
+ | 'CUSTOM_THEME_PICKER'
94
+ | 'CUSTOM_LANGUAGE_PICKER'
95
+ | 'CUSTOM_CALENDAR_TYPE'
96
+ | 'CUSTOM_CLOCK_TYPE'
97
+ | 'CUSTOM_WEATHER_LOCATION'
98
+ | 'CUSTOM_IMAGE'
99
+ | 'CUSTOM_IMAGE_ARRAY'
100
+ | 'CUSTOM_CALENDAR_IDS'
101
+ | 'CUSTOM_REMINDER_PRESETS'
102
+ | 'CUSTOM_EVENT_FORM'
103
+ | 'CUSTOM_CHRONOLOGICAL_EVENT_FORM'
104
+ | 'CUSTOM_DISPLAY_DENSITY'
105
+ | 'CUSTOM_BRIGHTNESS'
106
+ | 'CUSTOM_SPLIT_VIEW_CONFIG'
107
+ | 'CUSTOM_EVENT_CATEGORIES'
108
+ | 'HIDDEN';
109
+
110
+ export type SettingOption<T = unknown> = {
111
+ /** The option value */
112
+ value: T;
113
+ /** i18n message key for the option label */
114
+ labelKey: string;
115
+ /** Optional values to pass to the i18n message formatter */
116
+ labelValues?: Record<string, unknown>;
117
+ };
118
+
119
+ export type SliderConfig = {
120
+ min: number;
121
+ max: number;
122
+ step: number;
123
+ };
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Visibility & disabled conditions — declarative rules for conditional display
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export type VisibilityCondition =
130
+ | { dependsOn: string; equals: unknown }
131
+ | { dependsOn: string; notEquals: unknown }
132
+ | { dependsOn: string; isTruthy: true };
133
+
134
+ /** Single condition or array of conditions (all must be true = AND). */
135
+ export type VisibilityRule = VisibilityCondition | VisibilityCondition[];
136
+
137
+ /** Alias — same shape, controls interactivity instead of visibility. */
138
+ export type DisabledRule = VisibilityCondition | VisibilityCondition[];
139
+
140
+ /**
141
+ * Evaluate a visibility/disabled rule against current setting values.
142
+ * Returns `true` if the setting should be visible (or disabled, depending on usage).
143
+ * `undefined` rule = always true (no restriction).
144
+ */
145
+ export function evaluateVisibility(
146
+ rule: VisibilityRule | undefined,
147
+ getValue: (key: string) => unknown,
148
+ ): boolean {
149
+ if (!rule) return true;
150
+ const conditions: VisibilityCondition[] = Array.isArray(rule) ? rule : [rule];
151
+ return conditions.every((c) => {
152
+ const value = getValue(c.dependsOn);
153
+ if ('equals' in c) return value === c.equals;
154
+ if ('notEquals' in c) return value !== c.notEquals;
155
+ if ('isTruthy' in c) return !!value;
156
+ return true;
157
+ });
158
+ }
159
+
160
+ /** Convenience: returns true when the setting should be disabled. */
161
+ export function evaluateDisabled(
162
+ rule: DisabledRule | undefined,
163
+ getValue: (key: string) => unknown,
164
+ ): boolean {
165
+ if (!rule) return false;
166
+ return evaluateVisibility(rule, getValue);
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Setting categories — used for UI grouping and profile organization
171
+ // ---------------------------------------------------------------------------
172
+
173
+ export const SETTINGS_CATEGORIES = [
174
+ 'appearance',
175
+ 'calendarView',
176
+ 'calendars',
177
+ 'sound',
178
+ 'timer',
179
+ 'media',
180
+ 'lockScreen',
181
+ 'touch',
182
+ 'device',
183
+ 'language',
184
+ 'notification',
185
+ 'chronological',
186
+ 'eventForm',
187
+ 'chronologicalEventForm',
188
+ ] as const;
189
+
190
+ export type SettingsCategory = (typeof SETTINGS_CATEGORIES)[number];
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Category display labels — human-readable names for UI rendering
194
+ // ---------------------------------------------------------------------------
195
+
196
+ export const CATEGORY_LABELS: Record<SettingsCategory, string> = {
197
+ appearance: 'Appearance',
198
+ calendarView: 'Calendar View',
199
+ calendars: 'Calendars',
200
+ sound: 'Sound & Alerts',
201
+ timer: 'Timer',
202
+ media: 'Media',
203
+ lockScreen: 'Lock Screen',
204
+ touch: 'Touch & Gestures',
205
+ device: 'Device',
206
+ language: 'Language',
207
+ notification: 'Notifications',
208
+ chronological: 'Chronological',
209
+ eventForm: 'Event Form',
210
+ chronologicalEventForm: 'Event Form (Chronological)',
211
+ };
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Category icons — lucide icon names matching the mobile app
215
+ // ---------------------------------------------------------------------------
216
+
217
+ export const CATEGORY_ICONS: Record<SettingsCategory, string> = {
218
+ appearance: 'Palette',
219
+ calendarView: 'Eye',
220
+ calendars: 'Calendar',
221
+ sound: 'Volume2',
222
+ timer: 'Timer',
223
+ media: 'Images',
224
+ lockScreen: 'Lock',
225
+ touch: 'Hand',
226
+ device: 'Smartphone',
227
+ language: 'Globe',
228
+ notification: 'Bell',
229
+ chronological: 'List',
230
+ eventForm: 'CalendarPlus',
231
+ chronologicalEventForm: 'CalendarPlus',
232
+ };
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Calendar-type filtering — which categories are exclusive to a calendar type
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /** Categories only shown for chronological calendar type */
239
+ export const CHRONOLOGICAL_ONLY_CATEGORIES: ReadonlySet<SettingsCategory> =
240
+ new Set<SettingsCategory>(['chronological', 'chronologicalEventForm']);
241
+
242
+ /** Categories only shown for time-based calendar type */
243
+ export const TIME_BASED_ONLY_CATEGORIES: ReadonlySet<SettingsCategory> =
244
+ new Set<SettingsCategory>(['eventForm']);
245
+
246
+ /**
247
+ * Filter categories based on the active calendar type.
248
+ * Categories not exclusive to either type are always included.
249
+ */
250
+ export function getCategoriesForCalendarType(
251
+ calendarType: CalendarType,
252
+ ): SettingsCategory[] {
253
+ return SETTINGS_CATEGORIES.filter((cat) => {
254
+ if (
255
+ calendarType === 'time-based' &&
256
+ CHRONOLOGICAL_ONLY_CATEGORIES.has(cat)
257
+ ) {
258
+ return false;
259
+ }
260
+ if (
261
+ calendarType === 'chronological' &&
262
+ TIME_BASED_ONLY_CATEGORIES.has(cat)
263
+ ) {
264
+ return false;
265
+ }
266
+ return true;
267
+ });
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Settings sections — top-level groupings (e.g. "Kalender", "Enhet")
272
+ // ---------------------------------------------------------------------------
273
+
274
+ export const SETTINGS_SECTION_IDS = ['calendar', 'unit'] as const;
275
+ export type SettingsSectionId = (typeof SETTINGS_SECTION_IDS)[number];
276
+
277
+ export type SettingsSectionDef = {
278
+ id: SettingsSectionId;
279
+ labelKey: string;
280
+ };
281
+
282
+ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
283
+ { id: 'calendar', labelKey: 'Calendar.LabelCalendar' },
284
+ { id: 'unit', labelKey: 'Settings.Unit' },
285
+ ];
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Settings menu items — ordered list of all items in the settings menu
289
+ // ---------------------------------------------------------------------------
290
+
291
+ export const SETTINGS_MENU_ITEM_IDS = [
292
+ // Calendar section
293
+ 'calendarView',
294
+ 'calendars',
295
+ 'eventCategories',
296
+ 'notification',
297
+ 'timer',
298
+ 'guests',
299
+ 'eventForm',
300
+ 'chronologicalEventForm',
301
+ 'weather',
302
+ 'gallery',
303
+ 'audioClips',
304
+ 'gestures',
305
+ 'calendarType',
306
+ 'sharing',
307
+ // Unit section
308
+ 'screen',
309
+ 'volume',
310
+ 'lockScreen',
311
+ 'language',
312
+ 'wifi',
313
+ 'devices',
314
+ 'profiles',
315
+ 'tts',
316
+ 'updates',
317
+ 'about',
318
+ 'help',
319
+ 'dev',
320
+ // Account (rendered separately)
321
+ 'account',
322
+ 'activityLog',
323
+ ] as const;
324
+
325
+ export type SettingsMenuItemId = (typeof SETTINGS_MENU_ITEM_IDS)[number];
326
+
327
+ export type SettingsMenuItemDef = {
328
+ id: SettingsMenuItemId;
329
+ labelKey: string;
330
+ icon: string;
331
+ section: SettingsSectionId | 'account';
332
+ /** Screen/page identifiers rendered for this menu item */
333
+ screens: string[];
334
+ /**
335
+ * Setting key matchers for remote editing (web portal / profile editor).
336
+ * A setting is included if its key equals a matcher exactly, or starts
337
+ * with a matcher that ends with '.' (prefix match).
338
+ * Items without keys are navigation-only (e.g. gallery, guests).
339
+ */
340
+ keys?: string[];
341
+ /** Only show for this calendar type (undefined = always) */
342
+ calendarType?: CalendarType;
343
+ /** Only show for this app mode (undefined = always) */
344
+ appMode?: 'ENROLLED';
345
+ /** If true, don't wrap content in a scroll container */
346
+ noScrollView?: boolean;
347
+ };
348
+
349
+ /**
350
+ * Ordered list of all settings menu items. The array order defines the
351
+ * display order within each section. Items without registry setting keys
352
+ * are navigation-only (e.g. gallery, guests).
353
+ */
354
+ export const SETTINGS_MENU_ITEMS: readonly SettingsMenuItemDef[] = [
355
+ // -- Calendar section --
356
+ {
357
+ id: 'calendarView',
358
+ labelKey: 'Settings.Calendar',
359
+ icon: 'Eye',
360
+ section: 'calendar',
361
+ screens: [
362
+ 'calendarDayView',
363
+ 'calendarWeekView',
364
+ 'chronologicalHeader',
365
+ 'chronologicalFooter',
366
+ 'chronologicalMenu',
367
+ 'dayColors',
368
+ 'dateTime',
369
+ ],
370
+ keys: [
371
+ 'calendarView.showCalendarNames',
372
+ 'calendarView.splitView',
373
+ 'calendarView.dayViewZoom',
374
+ 'calendarView.weekViewZoom',
375
+ 'calendarView.calendarColumns',
376
+ 'appearance.enableDayColors',
377
+ 'chronological.dayView.displayMode',
378
+ 'chronological.header.',
379
+ 'chronological.footer.',
380
+ 'chronological.menu.',
381
+ 'chronological.quickSettings.',
382
+ 'chronological.timeOfDay.',
383
+ ],
384
+ },
385
+ {
386
+ id: 'calendars',
387
+ labelKey: 'Common.Calendars',
388
+ icon: 'Calendar',
389
+ section: 'calendar',
390
+ screens: ['calendars'],
391
+ },
392
+ {
393
+ id: 'eventCategories',
394
+ labelKey: 'EventCategory.SettingsTitle',
395
+ icon: 'Tag',
396
+ section: 'calendar',
397
+ screens: ['eventCategories'],
398
+ calendarType: 'chronological',
399
+ },
400
+ {
401
+ id: 'notification',
402
+ labelKey: 'Settings.Notification',
403
+ icon: 'Bell',
404
+ section: 'calendar',
405
+ screens: ['notification'],
406
+ keys: [
407
+ 'sound.reminderAlarmSound',
408
+ 'sound.startAlarmSound',
409
+ 'sound.endAlarmSound',
410
+ 'sound.reminderAlarmTimeout',
411
+ 'sound.reminderVolume',
412
+ ],
413
+ },
414
+ {
415
+ id: 'timer',
416
+ labelKey: 'Settings.TimerTitle',
417
+ icon: 'Timer',
418
+ section: 'calendar',
419
+ screens: ['timerFeatures', 'timer'],
420
+ appMode: 'ENROLLED',
421
+ keys: [
422
+ 'chronological.timer.',
423
+ 'timer.',
424
+ 'sound.timerAlarmSound',
425
+ 'sound.timerAlarmTimeout',
426
+ 'sound.timerVolume',
427
+ ],
428
+ },
429
+ {
430
+ id: 'guests',
431
+ labelKey: 'Settings.GuestUsers',
432
+ icon: 'Users',
433
+ section: 'calendar',
434
+ screens: ['guests'],
435
+ },
436
+ {
437
+ id: 'eventForm',
438
+ labelKey: 'SettingsEventForm.Title',
439
+ icon: 'ClipboardList',
440
+ section: 'calendar',
441
+ screens: ['eventForm'],
442
+ calendarType: 'time-based',
443
+ keys: ['eventForm.'],
444
+ },
445
+ {
446
+ id: 'chronologicalEventForm',
447
+ labelKey: 'SettingsChronologicalEventForm.Title',
448
+ icon: 'Plus',
449
+ section: 'calendar',
450
+ screens: ['chronologicalEventForm'],
451
+ calendarType: 'chronological',
452
+ keys: ['chronologicalEventForm.'],
453
+ },
454
+ {
455
+ id: 'weather',
456
+ labelKey: 'Settings.WeatherTitle',
457
+ icon: 'CloudSun',
458
+ section: 'calendar',
459
+ screens: ['weather'],
460
+ calendarType: 'time-based',
461
+ keys: [
462
+ 'calendarView.showWeatherOnTimeline',
463
+ 'calendarView.weatherLocation',
464
+ 'calendarView.showWeatherOnEvents',
465
+ ],
466
+ },
467
+ {
468
+ id: 'gallery',
469
+ labelKey: 'Gallery.Title',
470
+ icon: 'Images',
471
+ section: 'calendar',
472
+ screens: ['gallery'],
473
+ noScrollView: true,
474
+ },
475
+ {
476
+ id: 'audioClips',
477
+ labelKey: 'AudioClips.Title',
478
+ icon: 'AudioLines',
479
+ section: 'calendar',
480
+ screens: ['audioClips'],
481
+ noScrollView: true,
482
+ },
483
+ {
484
+ id: 'gestures',
485
+ labelKey: 'Settings.FunctionsTitle',
486
+ icon: 'Hand',
487
+ section: 'calendar',
488
+ screens: ['gestures'],
489
+ keys: ['touch.'],
490
+ },
491
+ {
492
+ id: 'calendarType',
493
+ labelKey: 'Settings.Mode',
494
+ icon: 'SlidersHorizontal',
495
+ section: 'calendar',
496
+ screens: ['calendarType'],
497
+ keys: ['calendarView.type'],
498
+ },
499
+ {
500
+ id: 'sharing',
501
+ labelKey: 'Settings.Sharing',
502
+ icon: 'Share2',
503
+ section: 'calendar',
504
+ screens: ['icalSubscriptions'],
505
+ },
506
+ // -- Unit section --
507
+ {
508
+ id: 'screen',
509
+ labelKey: 'Settings.Screen',
510
+ icon: 'LaptopMinimal',
511
+ section: 'unit',
512
+ screens: ['displayDensity', 'darkMode'],
513
+ keys: ['appearance.theme', 'appearance.clockType'],
514
+ },
515
+ {
516
+ id: 'volume',
517
+ labelKey: 'Common.Volume',
518
+ icon: 'Volume2',
519
+ section: 'unit',
520
+ screens: ['volume'],
521
+ appMode: 'ENROLLED',
522
+ keys: [
523
+ 'sound.reminderVolume',
524
+ 'sound.timerVolume',
525
+ 'sound.mediaVolume',
526
+ ],
527
+ },
528
+ {
529
+ id: 'lockScreen',
530
+ labelKey: 'Settings.LockScreen',
531
+ icon: 'Lock',
532
+ section: 'unit',
533
+ screens: ['inactivity', 'lockScreen'],
534
+ appMode: 'ENROLLED',
535
+ keys: ['lockScreen.'],
536
+ },
537
+ {
538
+ id: 'language',
539
+ labelKey: 'Settings.Language',
540
+ icon: 'Globe',
541
+ section: 'unit',
542
+ screens: ['language'],
543
+ keys: ['language.'],
544
+ },
545
+ {
546
+ id: 'wifi',
547
+ labelKey: 'Settings.WifiTitle',
548
+ icon: 'Wifi',
549
+ section: 'unit',
550
+ screens: ['wifi'],
551
+ appMode: 'ENROLLED',
552
+ },
553
+ {
554
+ id: 'devices',
555
+ labelKey: 'SettingsDevices.Title',
556
+ icon: 'Smartphone',
557
+ section: 'unit',
558
+ screens: ['devices'],
559
+ keys: [
560
+ 'device.',
561
+ 'calendarView.autoReturnToTodayEnabled',
562
+ 'calendarView.autoReturnToTodayTimeoutSeconds',
563
+ ],
564
+ },
565
+ {
566
+ id: 'tts',
567
+ labelKey: 'Settings.TtsTitle',
568
+ icon: 'Speech',
569
+ section: 'unit',
570
+ screens: ['tts'],
571
+ appMode: 'ENROLLED',
572
+ keys: [
573
+ 'sound.ttsEnabled',
574
+ 'sound.ttsRate',
575
+ ],
576
+ },
577
+ {
578
+ id: 'updates',
579
+ labelKey: 'Settings.UpdatesTitle',
580
+ icon: 'Download',
581
+ section: 'unit',
582
+ screens: ['updates'],
583
+ },
584
+ {
585
+ id: 'about',
586
+ labelKey: 'Settings.About',
587
+ icon: 'Info',
588
+ section: 'unit',
589
+ screens: ['restoreDefaults', 'recycleAppData', 'license', 'version'],
590
+ },
591
+ {
592
+ id: 'help',
593
+ labelKey: 'Settings.HelpTitle',
594
+ icon: 'CircleQuestionMark',
595
+ section: 'unit',
596
+ screens: ['help'],
597
+ },
598
+ {
599
+ id: 'dev',
600
+ labelKey: 'Settings.Dev',
601
+ icon: 'FolderCode',
602
+ section: 'unit',
603
+ screens: [
604
+ 'devDeviceInfo',
605
+ 'devComponents',
606
+ 'devStores',
607
+ 'devActions',
608
+ 'devAuth',
609
+ 'devNavigation',
610
+ 'devNetwork',
611
+ 'devAndroidSettings',
612
+ ],
613
+ },
614
+ // -- Account (rendered separately) --
615
+ {
616
+ id: 'account',
617
+ labelKey: 'Settings.Profile',
618
+ icon: 'User',
619
+ section: 'account',
620
+ screens: ['account', 'mfa'],
621
+ },
622
+ {
623
+ id: 'activityLog',
624
+ labelKey: 'SettingsActivityLog.Title',
625
+ icon: 'ScrollText',
626
+ section: 'account',
627
+ screens: ['activityLog'],
628
+ },
629
+ ];
630
+
631
+ /**
632
+ * Settings excluded from remote editing (web portal / profile management).
633
+ * These are device-local or internal-only settings.
634
+ */
635
+ export const EXCLUDED_DEVICE_SETTINGS: ReadonlySet<string> = new Set([
636
+ 'device.id',
637
+ 'device.devMenuEnabled',
638
+ 'device.authWarningDismissTtlDays',
639
+ 'notification.enabled',
640
+ 'notification.notifyAllCalendars',
641
+ 'notification.enabledCalendarIds',
642
+ 'notification.hasBeenPrompted',
643
+ ]);
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Per-setting i18n label / description keys
647
+ // ---------------------------------------------------------------------------
648
+
649
+ export type SettingLabelDef = {
650
+ /** i18n message key for the setting label */
651
+ labelKey: string;
652
+ /** Optional i18n message key for a description shown below the label */
653
+ descriptionKey?: string;
654
+ };
655
+
656
+ /**
657
+ * Maps each setting key to its i18n label and optional description key.
658
+ * These keys must exist in every consumer's i18n catalog.
659
+ *
660
+ * When a key is missing from this map, consumers should fall back to
661
+ * the auto-generated label from `SettingsRegistry.getSettingLabel()`.
662
+ */
663
+ export const SETTINGS_LABELS: Readonly<Record<string, SettingLabelDef>> = {
664
+ // ── Appearance ──────────────────────────────────────────────────────────
665
+ 'appearance.theme': { labelKey: 'Settings.SelectTheme' },
666
+ 'appearance.clockType': {
667
+ labelKey: 'Settings.ClockType',
668
+ descriptionKey: 'Settings.ClockTypeDescription',
669
+ },
670
+ 'appearance.enableDayColors': {
671
+ labelKey: 'Settings.EnableDayColors',
672
+ descriptionKey: 'Settings.EnableDayColorsDescription',
673
+ },
674
+
675
+ // ── Chronological day view display mode ──────────────────────────────────
676
+ 'chronological.dayView.displayMode': {
677
+ labelKey: 'Settings.DayViewDisplayMode',
678
+ },
679
+
680
+ // ── Calendar type ───────────────────────────────────────────────────────
681
+ 'calendarView.type': {
682
+ labelKey: 'Settings.CalendarType',
683
+ descriptionKey: 'Settings.ModeDescription',
684
+ },
685
+ 'calendarView.view': { labelKey: 'Settings.Calendar' },
686
+
687
+ // ── Calendar view ───────────────────────────────────────────────────────
688
+ 'calendarView.showCalendarNames': { labelKey: 'Settings.ShowCalendarNames' },
689
+ 'calendarView.splitView': {
690
+ labelKey: 'Settings.SplitViewLabel',
691
+ descriptionKey: 'Settings.SplitViewDescription',
692
+ },
693
+ 'calendarView.calendarColumns': {
694
+ labelKey: 'Settings.SplitViewConfig',
695
+ descriptionKey: 'Settings.SplitViewConfigDescription',
696
+ },
697
+ 'calendarView.dayViewZoom': {
698
+ labelKey: 'Settings.CalendarDayViewIntervalTitle',
699
+ descriptionKey: 'Settings.CalendarDayViewIntervalDescription',
700
+ },
701
+ 'calendarView.weekViewZoom': {
702
+ labelKey: 'Settings.CalendarWeekViewIntervalTitle',
703
+ descriptionKey: 'Settings.CalendarWeekViewIntervalDescription',
704
+ },
705
+
706
+ // ── Auto return to today ────────────────────────────────────────────────
707
+ 'calendarView.autoReturnToTodayEnabled': {
708
+ labelKey: 'Settings.AutoReturnToTodayEnabled',
709
+ },
710
+ 'calendarView.autoReturnToTodayTimeoutSeconds': {
711
+ labelKey: 'Settings.AutoReturnToTodayTimeout',
712
+ },
713
+
714
+ // ── Weather ─────────────────────────────────────────────────────────────
715
+ 'calendarView.weatherLocation': { labelKey: 'Settings.WeatherLocation' },
716
+ 'calendarView.showWeatherOnEvents': {
717
+ labelKey: 'Settings.ShowWeatherOnEvents',
718
+ descriptionKey: 'Settings.ShowWeatherOnEventsDescription',
719
+ },
720
+ 'calendarView.showWeatherOnTimeline': {
721
+ labelKey: 'Settings.ShowWeatherOnTimeline',
722
+ descriptionKey: 'Settings.ShowWeatherOnTimelineDescription',
723
+ },
724
+
725
+ // ── Sound & alerts ──────────────────────────────────────────────────────
726
+ 'sound.reminderVolume': {
727
+ labelKey: 'Settings.ReminderVolume',
728
+ descriptionKey: 'Settings.ReminderVolumeDescription',
729
+ },
730
+ 'sound.timerVolume': {
731
+ labelKey: 'Settings.TimerVolume',
732
+ descriptionKey: 'Settings.TimerVolumeDescription',
733
+ },
734
+ 'sound.mediaVolume': {
735
+ labelKey: 'Settings.MediaVolume',
736
+ descriptionKey: 'Settings.MediaVolumeDescription',
737
+ },
738
+ 'sound.alarmSound': { labelKey: 'Settings.AlarmSound' },
739
+ 'sound.reminderAlarmSound': { labelKey: 'Settings.ReminderAlarmAsset' },
740
+ 'sound.startAlarmSound': { labelKey: 'Settings.StartAlarmSound' },
741
+ 'sound.endAlarmSound': { labelKey: 'Settings.EndAlarmSound' },
742
+ 'sound.reminderAlarmTimeout': { labelKey: 'Settings.ReminderAlarmTimeout' },
743
+ 'sound.timerAlarmSound': { labelKey: 'Settings.TimerAlarmSound' },
744
+ 'sound.timerAlarmTimeout': { labelKey: 'Settings.TimerAlarmTimeout' },
745
+ 'sound.allowCustomReminderSounds': {
746
+ labelKey: 'Settings.AllowCustomReminderSounds',
747
+ },
748
+ 'sound.ttsEnabled': {
749
+ labelKey: 'Settings.EnableSpeech',
750
+ descriptionKey: 'Settings.EnableSpeechDescription',
751
+ },
752
+ 'sound.ttsRate': { labelKey: 'Settings.TtsTitle' },
753
+
754
+ // ── Timer ───────────────────────────────────────────────────────────────
755
+ 'timer.face': { labelKey: 'Settings.TimerFace' },
756
+ 'timer.showTimeRemaining': { labelKey: 'Settings.TimerShowTimeRemaining' },
757
+ 'timer.showEndTime': { labelKey: 'Settings.TimerShowEndTime' },
758
+ 'timer.showRestartButton': { labelKey: 'Settings.TimerShowRestartButton' },
759
+ 'timer.showPauseButton': { labelKey: 'Settings.TimerShowPauseButton' },
760
+
761
+ // ── Lock screen ─────────────────────────────────────────────────────────
762
+ 'lockScreen.pin': { labelKey: 'Settings.LockScreenTitlePin' },
763
+ 'lockScreen.clockDisplay': { labelKey: 'Settings.LockScreenClockTitle' },
764
+ 'lockScreen.showDate': { labelKey: 'Settings.LockScreenShowDate' },
765
+ 'lockScreen.showHourNumbers': {
766
+ labelKey: 'Settings.LockScreenShowHourNumbers',
767
+ },
768
+ 'lockScreen.imageMode': { labelKey: 'Settings.LockScreenImageModeTitle' },
769
+ 'lockScreen.backgroundImage': {
770
+ labelKey: 'Settings.LockScreenImageModeBackground',
771
+ },
772
+ 'lockScreen.photoFrameImages': {
773
+ labelKey: 'Settings.LockScreenImageModePhotoFrame',
774
+ },
775
+ 'lockScreen.photoFrameIntervalSeconds': {
776
+ labelKey: 'Settings.LockScreenPhotoFrameIntervalTitle',
777
+ },
778
+ 'lockScreen.inactivityLockEnabled': {
779
+ labelKey: 'SettingsInactivity.EnableLabel',
780
+ },
781
+ 'lockScreen.inactivityTimeoutMinutes': {
782
+ labelKey: 'SettingsInactivity.TimeoutLabel',
783
+ },
784
+
785
+ // ── Touch / gestures ───────────────────────────────────────────────────
786
+ 'touch.enableTapToCreate': {
787
+ labelKey: 'Settings.EnableTapToCreate',
788
+ descriptionKey: 'Settings.EnableTapToCreateDescription',
789
+ },
790
+ 'touch.enableDragDrop': {
791
+ labelKey: 'Settings.EnableDragDrop',
792
+ descriptionKey: 'Settings.EnableDragDropDescription',
793
+ },
794
+
795
+ // ── Device ──────────────────────────────────────────────────────────────
796
+ 'device.timePickerMode': { labelKey: 'Settings.DateTimeTitle' },
797
+
798
+ // ── Language ────────────────────────────────────────────────────────────
799
+ 'language.locale': { labelKey: 'Settings.SelectLanguage' },
800
+
801
+ // ── Notifications ───────────────────────────────────────────────────────
802
+ 'notification.enabled': { labelKey: 'Settings.NotificationsEnabled' },
803
+ 'notification.notifyAllCalendars': {
804
+ labelKey: 'Settings.NotifyAllCalendars',
805
+ },
806
+ 'notification.enabledCalendarIds': {
807
+ labelKey: 'Settings.NotificationsCalendars',
808
+ },
809
+
810
+ // ── Chronological header ────────────────────────────────────────────────
811
+ 'chronological.header.showNavigationArrows': {
812
+ labelKey: 'ChronologicalFeatures.NavigationArrows',
813
+ },
814
+ 'chronological.header.showClock': {
815
+ labelKey: 'ChronologicalFeatures.Clock',
816
+ },
817
+ 'chronological.header.showCurrentYearInDate': {
818
+ labelKey: 'ChronologicalFeatures.ShowCurrentYear',
819
+ },
820
+ 'chronological.header.showTimeOfDay': {
821
+ labelKey: 'ChronologicalFeatures.TimeOfDay',
822
+ },
823
+
824
+ // ── Chronological footer ────────────────────────────────────────────────
825
+ 'chronological.footer.showMenuButton': {
826
+ labelKey: 'ChronologicalFeatures.MenuButton',
827
+ },
828
+ 'chronological.footer.showViewSwitcherDay': {
829
+ labelKey: 'ChronologicalFeatures.ViewSwitcherDay',
830
+ },
831
+ 'chronological.footer.showViewSwitcherWeek': {
832
+ labelKey: 'ChronologicalFeatures.ViewSwitcherWeek',
833
+ },
834
+ 'chronological.footer.showViewSwitcherMonth': {
835
+ labelKey: 'ChronologicalFeatures.ViewSwitcherMonth',
836
+ },
837
+ 'chronological.footer.showTimerButton': {
838
+ labelKey: 'ChronologicalFeatures.TimerButton',
839
+ },
840
+ 'chronological.footer.showNewEventButton': {
841
+ labelKey: 'ChronologicalFeatures.NewEventButton',
842
+ },
843
+ 'chronological.footer.showNowButton': {
844
+ labelKey: 'ChronologicalFeatures.NowButton',
845
+ },
846
+ 'chronological.footer.showSettingsButton': {
847
+ labelKey: 'ChronologicalFeatures.SettingsButton',
848
+ },
849
+
850
+ // ── Chronological timer ─────────────────────────────────────────────────
851
+ 'chronological.timer.showNewCountdown': {
852
+ labelKey: 'TimerFeatures.ShowNewCountdown',
853
+ },
854
+ 'chronological.timer.showFromTemplate': {
855
+ labelKey: 'TimerFeatures.ShowFromTemplate',
856
+ },
857
+ 'chronological.timer.showEditTemplate': {
858
+ labelKey: 'TimerFeatures.ShowEditTemplate',
859
+ },
860
+ 'chronological.timer.showDeleteTemplate': {
861
+ labelKey: 'TimerFeatures.ShowDeleteTemplate',
862
+ },
863
+ 'chronological.timer.showAddTemplate': {
864
+ labelKey: 'TimerFeatures.ShowAddTemplate',
865
+ },
866
+
867
+ // ── Chronological menu ──────────────────────────────────────────────────
868
+ 'chronological.menu.showSettingsButton': {
869
+ labelKey: 'ChronologicalFeatures.MenuSettingsButton',
870
+ },
871
+ 'chronological.quickSettings.showTimerVolume': {
872
+ labelKey: 'ChronologicalFeatures.QuickSettingsTimerVolume',
873
+ },
874
+ 'chronological.quickSettings.showReminderVolume': {
875
+ labelKey: 'ChronologicalFeatures.QuickSettingsReminderVolume',
876
+ },
877
+ 'chronological.quickSettings.showMediaVolume': {
878
+ labelKey: 'ChronologicalFeatures.QuickSettingsMediaVolume',
879
+ },
880
+ 'chronological.quickSettings.showBrightness': {
881
+ labelKey: 'ChronologicalFeatures.QuickSettingsBrightness',
882
+ },
883
+ 'chronological.quickSettings.showLockScreen': {
884
+ labelKey: 'ChronologicalFeatures.QuickSettingsLockScreen',
885
+ },
886
+
887
+ // ── Event form (time-based) ─────────────────────────────────────────────
888
+ 'eventForm.recurrence': { labelKey: 'Calendar.LabelRecurrence' },
889
+ 'eventForm.reminders': { labelKey: 'Calendar.LabelReminders' },
890
+ 'eventForm.emailReminders': { labelKey: 'Calendar.LabelEmailReminders' },
891
+ 'eventForm.location': { labelKey: 'Common.Location' },
892
+ 'eventForm.travelTime': { labelKey: 'TravelTime.Title' },
893
+ 'eventForm.description': { labelKey: 'Common.Description' },
894
+ 'eventForm.checklist': { labelKey: 'EventChecklist.DefaultName' },
895
+ 'eventForm.images': { labelKey: 'EventImageGallery.SectionTitle' },
896
+ 'eventForm.audioClips': { labelKey: 'EventAudioGallery.SectionTitle' },
897
+ 'eventForm.notificationReceivers': {
898
+ labelKey: 'EventForm.NotificationReceivers',
899
+ },
900
+ 'eventForm.visibility': { labelKey: 'EventVisibility.Title' },
901
+
902
+ // ── Chronological event form ────────────────────────────────────────────
903
+ 'chronologicalEventForm.field.category': {
904
+ labelKey: 'ChronologicalEventForm.CategoryField',
905
+ },
906
+ };
907
+
908
+ /** Return the index of the first matching key pattern in the group, for sorting. */
909
+ function groupKeyIndex(key: string, keys: readonly string[]): number {
910
+ for (let i = 0; i < keys.length; i++) {
911
+ const matcher = keys[i];
912
+ if (key === matcher || (matcher.endsWith('.') && key.startsWith(matcher))) {
913
+ return i;
914
+ }
915
+ }
916
+ return keys.length;
917
+ }
918
+
919
+ /**
920
+ * Lazy-initialized map from setting key → declaration index in buildEntries.
921
+ * Used as a tiebreaker when multiple keys share the same prefix group index.
922
+ */
923
+ let _registryKeyOrder: Map<string, number> | undefined;
924
+ function registryKeyOrder(): Map<string, number> {
925
+ if (!_registryKeyOrder) {
926
+ _registryKeyOrder = new Map();
927
+ const keys = Object.keys(buildEntries(DEFAULT_REGISTRY_CONFIG));
928
+ for (let i = 0; i < keys.length; i++) {
929
+ _registryKeyOrder.set(keys[i], i);
930
+ }
931
+ }
932
+ return _registryKeyOrder;
933
+ }
934
+
935
+ /**
936
+ * Get the ordered list of setting keys for a given menu item.
937
+ * Returns keys matching the app's render order, filtered by EXCLUDED_DEVICE_SETTINGS and HIDDEN.
938
+ */
939
+ export function getKeysForMenuItem(
940
+ menuItemId: SettingsMenuItemId,
941
+ reg: SettingsRegistry = defaultRegistry,
942
+ ): string[] {
943
+ const menuItem = SETTINGS_MENU_ITEMS.find((m) => m.id === menuItemId);
944
+ if (!menuItem?.keys || menuItem.keys.length === 0) return [];
945
+
946
+ const matched = reg.keys.filter((key) => {
947
+ if (EXCLUDED_DEVICE_SETTINGS.has(key)) return false;
948
+ const entry = reg.entries[key as keyof typeof reg.entries];
949
+ if (entry?.uiType === 'HIDDEN') return false;
950
+ return menuItem.keys!.some((k) =>
951
+ k.endsWith('.') ? key.startsWith(k) : key === k,
952
+ );
953
+ });
954
+
955
+ const order = registryKeyOrder();
956
+ matched.sort((a, b) => {
957
+ const aIdx = groupKeyIndex(a, menuItem.keys!);
958
+ const bIdx = groupKeyIndex(b, menuItem.keys!);
959
+ if (aIdx !== bIdx) return aIdx - bIdx;
960
+ return (order.get(a) ?? Infinity) - (order.get(b) ?? Infinity);
961
+ });
962
+
963
+ return matched;
964
+ }
965
+
966
+ /**
967
+ * Group parsed setting entries using the app's menu structure (SETTINGS_MENU_ITEMS).
968
+ * Only includes menu items that have `keys` defined (editable settings).
969
+ * This ensures the web editor has the exact same menu items, order, sections,
970
+ * labels, and icons as the mobile app.
971
+ */
972
+ export function groupSettingsForWeb(
973
+ allSettings: ParsedSettingEntry[],
974
+ calendarType: CalendarType,
975
+ appMode?: 'ENROLLED',
976
+ ): {
977
+ id: SettingsMenuItemId;
978
+ labelKey: string;
979
+ icon: string;
980
+ section: SettingsSectionId | 'account';
981
+ settings: ParsedSettingEntry[];
982
+ }[] {
983
+ // Filter excluded and hidden settings
984
+ const settings = allSettings.filter(
985
+ (s) => !EXCLUDED_DEVICE_SETTINGS.has(s.key) && s.uiType !== 'HIDDEN',
986
+ );
987
+
988
+ const isChronological = calendarType === 'chronological';
989
+ const filteredSettings = settings.filter((s) => {
990
+ if (isChronological && s.key.startsWith('eventForm.')) return false;
991
+ if (!isChronological && s.key.startsWith('chronologicalEventForm.'))
992
+ return false;
993
+ if (s.calendarType && s.calendarType !== calendarType) return false;
994
+ if (s.appMode && s.appMode !== appMode) return false;
995
+ return true;
996
+ });
997
+
998
+ const claimed = new Set<string>();
999
+ const result: {
1000
+ id: SettingsMenuItemId;
1001
+ labelKey: string;
1002
+ icon: string;
1003
+ section: SettingsSectionId | 'account';
1004
+ settings: ParsedSettingEntry[];
1005
+ }[] = [];
1006
+
1007
+ for (const menuItem of SETTINGS_MENU_ITEMS) {
1008
+ // Skip items without keys (navigation-only like gallery, guests, etc.)
1009
+ if (!menuItem.keys || menuItem.keys.length === 0) continue;
1010
+
1011
+ // Skip items not relevant for current calendar type
1012
+ if (menuItem.calendarType && menuItem.calendarType !== calendarType)
1013
+ continue;
1014
+
1015
+ // Skip items not relevant for current app mode
1016
+ if (menuItem.appMode && menuItem.appMode !== appMode) continue;
1017
+
1018
+ const matched = filteredSettings.filter((s) => {
1019
+ if (claimed.has(s.key)) return false;
1020
+ return menuItem.keys!.some((k) =>
1021
+ k.endsWith('.') ? s.key.startsWith(k) : s.key === k,
1022
+ );
1023
+ });
1024
+
1025
+ if (matched.length === 0) continue;
1026
+
1027
+ // Sort by key order within the menu item's keys array
1028
+ const order = registryKeyOrder();
1029
+ matched.sort((a, b) => {
1030
+ const aIdx = groupKeyIndex(a.key, menuItem.keys!);
1031
+ const bIdx = groupKeyIndex(b.key, menuItem.keys!);
1032
+ if (aIdx !== bIdx) return aIdx - bIdx;
1033
+ return (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity);
1034
+ });
1035
+
1036
+ for (const s of matched) {
1037
+ claimed.add(s.key);
1038
+ }
1039
+
1040
+ result.push({
1041
+ id: menuItem.id,
1042
+ labelKey: menuItem.labelKey,
1043
+ icon: menuItem.icon,
1044
+ section: menuItem.section,
1045
+ settings: matched,
1046
+ });
1047
+ }
1048
+
1049
+ return result;
1050
+ }
1051
+
1052
+ // ---------------------------------------------------------------------------
1053
+ // Data type discriminator — stored alongside each setting in the DB
1054
+ // ---------------------------------------------------------------------------
1055
+
1056
+ export type SettingsDataType = 'string' | 'number' | 'boolean' | 'json';
1057
+
1058
+ // ---------------------------------------------------------------------------
1059
+ // Single registry entry definition
1060
+ // ---------------------------------------------------------------------------
1061
+
1062
+ export type SettingDef<T = unknown> = {
1063
+ category: SettingsCategory;
1064
+ type: SettingsDataType;
1065
+ default: T;
1066
+ /** Whether this setting should be synced to the server. Default true. */
1067
+ sync: boolean;
1068
+ /** UI component type for rendering this setting */
1069
+ uiType: SettingUiType;
1070
+ /** Available options for SELECT-type settings */
1071
+ options?: readonly SettingOption<T>[];
1072
+ /** Slider configuration for SLIDER-type settings */
1073
+ sliderConfig?: SliderConfig;
1074
+ /** Only show this setting for enrolled/kiosk devices (undefined = always) */
1075
+ appMode?: 'ENROLLED';
1076
+ /** Only show this setting for a specific calendar type (undefined = always) */
1077
+ calendarType?: CalendarType;
1078
+ /** Show this setting only when condition(s) are met */
1079
+ visibleWhen?: VisibilityRule;
1080
+ /** Disable (but still show) this setting when condition(s) are met */
1081
+ disabledWhen?: DisabledRule;
1082
+ };
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // Configuration for app-specific defaults
1086
+ // ---------------------------------------------------------------------------
1087
+
1088
+ export type RegistryConfig = {
1089
+ /** Whether the device is in enrolled/kiosk mode */
1090
+ isEnrolled: boolean;
1091
+ /** Default theme code */
1092
+ defaultTheme: ThemeSetting;
1093
+ /** Default locale code */
1094
+ defaultLocale: LocaleCode;
1095
+ };
1096
+
1097
+ /** Default config for non-enrolled mode */
1098
+ export const DEFAULT_REGISTRY_CONFIG: RegistryConfig = {
1099
+ isEnrolled: false,
1100
+ defaultTheme: 'system',
1101
+ defaultLocale: 'nb',
1102
+ };
1103
+
1104
+ // ---------------------------------------------------------------------------
1105
+ // Shared option arrays (reused across multiple settings)
1106
+ // ---------------------------------------------------------------------------
1107
+
1108
+ const ALARM_SOUND_OPTIONS: readonly SettingOption<AlarmSound>[] = [
1109
+ { value: 'none', labelKey: 'AlarmAsset.none' },
1110
+ { value: 'alarm1', labelKey: 'AlarmAsset.alarm1' },
1111
+ { value: 'alarm2', labelKey: 'AlarmAsset.alarm2' },
1112
+ { value: 'alarm3', labelKey: 'AlarmAsset.alarm3' },
1113
+ { value: 'alarm4', labelKey: 'AlarmAsset.alarm4' },
1114
+ { value: 'alarm5', labelKey: 'AlarmAsset.alarm5' },
1115
+ { value: 'alarm6', labelKey: 'AlarmAsset.alarm6' },
1116
+ { value: 'alarm7', labelKey: 'AlarmAsset.alarm7' },
1117
+ { value: 'alarm8', labelKey: 'AlarmAsset.alarm8' },
1118
+ { value: 'alarm9', labelKey: 'AlarmAsset.alarm9' },
1119
+ ];
1120
+
1121
+ const ALARM_TIMEOUT_OPTIONS: readonly SettingOption<AlarmTimeout>[] = [
1122
+ { value: 5, labelKey: 'Duration.CountSeconds', labelValues: { count: 5 } },
1123
+ { value: 30, labelKey: 'Duration.CountSeconds', labelValues: { count: 30 } },
1124
+ { value: 60, labelKey: 'Duration.CountMinutes', labelValues: { count: 1 } },
1125
+ { value: 120, labelKey: 'Duration.CountMinutes', labelValues: { count: 2 } },
1126
+ { value: 180, labelKey: 'Duration.CountMinutes', labelValues: { count: 3 } },
1127
+ { value: 300, labelKey: 'Duration.CountMinutes', labelValues: { count: 5 } },
1128
+ { value: 600, labelKey: 'Duration.CountMinutes', labelValues: { count: 10 } },
1129
+ ];
1130
+
1131
+ const DAY_VIEW_ZOOM_OPTIONS: readonly SettingOption<CalendarDayViewCellZoom>[] =
1132
+ [
1133
+ { value: 15, labelKey: 'Settings.Option.DayViewZoom.15min' },
1134
+ { value: 30, labelKey: 'Settings.Option.DayViewZoom.30min' },
1135
+ { value: 60, labelKey: 'Settings.Option.DayViewZoom.1hour' },
1136
+ ];
1137
+
1138
+ const TIME_OF_DAY_SLIDER: SliderConfig = { min: 0, max: 23, step: 1 };
1139
+
1140
+ // ---------------------------------------------------------------------------
1141
+ // Helper
1142
+ // ---------------------------------------------------------------------------
1143
+
1144
+ function def<T>(
1145
+ category: SettingsCategory,
1146
+ type: SettingsDataType,
1147
+ defaultValue: T,
1148
+ uiType: SettingUiType,
1149
+ extra?: {
1150
+ sync?: boolean;
1151
+ options?: readonly SettingOption<T>[];
1152
+ sliderConfig?: SliderConfig;
1153
+ appMode?: 'ENROLLED';
1154
+ calendarType?: CalendarType;
1155
+ visibleWhen?: VisibilityRule;
1156
+ disabledWhen?: DisabledRule;
1157
+ },
1158
+ ): SettingDef<T> {
1159
+ const result: SettingDef<T> = {
1160
+ category,
1161
+ type,
1162
+ default: defaultValue,
1163
+ sync: extra?.sync ?? true,
1164
+ uiType,
1165
+ };
1166
+ if (extra?.options) {
1167
+ result.options = extra.options;
1168
+ }
1169
+ if (extra?.sliderConfig) {
1170
+ result.sliderConfig = extra.sliderConfig;
1171
+ }
1172
+ if (extra?.appMode) {
1173
+ result.appMode = extra.appMode;
1174
+ }
1175
+ if (extra?.calendarType) {
1176
+ result.calendarType = extra.calendarType;
1177
+ }
1178
+ if (extra?.visibleWhen) {
1179
+ result.visibleWhen = extra.visibleWhen;
1180
+ }
1181
+ if (extra?.disabledWhen) {
1182
+ result.disabledWhen = extra.disabledWhen;
1183
+ }
1184
+ return result;
1185
+ }
1186
+
1187
+ // ---------------------------------------------------------------------------
1188
+ // Entry builder (internal — used by SettingsRegistry class and factory)
1189
+ // ---------------------------------------------------------------------------
1190
+
1191
+ function buildEntries(config: RegistryConfig) {
1192
+ return {
1193
+ // ═══════════════════════════════════════════════════════════════════════
1194
+ // Appearance
1195
+ // ═══════════════════════════════════════════════════════════════════════
1196
+ 'appearance.theme': def<ThemeSetting>(
1197
+ 'appearance',
1198
+ 'string',
1199
+ config.defaultTheme,
1200
+ 'CUSTOM_THEME_PICKER',
1201
+ {
1202
+ options: config.isEnrolled
1203
+ ? [
1204
+ { value: 'light', labelKey: 'Settings.Option.Theme.Light' },
1205
+ { value: 'dark', labelKey: 'Settings.Option.Theme.Dark' },
1206
+ ]
1207
+ : [
1208
+ { value: 'light', labelKey: 'Settings.Option.Theme.Light' },
1209
+ { value: 'dark', labelKey: 'Settings.Option.Theme.Dark' },
1210
+ { value: 'system', labelKey: 'Settings.Option.Theme.System' },
1211
+ ],
1212
+ },
1213
+ ),
1214
+ 'appearance.clockType': def<ClockType>(
1215
+ 'appearance',
1216
+ 'string',
1217
+ 'digital',
1218
+ 'CUSTOM_CLOCK_TYPE',
1219
+ {
1220
+ options: [
1221
+ { value: 'digital', labelKey: 'Settings.Option.ClockType.Digital' },
1222
+ { value: 'analog', labelKey: 'Settings.Option.ClockType.Analog' },
1223
+ ],
1224
+ appMode: 'ENROLLED',
1225
+ },
1226
+ ),
1227
+ 'appearance.enableDayColors': def<boolean>(
1228
+ 'appearance',
1229
+ 'boolean',
1230
+ false,
1231
+ 'TOGGLE',
1232
+ { calendarType: 'chronological' },
1233
+ ),
1234
+
1235
+ // ═══════════════════════════════════════════════════════════════════════
1236
+ // Calendar view
1237
+ // ═══════════════════════════════════════════════════════════════════════
1238
+ 'calendarView.type': def<CalendarType>(
1239
+ 'calendarView',
1240
+ 'string',
1241
+ 'time-based',
1242
+ 'CUSTOM_CALENDAR_TYPE',
1243
+ {
1244
+ options: [
1245
+ {
1246
+ value: 'chronological',
1247
+ labelKey: 'Settings.Option.CalendarType.Chronological',
1248
+ },
1249
+ {
1250
+ value: 'time-based',
1251
+ labelKey: 'Settings.Option.CalendarType.TimeBased',
1252
+ },
1253
+ ],
1254
+ },
1255
+ ),
1256
+ 'calendarView.view': def<CalendarViewMode>(
1257
+ 'calendarView',
1258
+ 'string',
1259
+ 'day',
1260
+ 'SELECT',
1261
+ {
1262
+ options: [
1263
+ { value: 'day', labelKey: 'Settings.Option.CalendarView.Day' },
1264
+ {
1265
+ value: '3-days',
1266
+ labelKey: 'Settings.Option.CalendarView.3Days',
1267
+ },
1268
+ {
1269
+ value: '5-days',
1270
+ labelKey: 'Settings.Option.CalendarView.5Days',
1271
+ },
1272
+ {
1273
+ value: '7-days',
1274
+ labelKey: 'Settings.Option.CalendarView.7Days',
1275
+ },
1276
+ { value: 'week', labelKey: 'Settings.Option.CalendarView.Week' },
1277
+ {
1278
+ value: 'month',
1279
+ labelKey: 'Settings.Option.CalendarView.Month',
1280
+ },
1281
+ {
1282
+ value: 'overview',
1283
+ labelKey: 'Settings.Option.CalendarView.Overview',
1284
+ },
1285
+ ],
1286
+ },
1287
+ ),
1288
+ 'calendarView.dayViewZoom': def<CalendarDayViewCellZoom>(
1289
+ 'calendarView',
1290
+ 'number',
1291
+ 60,
1292
+ 'SELECT',
1293
+ { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
1294
+ ),
1295
+ 'calendarView.weekViewZoom': def<CalendarDayViewCellZoom>(
1296
+ 'calendarView',
1297
+ 'number',
1298
+ 60,
1299
+ 'SELECT',
1300
+ { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
1301
+ ),
1302
+ 'calendarView.splitView': def<boolean>(
1303
+ 'calendarView',
1304
+ 'boolean',
1305
+ false,
1306
+ 'TOGGLE',
1307
+ ),
1308
+ 'calendarView.showCalendarNames': def<boolean>(
1309
+ 'calendarView',
1310
+ 'boolean',
1311
+ true,
1312
+ 'TOGGLE',
1313
+ ),
1314
+ 'calendarView.calendarColumns': def<unknown[]>(
1315
+ 'calendarView',
1316
+ 'json',
1317
+ [],
1318
+ 'CUSTOM_SPLIT_VIEW_CONFIG',
1319
+ ),
1320
+ 'calendarView.autoReturnToTodayEnabled': def<boolean>(
1321
+ 'calendarView',
1322
+ 'boolean',
1323
+ config.isEnrolled,
1324
+ 'TOGGLE',
1325
+ ),
1326
+ 'calendarView.autoReturnToTodayTimeoutSeconds': def<number>(
1327
+ 'calendarView',
1328
+ 'number',
1329
+ 300,
1330
+ 'SLIDER',
1331
+ {
1332
+ sliderConfig: { min: 30, max: 600, step: 30 },
1333
+ visibleWhen: { dependsOn: 'calendarView.autoReturnToTodayEnabled', isTruthy: true },
1334
+ },
1335
+ ),
1336
+ 'calendarView.showWeatherOnEvents': def<boolean>(
1337
+ 'calendarView',
1338
+ 'boolean',
1339
+ false,
1340
+ 'TOGGLE',
1341
+ ),
1342
+ 'calendarView.showWeatherOnTimeline': def<boolean>(
1343
+ 'calendarView',
1344
+ 'boolean',
1345
+ false,
1346
+ 'TOGGLE',
1347
+ ),
1348
+ 'calendarView.weatherLocation': def<WeatherLocation | null>(
1349
+ 'calendarView',
1350
+ 'json',
1351
+ null,
1352
+ 'CUSTOM_WEATHER_LOCATION',
1353
+ { visibleWhen: { dependsOn: 'calendarView.showWeatherOnTimeline', isTruthy: true } },
1354
+ ),
1355
+
1356
+ // ═══════════════════════════════════════════════════════════════════════
1357
+ // Event form field visibility (time-based)
1358
+ // ═══════════════════════════════════════════════════════════════════════
1359
+ 'eventForm.recurrence': def<boolean>(
1360
+ 'eventForm',
1361
+ 'boolean',
1362
+ true,
1363
+ 'TOGGLE',
1364
+ ),
1365
+ 'eventForm.location': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1366
+ 'eventForm.travelTime': def<boolean>(
1367
+ 'eventForm',
1368
+ 'boolean',
1369
+ false,
1370
+ 'TOGGLE',
1371
+ ),
1372
+ 'eventForm.reminders': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1373
+ 'eventForm.emailReminders': def<boolean>(
1374
+ 'eventForm',
1375
+ 'boolean',
1376
+ false,
1377
+ 'TOGGLE',
1378
+ ),
1379
+ 'eventForm.description': def<boolean>(
1380
+ 'eventForm',
1381
+ 'boolean',
1382
+ true,
1383
+ 'TOGGLE',
1384
+ ),
1385
+ 'eventForm.checklist': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1386
+ 'eventForm.images': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1387
+ 'eventForm.audioClips': def<boolean>(
1388
+ 'eventForm',
1389
+ 'boolean',
1390
+ false,
1391
+ 'TOGGLE',
1392
+ ),
1393
+ 'eventForm.notificationReceivers': def<boolean>(
1394
+ 'eventForm',
1395
+ 'boolean',
1396
+ true,
1397
+ 'TOGGLE',
1398
+ ),
1399
+ 'eventForm.visibility': def<boolean>(
1400
+ 'eventForm',
1401
+ 'boolean',
1402
+ false,
1403
+ 'TOGGLE',
1404
+ ),
1405
+
1406
+ // ═══════════════════════════════════════════════════════════════════════
1407
+ // Sound & alerts
1408
+ // ═══════════════════════════════════════════════════════════════════════
1409
+ 'sound.timerVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', {
1410
+ visibleWhen: { dependsOn: 'sound.timerAlarmSound', notEquals: 'none' },
1411
+ }),
1412
+ 'sound.reminderVolume': def<number>(
1413
+ 'sound',
1414
+ 'number',
1415
+ 0.5,
1416
+ 'VOLUME_SLIDER',
1417
+ {
1418
+ appMode: 'ENROLLED',
1419
+ visibleWhen: { dependsOn: 'sound.reminderAlarmSound', notEquals: 'none' },
1420
+ },
1421
+ ),
1422
+ 'sound.mediaVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', {
1423
+ appMode: 'ENROLLED',
1424
+ }),
1425
+ 'sound.alarmSound': def<AlarmSound>('sound', 'string', 'alarm1', 'SELECT', {
1426
+ options: ALARM_SOUND_OPTIONS,
1427
+ }),
1428
+ 'sound.reminderAlarmSound': def<AlarmSound>(
1429
+ 'sound',
1430
+ 'string',
1431
+ 'alarm1',
1432
+ 'SELECT',
1433
+ { options: ALARM_SOUND_OPTIONS },
1434
+ ),
1435
+ 'sound.startAlarmSound': def<AlarmSound>(
1436
+ 'sound',
1437
+ 'string',
1438
+ 'alarm1',
1439
+ 'SELECT',
1440
+ {
1441
+ options: ALARM_SOUND_OPTIONS,
1442
+ visibleWhen: { dependsOn: 'calendarView.type', equals: 'chronological' },
1443
+ },
1444
+ ),
1445
+ 'sound.endAlarmSound': def<AlarmSound>(
1446
+ 'sound',
1447
+ 'string',
1448
+ 'alarm1',
1449
+ 'SELECT',
1450
+ {
1451
+ options: ALARM_SOUND_OPTIONS,
1452
+ visibleWhen: { dependsOn: 'calendarView.type', equals: 'chronological' },
1453
+ },
1454
+ ),
1455
+ 'sound.timerAlarmSound': def<AlarmSound>(
1456
+ 'sound',
1457
+ 'string',
1458
+ 'alarm1',
1459
+ 'SELECT',
1460
+ { options: ALARM_SOUND_OPTIONS },
1461
+ ),
1462
+ 'sound.timerAlarmTimeout': def<AlarmTimeout>(
1463
+ 'sound',
1464
+ 'number',
1465
+ 180,
1466
+ 'SELECT',
1467
+ {
1468
+ options: ALARM_TIMEOUT_OPTIONS,
1469
+ visibleWhen: { dependsOn: 'sound.timerAlarmSound', notEquals: 'none' },
1470
+ },
1471
+ ),
1472
+ 'sound.reminderAlarmTimeout': def<AlarmTimeout>(
1473
+ 'sound',
1474
+ 'number',
1475
+ 180,
1476
+ 'SELECT',
1477
+ {
1478
+ options: ALARM_TIMEOUT_OPTIONS,
1479
+ visibleWhen: { dependsOn: 'sound.reminderAlarmSound', notEquals: 'none' },
1480
+ },
1481
+ ),
1482
+ 'sound.allowCustomReminderSounds': def<boolean>(
1483
+ 'sound',
1484
+ 'boolean',
1485
+ false,
1486
+ 'HIDDEN',
1487
+ ),
1488
+ 'sound.ttsEnabled': def<boolean>(
1489
+ 'sound',
1490
+ 'boolean',
1491
+ config.isEnrolled,
1492
+ 'TOGGLE',
1493
+ { appMode: 'ENROLLED' },
1494
+ ),
1495
+ 'sound.ttsRate': def<number>('sound', 'number', 1.0, 'SLIDER', {
1496
+ sliderConfig: { min: 0.5, max: 2, step: 0.1 },
1497
+ appMode: 'ENROLLED',
1498
+ visibleWhen: { dependsOn: 'sound.ttsEnabled', isTruthy: true },
1499
+ }),
1500
+
1501
+ // ═══════════════════════════════════════════════════════════════════════
1502
+ // Timer
1503
+ // ═══════════════════════════════════════════════════════════════════════
1504
+ 'timer.face': def<'ring' | 'bars'>('timer', 'string', 'ring', 'SELECT', {
1505
+ options: [
1506
+ { value: 'ring', labelKey: 'Timer.FaceRing' },
1507
+ { value: 'bars', labelKey: 'Timer.FaceBars' },
1508
+ ],
1509
+ }),
1510
+ 'timer.showTimeRemaining': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1511
+ 'timer.showEndTime': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1512
+ 'timer.showRestartButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1513
+ 'timer.showPauseButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1514
+
1515
+ // ═══════════════════════════════════════════════════════════════════════
1516
+ // Lock screen
1517
+ // ═══════════════════════════════════════════════════════════════════════
1518
+ 'lockScreen.inactivityLockEnabled': def<boolean>(
1519
+ 'lockScreen',
1520
+ 'boolean',
1521
+ false,
1522
+ 'TOGGLE',
1523
+ ),
1524
+ 'lockScreen.inactivityTimeoutMinutes': def<InactivityTimeoutMinutes>(
1525
+ 'lockScreen',
1526
+ 'number',
1527
+ 5,
1528
+ 'SELECT',
1529
+ {
1530
+ options: [
1531
+ { value: 1, labelKey: 'Settings.Option.InactivityTimeout.1min' },
1532
+ { value: 5, labelKey: 'Settings.Option.InactivityTimeout.5min' },
1533
+ { value: 10, labelKey: 'Settings.Option.InactivityTimeout.10min' },
1534
+ { value: 15, labelKey: 'Settings.Option.InactivityTimeout.15min' },
1535
+ { value: 30, labelKey: 'Settings.Option.InactivityTimeout.30min' },
1536
+ { value: 45, labelKey: 'Settings.Option.InactivityTimeout.45min' },
1537
+ ],
1538
+ visibleWhen: { dependsOn: 'lockScreen.inactivityLockEnabled', isTruthy: true },
1539
+ },
1540
+ ),
1541
+ 'lockScreen.pin': def<string>('lockScreen', 'string', '', 'PIN_INPUT'),
1542
+ 'lockScreen.clockDisplay': def<LockScreenClockDisplay>(
1543
+ 'lockScreen',
1544
+ 'string',
1545
+ 'digital',
1546
+ 'SELECT',
1547
+ {
1548
+ options: [
1549
+ { value: 'none', labelKey: 'Settings.Option.ClockDisplay.None' },
1550
+ {
1551
+ value: 'digital',
1552
+ labelKey: 'Settings.Option.ClockDisplay.Digital',
1553
+ },
1554
+ {
1555
+ value: 'analog',
1556
+ labelKey: 'Settings.Option.ClockDisplay.Analog',
1557
+ },
1558
+ ],
1559
+ },
1560
+ ),
1561
+ 'lockScreen.showHourNumbers': def<boolean>(
1562
+ 'lockScreen',
1563
+ 'boolean',
1564
+ true,
1565
+ 'TOGGLE',
1566
+ { visibleWhen: { dependsOn: 'lockScreen.clockDisplay', equals: 'analog' } },
1567
+ ),
1568
+ 'lockScreen.showDate': def<boolean>(
1569
+ 'lockScreen',
1570
+ 'boolean',
1571
+ true,
1572
+ 'TOGGLE',
1573
+ ),
1574
+ 'lockScreen.imageMode': def<LockScreenImageMode>(
1575
+ 'lockScreen',
1576
+ 'string',
1577
+ 'none',
1578
+ 'SELECT',
1579
+ {
1580
+ options: [
1581
+ { value: 'none', labelKey: 'Settings.Option.ImageMode.None' },
1582
+ {
1583
+ value: 'background',
1584
+ labelKey: 'Settings.Option.ImageMode.Background',
1585
+ },
1586
+ {
1587
+ value: 'photoFrame',
1588
+ labelKey: 'Settings.Option.ImageMode.PhotoFrame',
1589
+ },
1590
+ ],
1591
+ },
1592
+ ),
1593
+ 'lockScreen.backgroundImage': def<string | null>(
1594
+ 'lockScreen',
1595
+ 'json',
1596
+ null,
1597
+ 'CUSTOM_IMAGE',
1598
+ { visibleWhen: { dependsOn: 'lockScreen.imageMode', equals: 'background' } },
1599
+ ),
1600
+ 'lockScreen.photoFrameIntervalSeconds': def<PhotoFrameIntervalSeconds>(
1601
+ 'lockScreen',
1602
+ 'number',
1603
+ 60,
1604
+ 'SELECT',
1605
+ {
1606
+ options: [
1607
+ { value: 30, labelKey: 'Settings.Option.PhotoFrameInterval.30sec' },
1608
+ { value: 60, labelKey: 'Settings.Option.PhotoFrameInterval.1min' },
1609
+ { value: 120, labelKey: 'Settings.Option.PhotoFrameInterval.2min' },
1610
+ { value: 300, labelKey: 'Settings.Option.PhotoFrameInterval.5min' },
1611
+ ],
1612
+ visibleWhen: { dependsOn: 'lockScreen.imageMode', equals: 'photoFrame' },
1613
+ },
1614
+ ),
1615
+
1616
+ 'lockScreen.photoFrameImages': def<LockScreenImage[]>(
1617
+ 'lockScreen',
1618
+ 'json',
1619
+ [],
1620
+ 'CUSTOM_IMAGE_ARRAY',
1621
+ { visibleWhen: { dependsOn: 'lockScreen.imageMode', equals: 'photoFrame' } },
1622
+ ),
1623
+
1624
+ // ═══════════════════════════════════════════════════════════════════════
1625
+ // Touch / gestures
1626
+ // ═══════════════════════════════════════════════════════════════════════
1627
+ 'touch.enableTapToCreate': def<boolean>(
1628
+ 'touch',
1629
+ 'boolean',
1630
+ false,
1631
+ 'TOGGLE',
1632
+ { disabledWhen: { dependsOn: 'sound.ttsEnabled', isTruthy: true } },
1633
+ ),
1634
+ 'touch.enableDragDrop': def<boolean>('touch', 'boolean', false, 'TOGGLE', {
1635
+ disabledWhen: { dependsOn: 'sound.ttsEnabled', isTruthy: true },
1636
+ }),
1637
+
1638
+ // ═══════════════════════════════════════════════════════════════════════
1639
+ // Device (not synced unless noted)
1640
+ // ═══════════════════════════════════════════════════════════════════════
1641
+ 'device.id': def<string>('device', 'string', '', 'HIDDEN', { sync: false }),
1642
+ 'device.timePickerMode': def<TimePickerMode>(
1643
+ 'device',
1644
+ 'string',
1645
+ 'dials',
1646
+ 'SELECT',
1647
+ {
1648
+ options: [
1649
+ { value: 'dials', labelKey: 'Settings.Option.TimePickerMode.Dials' },
1650
+ {
1651
+ value: 'keypad',
1652
+ labelKey: 'Settings.Option.TimePickerMode.Keypad',
1653
+ },
1654
+ ],
1655
+ },
1656
+ ),
1657
+ 'device.devMenuEnabled': def<boolean>(
1658
+ 'device',
1659
+ 'boolean',
1660
+ false,
1661
+ 'HIDDEN',
1662
+ { sync: false },
1663
+ ),
1664
+ 'device.authWarningDismissTtlDays': def<number>(
1665
+ 'device',
1666
+ 'number',
1667
+ 3,
1668
+ 'HIDDEN',
1669
+ { sync: false },
1670
+ ),
1671
+
1672
+ // ═══════════════════════════════════════════════════════════════════════
1673
+ // Language
1674
+ // ═══════════════════════════════════════════════════════════════════════
1675
+ 'language.locale': def<LocaleCode>(
1676
+ 'language',
1677
+ 'string',
1678
+ config.defaultLocale,
1679
+ 'CUSTOM_LANGUAGE_PICKER',
1680
+ {
1681
+ options: [
1682
+ { value: 'nb', labelKey: 'Settings.Option.Language.Norwegian' },
1683
+ { value: 'en', labelKey: 'Settings.Option.Language.English' },
1684
+ ],
1685
+ },
1686
+ ),
1687
+
1688
+ // ═══════════════════════════════════════════════════════════════════════
1689
+ // Notifications
1690
+ // ═══════════════════════════════════════════════════════════════════════
1691
+ 'notification.enabled': def<boolean>(
1692
+ 'notification',
1693
+ 'boolean',
1694
+ false,
1695
+ 'TOGGLE',
1696
+ ),
1697
+ 'notification.notifyAllCalendars': def<boolean>(
1698
+ 'notification',
1699
+ 'boolean',
1700
+ true,
1701
+ 'TOGGLE',
1702
+ ),
1703
+ 'notification.enabledCalendarIds': def<string[]>(
1704
+ 'notification',
1705
+ 'json',
1706
+ [],
1707
+ 'CUSTOM_CALENDAR_IDS',
1708
+ ),
1709
+ 'notification.hasBeenPrompted': def<boolean>(
1710
+ 'notification',
1711
+ 'boolean',
1712
+ false,
1713
+ 'HIDDEN',
1714
+ { sync: false },
1715
+ ),
1716
+
1717
+ // ═══════════════════════════════════════════════════════════════════════
1718
+ // Chronological features (header, footer, menu, quick settings, timer)
1719
+ // ═══════════════════════════════════════════════════════════════════════
1720
+ // Header
1721
+ 'chronological.header.showNavigationArrows': def<boolean>(
1722
+ 'chronological',
1723
+ 'boolean',
1724
+ true,
1725
+ 'TOGGLE',
1726
+ ),
1727
+ 'chronological.header.showCurrentYearInDate': def<boolean>(
1728
+ 'chronological',
1729
+ 'boolean',
1730
+ false,
1731
+ 'TOGGLE',
1732
+ ),
1733
+ 'chronological.header.showClock': def<boolean>(
1734
+ 'chronological',
1735
+ 'boolean',
1736
+ true,
1737
+ 'TOGGLE',
1738
+ ),
1739
+ 'chronological.header.showTimeOfDay': def<boolean>(
1740
+ 'chronological',
1741
+ 'boolean',
1742
+ false,
1743
+ 'TOGGLE',
1744
+ ),
1745
+ // Footer
1746
+ 'chronological.footer.showMenuButton': def<boolean>(
1747
+ 'chronological',
1748
+ 'boolean',
1749
+ true,
1750
+ 'TOGGLE',
1751
+ ),
1752
+ 'chronological.footer.showViewSwitcherDay': def<boolean>(
1753
+ 'chronological',
1754
+ 'boolean',
1755
+ true,
1756
+ 'TOGGLE',
1757
+ ),
1758
+ 'chronological.footer.showViewSwitcherWeek': def<boolean>(
1759
+ 'chronological',
1760
+ 'boolean',
1761
+ true,
1762
+ 'TOGGLE',
1763
+ ),
1764
+ 'chronological.footer.showViewSwitcherMonth': def<boolean>(
1765
+ 'chronological',
1766
+ 'boolean',
1767
+ true,
1768
+ 'TOGGLE',
1769
+ ),
1770
+ 'chronological.footer.showTimerButton': def<boolean>(
1771
+ 'chronological',
1772
+ 'boolean',
1773
+ true,
1774
+ 'TOGGLE',
1775
+ ),
1776
+ 'chronological.footer.showNewEventButton': def<boolean>(
1777
+ 'chronological',
1778
+ 'boolean',
1779
+ true,
1780
+ 'TOGGLE',
1781
+ ),
1782
+ 'chronological.footer.showNowButton': def<boolean>(
1783
+ 'chronological',
1784
+ 'boolean',
1785
+ true,
1786
+ 'TOGGLE',
1787
+ ),
1788
+ 'chronological.footer.showSettingsButton': def<boolean>(
1789
+ 'chronological',
1790
+ 'boolean',
1791
+ true,
1792
+ 'TOGGLE',
1793
+ ),
1794
+ // Timer features
1795
+ 'chronological.timer.showNewCountdown': def<boolean>(
1796
+ 'chronological',
1797
+ 'boolean',
1798
+ true,
1799
+ 'TOGGLE',
1800
+ ),
1801
+ 'chronological.timer.showFromTemplate': def<boolean>(
1802
+ 'chronological',
1803
+ 'boolean',
1804
+ true,
1805
+ 'TOGGLE',
1806
+ ),
1807
+ 'chronological.timer.showAddTemplate': def<boolean>(
1808
+ 'chronological',
1809
+ 'boolean',
1810
+ true,
1811
+ 'TOGGLE',
1812
+ ),
1813
+ 'chronological.timer.showEditTemplate': def<boolean>(
1814
+ 'chronological',
1815
+ 'boolean',
1816
+ true,
1817
+ 'TOGGLE',
1818
+ ),
1819
+ 'chronological.timer.showDeleteTemplate': def<boolean>(
1820
+ 'chronological',
1821
+ 'boolean',
1822
+ true,
1823
+ 'TOGGLE',
1824
+ ),
1825
+ // Menu
1826
+ 'chronological.menu.showSettingsButton': def<boolean>(
1827
+ 'chronological',
1828
+ 'boolean',
1829
+ true,
1830
+ 'TOGGLE',
1831
+ ),
1832
+ // Quick settings
1833
+ 'chronological.quickSettings.showTimerVolume': def<boolean>(
1834
+ 'chronological',
1835
+ 'boolean',
1836
+ true,
1837
+ 'TOGGLE',
1838
+ ),
1839
+ 'chronological.quickSettings.showReminderVolume': def<boolean>(
1840
+ 'chronological',
1841
+ 'boolean',
1842
+ true,
1843
+ 'TOGGLE',
1844
+ ),
1845
+ 'chronological.quickSettings.showMediaVolume': def<boolean>(
1846
+ 'chronological',
1847
+ 'boolean',
1848
+ true,
1849
+ 'TOGGLE',
1850
+ ),
1851
+ 'chronological.quickSettings.showBrightness': def<boolean>(
1852
+ 'chronological',
1853
+ 'boolean',
1854
+ true,
1855
+ 'TOGGLE',
1856
+ ),
1857
+ 'chronological.quickSettings.showLockScreen': def<boolean>(
1858
+ 'chronological',
1859
+ 'boolean',
1860
+ true,
1861
+ 'TOGGLE',
1862
+ ),
1863
+ 'chronological.quickSettings.showDayViewMode': def<boolean>(
1864
+ 'chronological',
1865
+ 'boolean',
1866
+ true,
1867
+ 'TOGGLE',
1868
+ ),
1869
+ // Day view display mode
1870
+ 'chronological.dayView.displayMode':
1871
+ def<ChronologicalDayViewDisplayMode>(
1872
+ 'chronological',
1873
+ 'string',
1874
+ 'list',
1875
+ 'SELECT',
1876
+ {
1877
+ options: [
1878
+ {
1879
+ value: 'list' as const,
1880
+ labelKey: 'Settings.Option.DayViewDisplayMode.List',
1881
+ },
1882
+ {
1883
+ value: 'timeline' as const,
1884
+ labelKey: 'Settings.Option.DayViewDisplayMode.Timeline',
1885
+ },
1886
+ ],
1887
+ calendarType: 'chronological',
1888
+ },
1889
+ ),
1890
+ // Time-of-day periods
1891
+ 'chronological.timeOfDay.morningStart': def<number>(
1892
+ 'chronological',
1893
+ 'number',
1894
+ 6,
1895
+ 'SLIDER',
1896
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1897
+ ),
1898
+ 'chronological.timeOfDay.forenoonStart': def<number>(
1899
+ 'chronological',
1900
+ 'number',
1901
+ 9,
1902
+ 'SLIDER',
1903
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1904
+ ),
1905
+ 'chronological.timeOfDay.afternoonStart': def<number>(
1906
+ 'chronological',
1907
+ 'number',
1908
+ 12,
1909
+ 'SLIDER',
1910
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1911
+ ),
1912
+ 'chronological.timeOfDay.eveningStart': def<number>(
1913
+ 'chronological',
1914
+ 'number',
1915
+ 18,
1916
+ 'SLIDER',
1917
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1918
+ ),
1919
+ 'chronological.timeOfDay.nightStart': def<number>(
1920
+ 'chronological',
1921
+ 'number',
1922
+ 0,
1923
+ 'SLIDER',
1924
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1925
+ ),
1926
+
1927
+ // ═══════════════════════════════════════════════════════════════════════
1928
+ // Chronological event form
1929
+ // ═══════════════════════════════════════════════════════════════════════
1930
+ // Fixed fields
1931
+ 'chronologicalEventForm.fixedField.category': def<boolean>(
1932
+ 'chronologicalEventForm',
1933
+ 'boolean',
1934
+ true,
1935
+ 'TOGGLE',
1936
+ ),
1937
+ 'chronologicalEventForm.fixedField.allDay': def<boolean>(
1938
+ 'chronologicalEventForm',
1939
+ 'boolean',
1940
+ true,
1941
+ 'TOGGLE',
1942
+ ),
1943
+ 'chronologicalEventForm.fixedField.endTime': def<boolean>(
1944
+ 'chronologicalEventForm',
1945
+ 'boolean',
1946
+ true,
1947
+ 'TOGGLE',
1948
+ ),
1949
+ 'chronologicalEventForm.fixedField.alarm': def<boolean>(
1950
+ 'chronologicalEventForm',
1951
+ 'boolean',
1952
+ true,
1953
+ 'TOGGLE',
1954
+ ),
1955
+ 'chronologicalEventForm.fixedField.visibility': def<boolean>(
1956
+ 'chronologicalEventForm',
1957
+ 'boolean',
1958
+ true,
1959
+ 'TOGGLE',
1960
+ ),
1961
+ // Toggleable fields
1962
+ 'chronologicalEventForm.field.acknowledge': def<boolean>(
1963
+ 'chronologicalEventForm',
1964
+ 'boolean',
1965
+ true,
1966
+ 'TOGGLE',
1967
+ ),
1968
+ 'chronologicalEventForm.field.description': def<boolean>(
1969
+ 'chronologicalEventForm',
1970
+ 'boolean',
1971
+ true,
1972
+ 'TOGGLE',
1973
+ ),
1974
+ 'chronologicalEventForm.field.recurrence': def<boolean>(
1975
+ 'chronologicalEventForm',
1976
+ 'boolean',
1977
+ true,
1978
+ 'TOGGLE',
1979
+ ),
1980
+ 'chronologicalEventForm.field.checklist': def<boolean>(
1981
+ 'chronologicalEventForm',
1982
+ 'boolean',
1983
+ true,
1984
+ 'TOGGLE',
1985
+ ),
1986
+ 'chronologicalEventForm.field.extraImages': def<boolean>(
1987
+ 'chronologicalEventForm',
1988
+ 'boolean',
1989
+ true,
1990
+ 'TOGGLE',
1991
+ ),
1992
+ 'chronologicalEventForm.field.reminders': def<boolean>(
1993
+ 'chronologicalEventForm',
1994
+ 'boolean',
1995
+ true,
1996
+ 'TOGGLE',
1997
+ ),
1998
+ 'chronologicalEventForm.field.audioClips': def<boolean>(
1999
+ 'chronologicalEventForm',
2000
+ 'boolean',
2001
+ true,
2002
+ 'TOGGLE',
2003
+ ),
2004
+ 'chronologicalEventForm.field.category': def<boolean>(
2005
+ 'chronologicalEventForm',
2006
+ 'boolean',
2007
+ true,
2008
+ 'TOGGLE',
2009
+ ),
2010
+ // Suggest end time
2011
+ 'chronologicalEventForm.suggestEndTime.enabled': def<boolean>(
2012
+ 'chronologicalEventForm',
2013
+ 'boolean',
2014
+ false,
2015
+ 'TOGGLE',
2016
+ ),
2017
+ 'chronologicalEventForm.suggestEndTime.value': def<number>(
2018
+ 'chronologicalEventForm',
2019
+ 'number',
2020
+ 30,
2021
+ 'SLIDER',
2022
+ { sliderConfig: { min: 5, max: 480, step: 5 } },
2023
+ ),
2024
+ 'chronologicalEventForm.suggestEndTime.unit': def<SuggestEndTimeUnit>(
2025
+ 'chronologicalEventForm',
2026
+ 'string',
2027
+ 'minutes',
2028
+ 'SELECT',
2029
+ {
2030
+ options: [
2031
+ {
2032
+ value: 'minutes',
2033
+ labelKey: 'Settings.Option.SuggestEndTimeUnit.Minutes',
2034
+ },
2035
+ {
2036
+ value: 'hours',
2037
+ labelKey: 'Settings.Option.SuggestEndTimeUnit.Hours',
2038
+ },
2039
+ ],
2040
+ },
2041
+ ),
2042
+ // Default visibility
2043
+ 'chronologicalEventForm.defaultVisibility': def<EventVisibility>(
2044
+ 'chronologicalEventForm',
2045
+ 'string',
2046
+ 'Private',
2047
+ 'SELECT',
2048
+ {
2049
+ options: [
2050
+ { value: 'Public', labelKey: 'Settings.Option.Visibility.Public' },
2051
+ { value: 'Private', labelKey: 'Settings.Option.Visibility.Private' },
2052
+ { value: 'Custom', labelKey: 'Settings.Option.Visibility.Custom' },
2053
+ ],
2054
+ },
2055
+ ),
2056
+ // Default alarm toggles
2057
+ 'chronologicalEventForm.defaultAlarm.atStart': def<boolean>(
2058
+ 'chronologicalEventForm',
2059
+ 'boolean',
2060
+ true,
2061
+ 'TOGGLE',
2062
+ ),
2063
+ 'chronologicalEventForm.defaultAlarm.atEnd': def<boolean>(
2064
+ 'chronologicalEventForm',
2065
+ 'boolean',
2066
+ false,
2067
+ 'TOGGLE',
2068
+ ),
2069
+ // Reminder presets
2070
+ 'chronologicalEventForm.reminderPresets.timed': def<number[]>(
2071
+ 'chronologicalEventForm',
2072
+ 'json',
2073
+ [5, 15, 30, 60, 120, 1440],
2074
+ 'CUSTOM_REMINDER_PRESETS',
2075
+ ),
2076
+ 'chronologicalEventForm.reminderPresets.allDay': def<AllDayPreset[]>(
2077
+ 'chronologicalEventForm',
2078
+ 'json',
2079
+ [
2080
+ { daysBefore: 0, time: '09:00' },
2081
+ { daysBefore: 1, time: '18:00' },
2082
+ { daysBefore: 2, time: '09:00' },
2083
+ ],
2084
+ 'CUSTOM_REMINDER_PRESETS',
2085
+ ),
2086
+ } as const satisfies Record<string, SettingDef>;
2087
+ }
2088
+
2089
+ // ---------------------------------------------------------------------------
2090
+ // Derived types (based on entry shape — all configs produce the same keys)
2091
+ // ---------------------------------------------------------------------------
2092
+
2093
+ type SettingsEntries = ReturnType<typeof buildEntries>;
2094
+
2095
+ /** Union type of every valid setting key */
2096
+ export type SettingKey = keyof SettingsEntries;
2097
+
2098
+ /** Type-safe value type for a given setting key */
2099
+ export type SettingValue<K extends SettingKey> = SettingsEntries[K]['default'];
2100
+
2101
+ /** Complete settings map — all keys resolved to their value types */
2102
+ export type SettingsMap = {
2103
+ [K in SettingKey]: SettingValue<K>;
2104
+ };
2105
+
2106
+ // ---------------------------------------------------------------------------
2107
+ // Registry class
2108
+ // ---------------------------------------------------------------------------
2109
+
2110
+ export class SettingsRegistry {
2111
+ /** The raw setting definitions (category, type, default, sync) */
2112
+ readonly entries: SettingsEntries;
2113
+
2114
+ /** All setting keys as an array */
2115
+ readonly keys: SettingKey[];
2116
+
2117
+ constructor(config: Partial<RegistryConfig> = {}) {
2118
+ const full: RegistryConfig = { ...DEFAULT_REGISTRY_CONFIG, ...config };
2119
+ this.entries = buildEntries(full);
2120
+ this.keys = Object.keys(this.entries) as SettingKey[];
2121
+ }
2122
+
2123
+ /** Get the default value for a setting */
2124
+ getDefault<K extends SettingKey>(key: K): SettingValue<K> {
2125
+ return this.entries[key].default as SettingValue<K>;
2126
+ }
2127
+
2128
+ /** Get a complete map of all default values */
2129
+ getAllDefaults(): SettingsMap {
2130
+ const defaults = {} as Record<string, unknown>;
2131
+ for (const key of this.keys) {
2132
+ defaults[key] = this.entries[key].default;
2133
+ }
2134
+ return defaults as SettingsMap;
2135
+ }
2136
+
2137
+ /** Get the data type for a setting key */
2138
+ getDataType(key: SettingKey): SettingsDataType {
2139
+ return this.entries[key].type;
2140
+ }
2141
+
2142
+ /** Check whether a setting should be synced */
2143
+ isSynced(key: SettingKey): boolean {
2144
+ return this.entries[key].sync;
2145
+ }
2146
+
2147
+ /** Get all setting keys for a category */
2148
+ getByCategory(category: SettingsCategory): SettingKey[] {
2149
+ return this.keys.filter((key) => this.entries[key].category === category);
2150
+ }
2151
+
2152
+ /** Serialize a setting value to a string for DB storage */
2153
+ serialize(key: SettingKey, value: unknown): string {
2154
+ const dataType = this.getDataType(key);
2155
+ switch (dataType) {
2156
+ case 'string':
2157
+ return String(value ?? '');
2158
+ case 'number':
2159
+ return String(value ?? 0);
2160
+ case 'boolean':
2161
+ if (typeof value === 'string') {
2162
+ return value === 'true' ? 'true' : 'false';
2163
+ }
2164
+ return value ? 'true' : 'false';
2165
+ case 'json':
2166
+ // If already a serialized JSON string, return as-is to avoid
2167
+ // double-wrapping (e.g. JSON.stringify("[]") → "\"[]\"")
2168
+ if (typeof value === 'string') {
2169
+ try {
2170
+ JSON.parse(value);
2171
+ return value;
2172
+ } catch {
2173
+ return JSON.stringify(value);
2174
+ }
2175
+ }
2176
+ return JSON.stringify(value);
2177
+ }
2178
+ }
2179
+
2180
+ /** Deserialize a DB string back to a typed setting value */
2181
+ deserialize<K extends SettingKey>(
2182
+ key: K,
2183
+ raw: string | null,
2184
+ ): SettingValue<K> {
2185
+ if (raw === null || raw === undefined) {
2186
+ return this.getDefault(key);
2187
+ }
2188
+
2189
+ const dataType = this.getDataType(key);
2190
+ switch (dataType) {
2191
+ case 'string':
2192
+ return raw as SettingValue<K>;
2193
+ case 'number':
2194
+ return Number(raw) as SettingValue<K>;
2195
+ case 'boolean':
2196
+ return (raw === 'true') as SettingValue<K>;
2197
+ case 'json':
2198
+ try {
2199
+ let result: unknown = JSON.parse(raw);
2200
+ // Unwrap multiply-escaped JSON strings caused by repeated
2201
+ // double-serialization (each push/pull cycle added a layer)
2202
+ while (typeof result === 'string') {
2203
+ try {
2204
+ result = JSON.parse(result);
2205
+ } catch {
2206
+ break;
2207
+ }
2208
+ }
2209
+ return result as SettingValue<K>;
2210
+ } catch {
2211
+ return this.getDefault(key);
2212
+ }
2213
+ }
2214
+ }
2215
+
2216
+ /**
2217
+ * Get the category for a setting key.
2218
+ * Returns undefined if the key is not recognized.
2219
+ */
2220
+ getCategory(key: string): SettingsCategory | undefined {
2221
+ if (key in this.entries) {
2222
+ return this.entries[key as SettingKey].category;
2223
+ }
2224
+ // Fallback: extract category from dot-prefix
2225
+ const dotIndex = key.indexOf('.');
2226
+ if (dotIndex > 0) {
2227
+ const prefix = key.substring(0, dotIndex);
2228
+ if (SETTINGS_CATEGORIES.includes(prefix as SettingsCategory)) {
2229
+ return prefix as SettingsCategory;
2230
+ }
2231
+ }
2232
+ return undefined;
2233
+ }
2234
+
2235
+ /**
2236
+ * Get a human-readable label for a setting key.
2237
+ * Strips the category prefix and formats the remaining path.
2238
+ * Example: 'lockScreen.inactivityTimeoutMinutes' → 'Inactivity Timeout Minutes'
2239
+ */
2240
+ getSettingLabel(key: string): string {
2241
+ const dotIndex = key.indexOf('.');
2242
+ const name = dotIndex > 0 ? key.substring(dotIndex + 1) : key;
2243
+ // Convert camelCase / dot-separated path to Title Case words
2244
+ return name
2245
+ .replace(/\./g, ' › ')
2246
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
2247
+ .replace(/^./, (c) => c.toUpperCase());
2248
+ }
2249
+ }
2250
+
2251
+ // ---------------------------------------------------------------------------
2252
+ // Snapshot parsing — parse a JSON settings snapshot into categorized groups
2253
+ // ---------------------------------------------------------------------------
2254
+
2255
+ export type ParsedSettingEntry = {
2256
+ /** The full setting key (e.g. 'lockScreen.inactivityTimeoutMinutes') */
2257
+ key: string;
2258
+ /** The setting name without category prefix (e.g. 'inactivityTimeoutMinutes') */
2259
+ name: string;
2260
+ /** Human-readable label (e.g. 'Inactivity Timeout Minutes') */
2261
+ label: string;
2262
+ /** i18n message key for the label (from SETTINGS_LABELS) */
2263
+ labelKey?: string;
2264
+ /** i18n message key for a description (from SETTINGS_LABELS) */
2265
+ descriptionKey?: string;
2266
+ /** The setting value */
2267
+ value: unknown;
2268
+ /** UI component type for rendering this setting */
2269
+ uiType: SettingUiType;
2270
+ /** Available options for SELECT-type settings */
2271
+ options?: readonly SettingOption[];
2272
+ /** Slider configuration for SLIDER-type settings */
2273
+ sliderConfig?: SliderConfig;
2274
+ /** Only show this setting for enrolled/kiosk devices (undefined = always) */
2275
+ appMode?: 'ENROLLED';
2276
+ /** Only show this setting for a specific calendar type (undefined = always) */
2277
+ calendarType?: CalendarType;
2278
+ /** Show this setting only when condition(s) are met */
2279
+ visibleWhen?: VisibilityRule;
2280
+ /** Disable (but still show) this setting when condition(s) are met */
2281
+ disabledWhen?: DisabledRule;
2282
+ };
2283
+
2284
+ export type ParsedSettingsGroup = {
2285
+ /** The category key */
2286
+ category: SettingsCategory;
2287
+ /** Human-readable category label */
2288
+ label: string;
2289
+ /** Settings in this category */
2290
+ settings: ParsedSettingEntry[];
2291
+ };
2292
+
2293
+ /**
2294
+ * Parse a settings JSON snapshot into categorized groups, optionally filtered
2295
+ * by calendar type. This is the single function both web and mobile should use
2296
+ * to display a settings snapshot.
2297
+ *
2298
+ * @param json The raw JSON string from the settings snapshot
2299
+ * @param calendarType Optional calendar type filter — hides irrelevant categories
2300
+ * @param registry Optional registry instance (uses defaultRegistry if omitted)
2301
+ */
2302
+ export function parseSettingsSnapshot(
2303
+ json: string | Record<string, unknown>,
2304
+ calendarType?: CalendarType,
2305
+ registry: SettingsRegistry = defaultRegistry,
2306
+ ): ParsedSettingsGroup[] {
2307
+ let parsed: Record<string, unknown>;
2308
+ if (typeof json === 'string') {
2309
+ try {
2310
+ parsed = JSON.parse(json);
2311
+ } catch {
2312
+ return [];
2313
+ }
2314
+ } else {
2315
+ parsed = json;
2316
+ }
2317
+
2318
+ const groups = new Map<SettingsCategory, ParsedSettingEntry[]>();
2319
+
2320
+ for (const [key, rawValue] of Object.entries(parsed)) {
2321
+ const category = registry.getCategory(key);
2322
+ if (!category) {
2323
+ continue;
2324
+ }
2325
+
2326
+ // Deserialize string values to their native types using the registry
2327
+ let value: unknown = rawValue;
2328
+ if (typeof rawValue === 'string' && key in registry.entries) {
2329
+ value = registry.deserialize(key as SettingKey, rawValue);
2330
+ }
2331
+
2332
+ const dotIndex = key.indexOf('.');
2333
+ const name = dotIndex > 0 ? key.substring(dotIndex + 1) : key;
2334
+
2335
+ if (!groups.has(category)) {
2336
+ groups.set(category, []);
2337
+ }
2338
+
2339
+ const labelDef = SETTINGS_LABELS[key];
2340
+ const entryDef =
2341
+ key in registry.entries ? registry.entries[key as SettingKey] : undefined;
2342
+ groups.get(category)!.push({
2343
+ key,
2344
+ name,
2345
+ label: registry.getSettingLabel(key),
2346
+ labelKey: labelDef?.labelKey,
2347
+ descriptionKey: labelDef?.descriptionKey,
2348
+ value,
2349
+ uiType: entryDef?.uiType ?? 'TEXT_INPUT',
2350
+ ...(entryDef?.options && {
2351
+ options: entryDef.options as readonly SettingOption[],
2352
+ }),
2353
+ ...(entryDef?.sliderConfig && { sliderConfig: entryDef.sliderConfig }),
2354
+ ...(entryDef?.appMode && { appMode: entryDef.appMode }),
2355
+ ...(entryDef?.calendarType && { calendarType: entryDef.calendarType }),
2356
+ ...(entryDef?.visibleWhen && { visibleWhen: entryDef.visibleWhen }),
2357
+ ...(entryDef?.disabledWhen && { disabledWhen: entryDef.disabledWhen }),
2358
+ });
2359
+ }
2360
+
2361
+ // Determine which categories to show
2362
+ const allowedCategories = calendarType
2363
+ ? getCategoriesForCalendarType(calendarType)
2364
+ : [...SETTINGS_CATEGORIES];
2365
+
2366
+ return allowedCategories
2367
+ .filter((cat) => groups.has(cat))
2368
+ .map((category) => ({
2369
+ category,
2370
+ label: CATEGORY_LABELS[category],
2371
+ settings: groups.get(category)!,
2372
+ }));
2373
+ }
2374
+
2375
+ /**
2376
+ * Format a setting value for display.
2377
+ * Handles booleans, numbers, strings, arrays, objects, and null/undefined.
2378
+ */
2379
+ export function formatSettingValue(value: unknown): string {
2380
+ if (value === null || value === undefined) {
2381
+ return '—';
2382
+ }
2383
+ if (typeof value === 'boolean') {
2384
+ return value ? 'true' : 'false';
2385
+ }
2386
+ if (typeof value === 'number') {
2387
+ return String(value);
2388
+ }
2389
+ if (typeof value === 'string') {
2390
+ return value || '""';
2391
+ }
2392
+ if (Array.isArray(value)) {
2393
+ return `[${value.length} items]`;
2394
+ }
2395
+ if (typeof value === 'object') {
2396
+ return JSON.stringify(value);
2397
+ }
2398
+ return String(value);
2399
+ }
2400
+
2401
+ /**
2402
+ * Serialize a settings object (with mixed native types) into a
2403
+ * string-values-only snapshot suitable for pushing to a mobile device.
2404
+ *
2405
+ * Uses the registry's `serialize()` method for known keys so that
2406
+ * booleans become "true"/"false", numbers become digit strings, etc.
2407
+ * Unknown keys are converted with `String(value)`.
2408
+ */
2409
+ export function serializeSettingsSnapshot(
2410
+ settings: Record<string, unknown>,
2411
+ registry: SettingsRegistry,
2412
+ ): Record<string, string> {
2413
+ const result: Record<string, string> = {};
2414
+ for (const [key, value] of Object.entries(settings)) {
2415
+ if (key in registry.entries) {
2416
+ result[key] = registry.serialize(key as SettingKey, value);
2417
+ } else {
2418
+ result[key] = String(value ?? '');
2419
+ }
2420
+ }
2421
+ return result;
2422
+ }
2423
+
2424
+ /**
2425
+ * Deserialize a settings snapshot (string values from the server/DB) into
2426
+ * native-typed values using the registry.
2427
+ *
2428
+ * This ensures local state always contains native types (boolean, number, etc.)
2429
+ * so that subsequent `serializeSettingsSnapshot` calls produce correct results.
2430
+ */
2431
+ export function deserializeSettingsSnapshot(
2432
+ snapshot: Record<string, unknown>,
2433
+ registry: SettingsRegistry = defaultRegistry,
2434
+ ): Record<string, unknown> {
2435
+ const result: Record<string, unknown> = {};
2436
+ for (const [key, value] of Object.entries(snapshot)) {
2437
+ if (typeof value === 'string' && key in registry.entries) {
2438
+ result[key] = registry.deserialize(key as SettingKey, value);
2439
+ } else {
2440
+ result[key] = value;
2441
+ }
2442
+ }
2443
+ return result;
2444
+ }
2445
+
2446
+ // ---------------------------------------------------------------------------
2447
+ // Default registry instance (non-enrolled, system theme, nb locale)
2448
+ // ---------------------------------------------------------------------------
2449
+
2450
+ export const defaultRegistry = new SettingsRegistry();
2451
+
2452
+ // ---------------------------------------------------------------------------
2453
+ // Factory function — returns raw entry map for local type derivation.
2454
+ // Consumers that need precise generic inference (e.g. useQuery<K>) can use
2455
+ // this to anchor types on a local const variable.
2456
+ // ---------------------------------------------------------------------------
2457
+
2458
+ export function createSettingsRegistry(config: Partial<RegistryConfig> = {}) {
2459
+ return buildEntries({ ...DEFAULT_REGISTRY_CONFIG, ...config });
2460
+ }