@focus8/settings-registry 0.6.0 → 0.8.0

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/dist/index.js CHANGED
@@ -138,7 +138,7 @@ export const SETTINGS_GROUPS = [
138
138
  id: 'calendarType',
139
139
  labelKey: 'Settings.CalendarType',
140
140
  icon: 'ArrowLeftRight',
141
- keys: ['calendarView.type', 'calendarView.view'],
141
+ keys: ['calendarView.type'],
142
142
  },
143
143
  {
144
144
  id: 'calendarView',
@@ -147,9 +147,9 @@ export const SETTINGS_GROUPS = [
147
147
  keys: [
148
148
  'calendarView.showCalendarNames',
149
149
  'calendarView.splitView',
150
- 'calendarView.calendarColumns',
151
150
  'calendarView.dayViewZoom',
152
151
  'calendarView.weekViewZoom',
152
+ 'calendarView.calendarColumns',
153
153
  ],
154
154
  },
155
155
  {
@@ -163,9 +163,9 @@ export const SETTINGS_GROUPS = [
163
163
  labelKey: 'Settings.DateWeather',
164
164
  icon: 'CloudSun',
165
165
  keys: [
166
- 'calendarView.showWeatherOnEvents',
167
166
  'calendarView.showWeatherOnTimeline',
168
167
  'calendarView.weatherLocation',
168
+ 'calendarView.showWeatherOnEvents',
169
169
  ],
170
170
  },
171
171
  {
@@ -175,13 +175,13 @@ export const SETTINGS_GROUPS = [
175
175
  keys: [
176
176
  'sound.reminderVolume',
177
177
  'sound.mediaVolume',
178
- 'sound.alarmSound',
179
178
  'sound.reminderAlarmSound',
180
179
  'sound.reminderAlarmTimeout',
181
180
  'sound.allowCustomReminderSounds',
181
+ 'notification.',
182
182
  'sound.ttsEnabled',
183
183
  'sound.ttsRate',
184
- 'notification.',
184
+ 'sound.alarmSound',
185
185
  ],
186
186
  },
187
187
  {
@@ -189,17 +189,20 @@ export const SETTINGS_GROUPS = [
189
189
  labelKey: 'Settings.TimerTitle',
190
190
  icon: 'Timer',
191
191
  keys: [
192
- 'sound.timerVolume',
192
+ 'chronological.timer.',
193
+ 'timer.',
193
194
  'sound.timerAlarmSound',
194
195
  'sound.timerAlarmTimeout',
195
- 'timer.',
196
+ 'sound.timerVolume',
196
197
  ],
198
+ appMode: 'ENROLLED',
197
199
  },
198
200
  {
199
201
  id: 'lockScreen',
200
202
  labelKey: 'Settings.LockScreen',
201
203
  icon: 'Lock',
202
204
  keys: ['lockScreen.'],
205
+ appMode: 'ENROLLED',
203
206
  },
204
207
  {
205
208
  id: 'touch',
@@ -239,6 +242,9 @@ export const EXCLUDED_DEVICE_SETTINGS = new Set([
239
242
  'device.id',
240
243
  'device.devMenuEnabled',
241
244
  'device.authWarningDismissTtlDays',
245
+ 'notification.enabled',
246
+ 'notification.notifyAllCalendars',
247
+ 'notification.enabledCalendarIds',
242
248
  'notification.hasBeenPrompted',
243
249
  ]);
244
250
  /**
@@ -464,6 +470,31 @@ export const SETTINGS_LABELS = {
464
470
  function matchesGroup(key, group) {
465
471
  return group.keys.some((matcher) => key === matcher || (matcher.endsWith('.') && key.startsWith(matcher)));
466
472
  }
473
+ /** Return the index of the first matching key pattern in the group, for sorting. */
474
+ function groupKeyIndex(key, keys) {
475
+ for (let i = 0; i < keys.length; i++) {
476
+ const matcher = keys[i];
477
+ if (key === matcher || (matcher.endsWith('.') && key.startsWith(matcher))) {
478
+ return i;
479
+ }
480
+ }
481
+ return keys.length;
482
+ }
483
+ /**
484
+ * Lazy-initialized map from setting key → declaration index in buildEntries.
485
+ * Used as a tiebreaker when multiple keys share the same prefix group index.
486
+ */
487
+ let _registryKeyOrder;
488
+ function registryKeyOrder() {
489
+ if (!_registryKeyOrder) {
490
+ _registryKeyOrder = new Map();
491
+ const keys = Object.keys(buildEntries(DEFAULT_REGISTRY_CONFIG));
492
+ for (let i = 0; i < keys.length; i++) {
493
+ _registryKeyOrder.set(keys[i], i);
494
+ }
495
+ }
496
+ return _registryKeyOrder;
497
+ }
467
498
  /**
468
499
  * Group parsed setting entries into user-facing groups matching the mobile
469
500
  * app's settings menu structure.
@@ -472,9 +503,9 @@ function matchesGroup(key, group) {
472
503
  * buckets them into the standard groups, filtering by calendar type and
473
504
  * excluded keys.
474
505
  */
475
- export function groupSettingsForDevice(allSettings, calendarType) {
476
- // Filter excluded
477
- const settings = allSettings.filter((s) => !EXCLUDED_DEVICE_SETTINGS.has(s.key));
506
+ export function groupSettingsForDevice(allSettings, calendarType, appMode) {
507
+ // Filter excluded and hidden settings
508
+ const settings = allSettings.filter((s) => !EXCLUDED_DEVICE_SETTINGS.has(s.key) && s.uiType !== 'HIDDEN');
478
509
  // Calendar type filtering for event form keys
479
510
  const isChronological = calendarType === 'chronological';
480
511
  const filteredSettings = settings.filter((s) => {
@@ -484,6 +515,14 @@ export function groupSettingsForDevice(allSettings, calendarType) {
484
515
  if (!isChronological && s.key.startsWith('chronologicalEventForm.')) {
485
516
  return false;
486
517
  }
518
+ // Per-setting calendar type restriction
519
+ if (s.calendarType && s.calendarType !== calendarType) {
520
+ return false;
521
+ }
522
+ // Per-setting app mode restriction
523
+ if (s.appMode && s.appMode !== appMode) {
524
+ return false;
525
+ }
487
526
  return true;
488
527
  });
489
528
  const claimed = new Set();
@@ -493,10 +532,25 @@ export function groupSettingsForDevice(allSettings, calendarType) {
493
532
  if (group.calendarType && group.calendarType !== calendarType) {
494
533
  continue;
495
534
  }
535
+ // Skip groups not relevant for current app mode
536
+ if (group.appMode && group.appMode !== appMode) {
537
+ continue;
538
+ }
496
539
  const matched = filteredSettings.filter((s) => !claimed.has(s.key) && matchesGroup(s.key, group));
497
540
  if (matched.length === 0) {
498
541
  continue;
499
542
  }
543
+ // Sort matched settings by the order defined in group.keys,
544
+ // with registry declaration order as tiebreaker for prefix matches
545
+ const order = registryKeyOrder();
546
+ matched.sort((a, b) => {
547
+ const aIdx = groupKeyIndex(a.key, group.keys);
548
+ const bIdx = groupKeyIndex(b.key, group.keys);
549
+ if (aIdx !== bIdx) {
550
+ return aIdx - bIdx;
551
+ }
552
+ return (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity);
553
+ });
500
554
  for (const s of matched) {
501
555
  claimed.add(s.key);
502
556
  }
@@ -516,10 +570,57 @@ export const DEFAULT_REGISTRY_CONFIG = {
516
570
  defaultLocale: 'nb',
517
571
  };
518
572
  // ---------------------------------------------------------------------------
573
+ // Shared option arrays (reused across multiple settings)
574
+ // ---------------------------------------------------------------------------
575
+ const ALARM_SOUND_OPTIONS = [
576
+ { value: 'none', labelKey: 'Settings.Option.AlarmSound.None' },
577
+ { value: 'alarm1', labelKey: 'Settings.Option.AlarmSound.Alarm1' },
578
+ { value: 'alarm2', labelKey: 'Settings.Option.AlarmSound.Alarm2' },
579
+ { value: 'alarm3', labelKey: 'Settings.Option.AlarmSound.Alarm3' },
580
+ { value: 'alarm4', labelKey: 'Settings.Option.AlarmSound.Alarm4' },
581
+ { value: 'alarm5', labelKey: 'Settings.Option.AlarmSound.Alarm5' },
582
+ { value: 'alarm6', labelKey: 'Settings.Option.AlarmSound.Alarm6' },
583
+ { value: 'alarm7', labelKey: 'Settings.Option.AlarmSound.Alarm7' },
584
+ { value: 'alarm8', labelKey: 'Settings.Option.AlarmSound.Alarm8' },
585
+ { value: 'alarm9', labelKey: 'Settings.Option.AlarmSound.Alarm9' },
586
+ ];
587
+ const ALARM_TIMEOUT_OPTIONS = [
588
+ { value: 1, labelKey: 'Settings.Option.AlarmTimeout.1min' },
589
+ { value: 2, labelKey: 'Settings.Option.AlarmTimeout.2min' },
590
+ { value: 3, labelKey: 'Settings.Option.AlarmTimeout.3min' },
591
+ { value: 5, labelKey: 'Settings.Option.AlarmTimeout.5min' },
592
+ { value: 10, labelKey: 'Settings.Option.AlarmTimeout.10min' },
593
+ ];
594
+ const DAY_VIEW_ZOOM_OPTIONS = [
595
+ { value: 15, labelKey: 'Settings.Option.DayViewZoom.15min' },
596
+ { value: 30, labelKey: 'Settings.Option.DayViewZoom.30min' },
597
+ { value: 60, labelKey: 'Settings.Option.DayViewZoom.1hour' },
598
+ ];
599
+ const TIME_OF_DAY_SLIDER = { min: 0, max: 23, step: 1 };
600
+ // ---------------------------------------------------------------------------
519
601
  // Helper
520
602
  // ---------------------------------------------------------------------------
521
- function def(category, type, defaultValue, sync = true) {
522
- return { category, type, default: defaultValue, sync };
603
+ function def(category, type, defaultValue, uiType, extra) {
604
+ const result = {
605
+ category,
606
+ type,
607
+ default: defaultValue,
608
+ sync: extra?.sync ?? true,
609
+ uiType,
610
+ };
611
+ if (extra?.options) {
612
+ result.options = extra.options;
613
+ }
614
+ if (extra?.sliderConfig) {
615
+ result.sliderConfig = extra.sliderConfig;
616
+ }
617
+ if (extra?.appMode) {
618
+ result.appMode = extra.appMode;
619
+ }
620
+ if (extra?.calendarType) {
621
+ result.calendarType = extra.calendarType;
622
+ }
623
+ return result;
523
624
  }
524
625
  // ---------------------------------------------------------------------------
525
626
  // Entry builder (internal — used by SettingsRegistry class and factory)
@@ -529,159 +630,277 @@ function buildEntries(config) {
529
630
  // ═══════════════════════════════════════════════════════════════════════
530
631
  // Appearance
531
632
  // ═══════════════════════════════════════════════════════════════════════
532
- 'appearance.theme': def('appearance', 'string', config.defaultTheme),
533
- 'appearance.clockType': def('appearance', 'string', 'digital'),
534
- 'appearance.enableDayColors': def('appearance', 'boolean', false),
633
+ 'appearance.theme': def('appearance', 'string', config.defaultTheme, 'CUSTOM_THEME_PICKER', {
634
+ options: [
635
+ { value: 'light', labelKey: 'Settings.Option.Theme.Light' },
636
+ { value: 'dark', labelKey: 'Settings.Option.Theme.Dark' },
637
+ { value: 'system', labelKey: 'Settings.Option.Theme.System' },
638
+ ],
639
+ }),
640
+ 'appearance.clockType': def('appearance', 'string', 'digital', 'CUSTOM_CLOCK_TYPE', {
641
+ options: [
642
+ { value: 'digital', labelKey: 'Settings.Option.ClockType.Digital' },
643
+ { value: 'analog', labelKey: 'Settings.Option.ClockType.Analog' },
644
+ ],
645
+ appMode: 'ENROLLED',
646
+ }),
647
+ 'appearance.enableDayColors': def('appearance', 'boolean', false, 'TOGGLE', { calendarType: 'chronological' }),
535
648
  // ═══════════════════════════════════════════════════════════════════════
536
649
  // Calendar view
537
650
  // ═══════════════════════════════════════════════════════════════════════
538
- 'calendarView.type': def('calendarView', 'string', 'time-based'),
539
- 'calendarView.view': def('calendarView', 'string', 'day'),
540
- 'calendarView.dayViewZoom': def('calendarView', 'number', 60),
541
- 'calendarView.weekViewZoom': def('calendarView', 'number', 60),
542
- 'calendarView.splitView': def('calendarView', 'boolean', false),
543
- 'calendarView.showCalendarNames': def('calendarView', 'boolean', true),
544
- 'calendarView.calendarColumns': def('calendarView', 'json', []),
545
- 'calendarView.autoReturnToTodayEnabled': def('calendarView', 'boolean', config.isEnrolled),
546
- 'calendarView.autoReturnToTodayTimeoutSeconds': def('calendarView', 'number', 300),
547
- 'calendarView.showWeatherOnEvents': def('calendarView', 'boolean', false),
548
- 'calendarView.showWeatherOnTimeline': def('calendarView', 'boolean', false),
549
- 'calendarView.weatherLocation': def('calendarView', 'json', null),
651
+ 'calendarView.type': def('calendarView', 'string', 'time-based', 'CUSTOM_CALENDAR_TYPE', {
652
+ options: [
653
+ {
654
+ value: 'chronological',
655
+ labelKey: 'Settings.Option.CalendarType.Chronological',
656
+ },
657
+ {
658
+ value: 'time-based',
659
+ labelKey: 'Settings.Option.CalendarType.TimeBased',
660
+ },
661
+ ],
662
+ }),
663
+ 'calendarView.view': def('calendarView', 'string', 'day', 'SELECT', {
664
+ options: [
665
+ { value: 'day', labelKey: 'Settings.Option.CalendarView.Day' },
666
+ {
667
+ value: '3-days',
668
+ labelKey: 'Settings.Option.CalendarView.3Days',
669
+ },
670
+ {
671
+ value: '5-days',
672
+ labelKey: 'Settings.Option.CalendarView.5Days',
673
+ },
674
+ {
675
+ value: '7-days',
676
+ labelKey: 'Settings.Option.CalendarView.7Days',
677
+ },
678
+ { value: 'week', labelKey: 'Settings.Option.CalendarView.Week' },
679
+ {
680
+ value: 'month',
681
+ labelKey: 'Settings.Option.CalendarView.Month',
682
+ },
683
+ {
684
+ value: 'overview',
685
+ labelKey: 'Settings.Option.CalendarView.Overview',
686
+ },
687
+ ],
688
+ }),
689
+ 'calendarView.dayViewZoom': def('calendarView', 'number', 60, 'SELECT', { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' }),
690
+ 'calendarView.weekViewZoom': def('calendarView', 'number', 60, 'SELECT', { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' }),
691
+ 'calendarView.splitView': def('calendarView', 'boolean', false, 'TOGGLE'),
692
+ 'calendarView.showCalendarNames': def('calendarView', 'boolean', true, 'TOGGLE'),
693
+ 'calendarView.calendarColumns': def('calendarView', 'json', [], 'CUSTOM_SPLIT_VIEW_CONFIG'),
694
+ 'calendarView.autoReturnToTodayEnabled': def('calendarView', 'boolean', config.isEnrolled, 'TOGGLE'),
695
+ 'calendarView.autoReturnToTodayTimeoutSeconds': def('calendarView', 'number', 300, 'SLIDER', { sliderConfig: { min: 30, max: 600, step: 30 } }),
696
+ 'calendarView.showWeatherOnEvents': def('calendarView', 'boolean', false, 'TOGGLE'),
697
+ 'calendarView.showWeatherOnTimeline': def('calendarView', 'boolean', false, 'TOGGLE'),
698
+ 'calendarView.weatherLocation': def('calendarView', 'json', null, 'CUSTOM_WEATHER_LOCATION'),
550
699
  // ═══════════════════════════════════════════════════════════════════════
551
700
  // Event form field visibility (time-based)
552
701
  // ═══════════════════════════════════════════════════════════════════════
553
- 'eventForm.recurrence': def('eventForm', 'boolean', true),
554
- 'eventForm.reminders': def('eventForm', 'boolean', true),
555
- 'eventForm.emailReminders': def('eventForm', 'boolean', false),
556
- 'eventForm.location': def('eventForm', 'boolean', true),
557
- 'eventForm.travelTime': def('eventForm', 'boolean', false),
558
- 'eventForm.description': def('eventForm', 'boolean', true),
559
- 'eventForm.checklist': def('eventForm', 'boolean', true),
560
- 'eventForm.images': def('eventForm', 'boolean', false),
561
- 'eventForm.audioClips': def('eventForm', 'boolean', false),
562
- 'eventForm.notificationReceivers': def('eventForm', 'boolean', true),
563
- 'eventForm.visibility': def('eventForm', 'boolean', false),
702
+ 'eventForm.recurrence': def('eventForm', 'boolean', true, 'TOGGLE'),
703
+ 'eventForm.location': def('eventForm', 'boolean', true, 'TOGGLE'),
704
+ 'eventForm.travelTime': def('eventForm', 'boolean', false, 'TOGGLE'),
705
+ 'eventForm.reminders': def('eventForm', 'boolean', true, 'TOGGLE'),
706
+ 'eventForm.emailReminders': def('eventForm', 'boolean', false, 'TOGGLE'),
707
+ 'eventForm.description': def('eventForm', 'boolean', true, 'TOGGLE'),
708
+ 'eventForm.checklist': def('eventForm', 'boolean', true, 'TOGGLE'),
709
+ 'eventForm.images': def('eventForm', 'boolean', false, 'TOGGLE'),
710
+ 'eventForm.audioClips': def('eventForm', 'boolean', false, 'TOGGLE'),
711
+ 'eventForm.notificationReceivers': def('eventForm', 'boolean', true, 'TOGGLE'),
712
+ 'eventForm.visibility': def('eventForm', 'boolean', false, 'TOGGLE'),
564
713
  // ═══════════════════════════════════════════════════════════════════════
565
714
  // Sound & alerts
566
715
  // ═══════════════════════════════════════════════════════════════════════
567
- 'sound.timerVolume': def('sound', 'number', 0.5),
568
- 'sound.reminderVolume': def('sound', 'number', 0.5),
569
- 'sound.mediaVolume': def('sound', 'number', 0.5),
570
- 'sound.alarmSound': def('sound', 'string', 'alarm1'),
571
- 'sound.reminderAlarmSound': def('sound', 'string', 'alarm1'),
572
- 'sound.timerAlarmSound': def('sound', 'string', 'alarm1'),
573
- 'sound.timerAlarmTimeout': def('sound', 'number', 3),
574
- 'sound.reminderAlarmTimeout': def('sound', 'number', 3),
575
- 'sound.allowCustomReminderSounds': def('sound', 'boolean', false),
576
- 'sound.ttsEnabled': def('sound', 'boolean', config.isEnrolled),
577
- 'sound.ttsRate': def('sound', 'number', 1.0),
716
+ 'sound.timerVolume': def('sound', 'number', 0.5, 'VOLUME_SLIDER'),
717
+ 'sound.reminderVolume': def('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
718
+ 'sound.mediaVolume': def('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
719
+ 'sound.alarmSound': def('sound', 'string', 'alarm1', 'SELECT', { options: ALARM_SOUND_OPTIONS }),
720
+ 'sound.reminderAlarmSound': def('sound', 'string', 'alarm1', 'SELECT', { options: ALARM_SOUND_OPTIONS }),
721
+ 'sound.timerAlarmSound': def('sound', 'string', 'alarm1', 'SELECT', { options: ALARM_SOUND_OPTIONS }),
722
+ 'sound.timerAlarmTimeout': def('sound', 'number', 3, 'SELECT', { options: ALARM_TIMEOUT_OPTIONS }),
723
+ 'sound.reminderAlarmTimeout': def('sound', 'number', 3, 'SELECT', { options: ALARM_TIMEOUT_OPTIONS }),
724
+ 'sound.allowCustomReminderSounds': def('sound', 'boolean', false, 'TOGGLE'),
725
+ 'sound.ttsEnabled': def('sound', 'boolean', config.isEnrolled, 'TOGGLE', { appMode: 'ENROLLED' }),
726
+ 'sound.ttsRate': def('sound', 'number', 1.0, 'SLIDER', { sliderConfig: { min: 0.5, max: 2, step: 0.1 }, appMode: 'ENROLLED' }),
578
727
  // ═══════════════════════════════════════════════════════════════════════
579
728
  // Timer
580
729
  // ═══════════════════════════════════════════════════════════════════════
581
- 'timer.showTimeRemaining': def('timer', 'boolean', true),
582
- 'timer.showEndTime': def('timer', 'boolean', true),
583
- 'timer.showRestartButton': def('timer', 'boolean', true),
584
- 'timer.showPauseButton': def('timer', 'boolean', true),
730
+ 'timer.showTimeRemaining': def('timer', 'boolean', true, 'TOGGLE'),
731
+ 'timer.showEndTime': def('timer', 'boolean', true, 'TOGGLE'),
732
+ 'timer.showRestartButton': def('timer', 'boolean', true, 'TOGGLE'),
733
+ 'timer.showPauseButton': def('timer', 'boolean', true, 'TOGGLE'),
585
734
  // ═══════════════════════════════════════════════════════════════════════
586
735
  // Lock screen
587
736
  // ═══════════════════════════════════════════════════════════════════════
588
- 'lockScreen.pin': def('lockScreen', 'string', ''),
589
- 'lockScreen.inactivityLockEnabled': def('lockScreen', 'boolean', false),
590
- 'lockScreen.inactivityTimeoutMinutes': def('lockScreen', 'number', 5),
591
- 'lockScreen.clockDisplay': def('lockScreen', 'string', 'digital'),
592
- 'lockScreen.showDate': def('lockScreen', 'boolean', true),
593
- 'lockScreen.showHourNumbers': def('lockScreen', 'boolean', true),
594
- 'lockScreen.imageMode': def('lockScreen', 'string', 'none'),
595
- 'lockScreen.backgroundImage': def('lockScreen', 'json', null),
596
- 'lockScreen.photoFrameImages': def('lockScreen', 'json', []),
597
- 'lockScreen.photoFrameIntervalSeconds': def('lockScreen', 'number', 60),
737
+ 'lockScreen.inactivityLockEnabled': def('lockScreen', 'boolean', false, 'TOGGLE'),
738
+ 'lockScreen.inactivityTimeoutMinutes': def('lockScreen', 'number', 5, 'SELECT', {
739
+ options: [
740
+ { value: 1, labelKey: 'Settings.Option.InactivityTimeout.1min' },
741
+ { value: 5, labelKey: 'Settings.Option.InactivityTimeout.5min' },
742
+ { value: 10, labelKey: 'Settings.Option.InactivityTimeout.10min' },
743
+ { value: 15, labelKey: 'Settings.Option.InactivityTimeout.15min' },
744
+ { value: 30, labelKey: 'Settings.Option.InactivityTimeout.30min' },
745
+ { value: 45, labelKey: 'Settings.Option.InactivityTimeout.45min' },
746
+ ],
747
+ }),
748
+ 'lockScreen.pin': def('lockScreen', 'string', '', 'PIN_INPUT'),
749
+ 'lockScreen.clockDisplay': def('lockScreen', 'string', 'digital', 'SELECT', {
750
+ options: [
751
+ { value: 'none', labelKey: 'Settings.Option.ClockDisplay.None' },
752
+ {
753
+ value: 'digital',
754
+ labelKey: 'Settings.Option.ClockDisplay.Digital',
755
+ },
756
+ {
757
+ value: 'analog',
758
+ labelKey: 'Settings.Option.ClockDisplay.Analog',
759
+ },
760
+ ],
761
+ }),
762
+ 'lockScreen.showHourNumbers': def('lockScreen', 'boolean', true, 'TOGGLE'),
763
+ 'lockScreen.showDate': def('lockScreen', 'boolean', true, 'TOGGLE'),
764
+ 'lockScreen.imageMode': def('lockScreen', 'string', 'none', 'SELECT', {
765
+ options: [
766
+ { value: 'none', labelKey: 'Settings.Option.ImageMode.None' },
767
+ {
768
+ value: 'background',
769
+ labelKey: 'Settings.Option.ImageMode.Background',
770
+ },
771
+ {
772
+ value: 'photoFrame',
773
+ labelKey: 'Settings.Option.ImageMode.PhotoFrame',
774
+ },
775
+ ],
776
+ }),
777
+ 'lockScreen.backgroundImage': def('lockScreen', 'json', null, 'CUSTOM_IMAGE'),
778
+ 'lockScreen.photoFrameIntervalSeconds': def('lockScreen', 'number', 60, 'SELECT', {
779
+ options: [
780
+ { value: 30, labelKey: 'Settings.Option.PhotoFrameInterval.30sec' },
781
+ { value: 60, labelKey: 'Settings.Option.PhotoFrameInterval.1min' },
782
+ { value: 120, labelKey: 'Settings.Option.PhotoFrameInterval.2min' },
783
+ { value: 300, labelKey: 'Settings.Option.PhotoFrameInterval.5min' },
784
+ ],
785
+ }),
786
+ 'lockScreen.photoFrameImages': def('lockScreen', 'json', [], 'CUSTOM_IMAGE_ARRAY'),
598
787
  // ═══════════════════════════════════════════════════════════════════════
599
788
  // Touch / gestures
600
789
  // ═══════════════════════════════════════════════════════════════════════
601
- 'touch.enableTapToCreate': def('touch', 'boolean', false),
602
- 'touch.enableDragDrop': def('touch', 'boolean', false),
790
+ 'touch.enableTapToCreate': def('touch', 'boolean', false, 'TOGGLE'),
791
+ 'touch.enableDragDrop': def('touch', 'boolean', false, 'TOGGLE'),
603
792
  // ═══════════════════════════════════════════════════════════════════════
604
793
  // Device (not synced unless noted)
605
794
  // ═══════════════════════════════════════════════════════════════════════
606
- 'device.id': def('device', 'string', '', false),
607
- 'device.timePickerMode': def('device', 'string', 'dials'),
608
- 'device.devMenuEnabled': def('device', 'boolean', false, false),
609
- 'device.authWarningDismissTtlDays': def('device', 'number', 3, false),
795
+ 'device.id': def('device', 'string', '', 'HIDDEN', { sync: false }),
796
+ 'device.timePickerMode': def('device', 'string', 'dials', 'SELECT', {
797
+ options: [
798
+ { value: 'dials', labelKey: 'Settings.Option.TimePickerMode.Dials' },
799
+ {
800
+ value: 'keypad',
801
+ labelKey: 'Settings.Option.TimePickerMode.Keypad',
802
+ },
803
+ ],
804
+ }),
805
+ 'device.devMenuEnabled': def('device', 'boolean', false, 'HIDDEN', { sync: false }),
806
+ 'device.authWarningDismissTtlDays': def('device', 'number', 3, 'HIDDEN', { sync: false }),
610
807
  // ═══════════════════════════════════════════════════════════════════════
611
808
  // Language
612
809
  // ═══════════════════════════════════════════════════════════════════════
613
- 'language.locale': def('language', 'string', config.defaultLocale),
810
+ 'language.locale': def('language', 'string', config.defaultLocale, 'CUSTOM_LANGUAGE_PICKER', {
811
+ options: [
812
+ { value: 'nb', labelKey: 'Settings.Option.Language.Norwegian' },
813
+ { value: 'en', labelKey: 'Settings.Option.Language.English' },
814
+ ],
815
+ }),
614
816
  // ═══════════════════════════════════════════════════════════════════════
615
817
  // Notifications
616
818
  // ═══════════════════════════════════════════════════════════════════════
617
- 'notification.enabled': def('notification', 'boolean', false),
618
- 'notification.notifyAllCalendars': def('notification', 'boolean', true),
619
- 'notification.enabledCalendarIds': def('notification', 'json', []),
620
- 'notification.hasBeenPrompted': def('notification', 'boolean', false, false),
819
+ 'notification.enabled': def('notification', 'boolean', false, 'TOGGLE'),
820
+ 'notification.notifyAllCalendars': def('notification', 'boolean', true, 'TOGGLE'),
821
+ 'notification.enabledCalendarIds': def('notification', 'json', [], 'CUSTOM_CALENDAR_IDS'),
822
+ 'notification.hasBeenPrompted': def('notification', 'boolean', false, 'HIDDEN', { sync: false }),
621
823
  // ═══════════════════════════════════════════════════════════════════════
622
824
  // Chronological features (header, footer, menu, quick settings, timer)
623
825
  // ═══════════════════════════════════════════════════════════════════════
624
826
  // Header
625
- 'chronological.header.showNavigationArrows': def('chronological', 'boolean', true),
626
- 'chronological.header.showClock': def('chronological', 'boolean', true),
627
- 'chronological.header.showCurrentYearInDate': def('chronological', 'boolean', false),
628
- 'chronological.header.showTimeOfDay': def('chronological', 'boolean', false),
827
+ 'chronological.header.showNavigationArrows': def('chronological', 'boolean', true, 'TOGGLE'),
828
+ 'chronological.header.showCurrentYearInDate': def('chronological', 'boolean', false, 'TOGGLE'),
829
+ 'chronological.header.showClock': def('chronological', 'boolean', true, 'TOGGLE'),
830
+ 'chronological.header.showTimeOfDay': def('chronological', 'boolean', false, 'TOGGLE'),
629
831
  // Footer
630
- 'chronological.footer.showMenuButton': def('chronological', 'boolean', true),
631
- 'chronological.footer.showViewSwitcherDay': def('chronological', 'boolean', true),
632
- 'chronological.footer.showViewSwitcherWeek': def('chronological', 'boolean', true),
633
- 'chronological.footer.showViewSwitcherMonth': def('chronological', 'boolean', true),
634
- 'chronological.footer.showTimerButton': def('chronological', 'boolean', true),
635
- 'chronological.footer.showNewEventButton': def('chronological', 'boolean', true),
636
- 'chronological.footer.showSettingsButton': def('chronological', 'boolean', true),
832
+ 'chronological.footer.showMenuButton': def('chronological', 'boolean', true, 'TOGGLE'),
833
+ 'chronological.footer.showViewSwitcherDay': def('chronological', 'boolean', true, 'TOGGLE'),
834
+ 'chronological.footer.showViewSwitcherWeek': def('chronological', 'boolean', true, 'TOGGLE'),
835
+ 'chronological.footer.showViewSwitcherMonth': def('chronological', 'boolean', true, 'TOGGLE'),
836
+ 'chronological.footer.showTimerButton': def('chronological', 'boolean', true, 'TOGGLE'),
837
+ 'chronological.footer.showNewEventButton': def('chronological', 'boolean', true, 'TOGGLE'),
838
+ 'chronological.footer.showSettingsButton': def('chronological', 'boolean', true, 'TOGGLE'),
637
839
  // Timer features
638
- 'chronological.timer.showNewCountdown': def('chronological', 'boolean', true),
639
- 'chronological.timer.showFromTemplate': def('chronological', 'boolean', true),
640
- 'chronological.timer.showEditTemplate': def('chronological', 'boolean', true),
641
- 'chronological.timer.showDeleteTemplate': def('chronological', 'boolean', true),
642
- 'chronological.timer.showAddTemplate': def('chronological', 'boolean', true),
840
+ 'chronological.timer.showNewCountdown': def('chronological', 'boolean', true, 'TOGGLE'),
841
+ 'chronological.timer.showFromTemplate': def('chronological', 'boolean', true, 'TOGGLE'),
842
+ 'chronological.timer.showAddTemplate': def('chronological', 'boolean', true, 'TOGGLE'),
843
+ 'chronological.timer.showEditTemplate': def('chronological', 'boolean', true, 'TOGGLE'),
844
+ 'chronological.timer.showDeleteTemplate': def('chronological', 'boolean', true, 'TOGGLE'),
643
845
  // Menu
644
- 'chronological.menu.showSettingsButton': def('chronological', 'boolean', true),
846
+ 'chronological.menu.showSettingsButton': def('chronological', 'boolean', true, 'TOGGLE'),
645
847
  // Quick settings
646
- 'chronological.quickSettings.showTimerVolume': def('chronological', 'boolean', true),
647
- 'chronological.quickSettings.showReminderVolume': def('chronological', 'boolean', true),
648
- 'chronological.quickSettings.showMediaVolume': def('chronological', 'boolean', true),
649
- 'chronological.quickSettings.showBrightness': def('chronological', 'boolean', true),
650
- 'chronological.quickSettings.showLockScreen': def('chronological', 'boolean', true),
848
+ 'chronological.quickSettings.showTimerVolume': def('chronological', 'boolean', true, 'TOGGLE'),
849
+ 'chronological.quickSettings.showReminderVolume': def('chronological', 'boolean', true, 'TOGGLE'),
850
+ 'chronological.quickSettings.showMediaVolume': def('chronological', 'boolean', true, 'TOGGLE'),
851
+ 'chronological.quickSettings.showBrightness': def('chronological', 'boolean', true, 'TOGGLE'),
852
+ 'chronological.quickSettings.showLockScreen': def('chronological', 'boolean', true, 'TOGGLE'),
651
853
  // Time-of-day periods
652
- 'chronological.timeOfDay.morningStart': def('chronological', 'number', 6),
653
- 'chronological.timeOfDay.forenoonStart': def('chronological', 'number', 9),
654
- 'chronological.timeOfDay.afternoonStart': def('chronological', 'number', 12),
655
- 'chronological.timeOfDay.eveningStart': def('chronological', 'number', 18),
656
- 'chronological.timeOfDay.nightStart': def('chronological', 'number', 0),
854
+ 'chronological.timeOfDay.morningStart': def('chronological', 'number', 6, 'SLIDER', { sliderConfig: TIME_OF_DAY_SLIDER }),
855
+ 'chronological.timeOfDay.forenoonStart': def('chronological', 'number', 9, 'SLIDER', { sliderConfig: TIME_OF_DAY_SLIDER }),
856
+ 'chronological.timeOfDay.afternoonStart': def('chronological', 'number', 12, 'SLIDER', { sliderConfig: TIME_OF_DAY_SLIDER }),
857
+ 'chronological.timeOfDay.eveningStart': def('chronological', 'number', 18, 'SLIDER', { sliderConfig: TIME_OF_DAY_SLIDER }),
858
+ 'chronological.timeOfDay.nightStart': def('chronological', 'number', 0, 'SLIDER', { sliderConfig: TIME_OF_DAY_SLIDER }),
657
859
  // ═══════════════════════════════════════════════════════════════════════
658
860
  // Chronological event form
659
861
  // ═══════════════════════════════════════════════════════════════════════
660
- // Toggleable fields
661
- 'chronologicalEventForm.field.description': def('chronologicalEventForm', 'boolean', true),
662
- 'chronologicalEventForm.field.recurrence': def('chronologicalEventForm', 'boolean', true),
663
- 'chronologicalEventForm.field.acknowledge': def('chronologicalEventForm', 'boolean', true),
664
- 'chronologicalEventForm.field.checklist': def('chronologicalEventForm', 'boolean', true),
665
- 'chronologicalEventForm.field.extraImages': def('chronologicalEventForm', 'boolean', true),
666
- 'chronologicalEventForm.field.reminders': def('chronologicalEventForm', 'boolean', true),
667
- 'chronologicalEventForm.field.audioClips': def('chronologicalEventForm', 'boolean', true),
668
862
  // Fixed fields
669
- 'chronologicalEventForm.fixedField.allDay': def('chronologicalEventForm', 'boolean', true),
670
- 'chronologicalEventForm.fixedField.endTime': def('chronologicalEventForm', 'boolean', true),
671
- 'chronologicalEventForm.fixedField.visibility': def('chronologicalEventForm', 'boolean', true),
863
+ 'chronologicalEventForm.fixedField.allDay': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
864
+ 'chronologicalEventForm.fixedField.endTime': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
865
+ 'chronologicalEventForm.fixedField.visibility': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
866
+ // Toggleable fields
867
+ 'chronologicalEventForm.field.acknowledge': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
868
+ 'chronologicalEventForm.field.description': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
869
+ 'chronologicalEventForm.field.recurrence': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
870
+ 'chronologicalEventForm.field.checklist': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
871
+ 'chronologicalEventForm.field.extraImages': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
872
+ 'chronologicalEventForm.field.reminders': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
873
+ 'chronologicalEventForm.field.audioClips': def('chronologicalEventForm', 'boolean', true, 'TOGGLE'),
672
874
  // Suggest end time
673
- 'chronologicalEventForm.suggestEndTime.enabled': def('chronologicalEventForm', 'boolean', false),
674
- 'chronologicalEventForm.suggestEndTime.value': def('chronologicalEventForm', 'number', 30),
675
- 'chronologicalEventForm.suggestEndTime.unit': def('chronologicalEventForm', 'string', 'minutes'),
875
+ 'chronologicalEventForm.suggestEndTime.enabled': def('chronologicalEventForm', 'boolean', false, 'TOGGLE'),
876
+ 'chronologicalEventForm.suggestEndTime.value': def('chronologicalEventForm', 'number', 30, 'SLIDER', { sliderConfig: { min: 5, max: 480, step: 5 } }),
877
+ 'chronologicalEventForm.suggestEndTime.unit': def('chronologicalEventForm', 'string', 'minutes', 'SELECT', {
878
+ options: [
879
+ {
880
+ value: 'minutes',
881
+ labelKey: 'Settings.Option.SuggestEndTimeUnit.Minutes',
882
+ },
883
+ {
884
+ value: 'hours',
885
+ labelKey: 'Settings.Option.SuggestEndTimeUnit.Hours',
886
+ },
887
+ ],
888
+ }),
676
889
  // Default visibility
677
- 'chronologicalEventForm.defaultVisibility': def('chronologicalEventForm', 'string', 'Private'),
890
+ 'chronologicalEventForm.defaultVisibility': def('chronologicalEventForm', 'string', 'Private', 'SELECT', {
891
+ options: [
892
+ { value: 'Public', labelKey: 'Settings.Option.Visibility.Public' },
893
+ { value: 'Private', labelKey: 'Settings.Option.Visibility.Private' },
894
+ { value: 'Custom', labelKey: 'Settings.Option.Visibility.Custom' },
895
+ ],
896
+ }),
678
897
  // Reminder presets
679
- 'chronologicalEventForm.reminderPresets.timed': def('chronologicalEventForm', 'json', [5, 15, 30, 60, 120, 1440]),
898
+ 'chronologicalEventForm.reminderPresets.timed': def('chronologicalEventForm', 'json', [5, 15, 30, 60, 120, 1440], 'CUSTOM_REMINDER_PRESETS'),
680
899
  'chronologicalEventForm.reminderPresets.allDay': def('chronologicalEventForm', 'json', [
681
900
  { daysBefore: 0, time: '09:00' },
682
901
  { daysBefore: 1, time: '18:00' },
683
902
  { daysBefore: 2, time: '09:00' },
684
- ]),
903
+ ], 'CUSTOM_REMINDER_PRESETS'),
685
904
  };
686
905
  }
687
906
  // ---------------------------------------------------------------------------
@@ -726,8 +945,22 @@ export class SettingsRegistry {
726
945
  case 'number':
727
946
  return String(value ?? 0);
728
947
  case 'boolean':
948
+ if (typeof value === 'string') {
949
+ return value === 'true' ? 'true' : 'false';
950
+ }
729
951
  return value ? 'true' : 'false';
730
952
  case 'json':
953
+ // If already a serialized JSON string, return as-is to avoid
954
+ // double-wrapping (e.g. JSON.stringify("[]") → "\"[]\"")
955
+ if (typeof value === 'string') {
956
+ try {
957
+ JSON.parse(value);
958
+ return value;
959
+ }
960
+ catch {
961
+ return JSON.stringify(value);
962
+ }
963
+ }
731
964
  return JSON.stringify(value);
732
965
  }
733
966
  }
@@ -746,7 +979,18 @@ export class SettingsRegistry {
746
979
  return (raw === 'true');
747
980
  case 'json':
748
981
  try {
749
- return JSON.parse(raw);
982
+ let result = JSON.parse(raw);
983
+ // Unwrap multiply-escaped JSON strings caused by repeated
984
+ // double-serialization (each push/pull cycle added a layer)
985
+ while (typeof result === 'string') {
986
+ try {
987
+ result = JSON.parse(result);
988
+ }
989
+ catch {
990
+ break;
991
+ }
992
+ }
993
+ return result;
750
994
  }
751
995
  catch {
752
996
  return this.getDefault(key);
@@ -825,6 +1069,9 @@ export function parseSettingsSnapshot(json, calendarType, registry = defaultRegi
825
1069
  groups.set(category, []);
826
1070
  }
827
1071
  const labelDef = SETTINGS_LABELS[key];
1072
+ const entryDef = key in registry.entries
1073
+ ? registry.entries[key]
1074
+ : undefined;
828
1075
  groups.get(category).push({
829
1076
  key,
830
1077
  name,
@@ -832,6 +1079,11 @@ export function parseSettingsSnapshot(json, calendarType, registry = defaultRegi
832
1079
  labelKey: labelDef?.labelKey,
833
1080
  descriptionKey: labelDef?.descriptionKey,
834
1081
  value,
1082
+ uiType: entryDef?.uiType ?? 'TEXT_INPUT',
1083
+ ...(entryDef?.options && { options: entryDef.options }),
1084
+ ...(entryDef?.sliderConfig && { sliderConfig: entryDef.sliderConfig }),
1085
+ ...(entryDef?.appMode && { appMode: entryDef.appMode }),
1086
+ ...(entryDef?.calendarType && { calendarType: entryDef.calendarType }),
835
1087
  });
836
1088
  }
837
1089
  // Determine which categories to show
@@ -871,6 +1123,45 @@ export function formatSettingValue(value) {
871
1123
  }
872
1124
  return String(value);
873
1125
  }
1126
+ /**
1127
+ * Serialize a settings object (with mixed native types) into a
1128
+ * string-values-only snapshot suitable for pushing to a mobile device.
1129
+ *
1130
+ * Uses the registry's `serialize()` method for known keys so that
1131
+ * booleans become "true"/"false", numbers become digit strings, etc.
1132
+ * Unknown keys are converted with `String(value)`.
1133
+ */
1134
+ export function serializeSettingsSnapshot(settings, registry) {
1135
+ const result = {};
1136
+ for (const [key, value] of Object.entries(settings)) {
1137
+ if (key in registry.entries) {
1138
+ result[key] = registry.serialize(key, value);
1139
+ }
1140
+ else {
1141
+ result[key] = String(value ?? '');
1142
+ }
1143
+ }
1144
+ return result;
1145
+ }
1146
+ /**
1147
+ * Deserialize a settings snapshot (string values from the server/DB) into
1148
+ * native-typed values using the registry.
1149
+ *
1150
+ * This ensures local state always contains native types (boolean, number, etc.)
1151
+ * so that subsequent `serializeSettingsSnapshot` calls produce correct results.
1152
+ */
1153
+ export function deserializeSettingsSnapshot(snapshot, registry = defaultRegistry) {
1154
+ const result = {};
1155
+ for (const [key, value] of Object.entries(snapshot)) {
1156
+ if (typeof value === 'string' && key in registry.entries) {
1157
+ result[key] = registry.deserialize(key, value);
1158
+ }
1159
+ else {
1160
+ result[key] = value;
1161
+ }
1162
+ }
1163
+ return result;
1164
+ }
874
1165
  // ---------------------------------------------------------------------------
875
1166
  // Default registry instance (non-enrolled, system theme, nb locale)
876
1167
  // ---------------------------------------------------------------------------