@focus8/settings-registry 0.8.6 → 0.8.8

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