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