@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/src/index.ts CHANGED
@@ -77,6 +77,47 @@ export type AllDayPreset = {
77
77
  time: string;
78
78
  };
79
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // UI type discriminator — determines which component renders each setting
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export type SettingUiType =
85
+ | 'TOGGLE'
86
+ | 'SELECT'
87
+ | 'VOLUME_SLIDER'
88
+ | 'SLIDER'
89
+ | 'NUMBER_INPUT'
90
+ | 'TEXT_INPUT'
91
+ | 'PIN_INPUT'
92
+ | 'CUSTOM_THEME_PICKER'
93
+ | 'CUSTOM_LANGUAGE_PICKER'
94
+ | 'CUSTOM_CALENDAR_TYPE'
95
+ | 'CUSTOM_CLOCK_TYPE'
96
+ | 'CUSTOM_WEATHER_LOCATION'
97
+ | 'CUSTOM_IMAGE'
98
+ | 'CUSTOM_IMAGE_ARRAY'
99
+ | 'CUSTOM_CALENDAR_IDS'
100
+ | 'CUSTOM_REMINDER_PRESETS'
101
+ | 'CUSTOM_EVENT_FORM'
102
+ | 'CUSTOM_CHRONOLOGICAL_EVENT_FORM'
103
+ | 'CUSTOM_DISPLAY_DENSITY'
104
+ | 'CUSTOM_BRIGHTNESS'
105
+ | 'CUSTOM_SPLIT_VIEW_CONFIG'
106
+ | 'HIDDEN';
107
+
108
+ export type SettingOption<T = unknown> = {
109
+ /** The option value */
110
+ value: T;
111
+ /** i18n message key for the option label */
112
+ labelKey: string;
113
+ };
114
+
115
+ export type SliderConfig = {
116
+ min: number;
117
+ max: number;
118
+ step: number;
119
+ };
120
+
80
121
  // ---------------------------------------------------------------------------
81
122
  // Setting categories — used for UI grouping and profile organization
82
123
  // ---------------------------------------------------------------------------
@@ -219,6 +260,8 @@ export type SettingsGroupDef = {
219
260
  keys: string[];
220
261
  /** Only show for this calendar type (undefined = always) */
221
262
  calendarType?: CalendarType;
263
+ /** Only show for this app mode (undefined = always) */
264
+ appMode?: 'ENROLLED';
222
265
  };
223
266
 
224
267
  /**
@@ -242,7 +285,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
242
285
  id: 'calendarType',
243
286
  labelKey: 'Settings.CalendarType',
244
287
  icon: 'ArrowLeftRight',
245
- keys: ['calendarView.type', 'calendarView.view'],
288
+ keys: ['calendarView.type'],
246
289
  },
247
290
  {
248
291
  id: 'calendarView',
@@ -251,9 +294,9 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
251
294
  keys: [
252
295
  'calendarView.showCalendarNames',
253
296
  'calendarView.splitView',
254
- 'calendarView.calendarColumns',
255
297
  'calendarView.dayViewZoom',
256
298
  'calendarView.weekViewZoom',
299
+ 'calendarView.calendarColumns',
257
300
  ],
258
301
  },
259
302
  {
@@ -267,9 +310,9 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
267
310
  labelKey: 'Settings.DateWeather',
268
311
  icon: 'CloudSun',
269
312
  keys: [
270
- 'calendarView.showWeatherOnEvents',
271
313
  'calendarView.showWeatherOnTimeline',
272
314
  'calendarView.weatherLocation',
315
+ 'calendarView.showWeatherOnEvents',
273
316
  ],
274
317
  },
275
318
  {
@@ -279,13 +322,13 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
279
322
  keys: [
280
323
  'sound.reminderVolume',
281
324
  'sound.mediaVolume',
282
- 'sound.alarmSound',
283
325
  'sound.reminderAlarmSound',
284
326
  'sound.reminderAlarmTimeout',
285
327
  'sound.allowCustomReminderSounds',
328
+ 'notification.',
286
329
  'sound.ttsEnabled',
287
330
  'sound.ttsRate',
288
- 'notification.',
331
+ 'sound.alarmSound',
289
332
  ],
290
333
  },
291
334
  {
@@ -293,17 +336,20 @@ export const SETTINGS_GROUPS: readonly SettingsGroupDef[] = [
293
336
  labelKey: 'Settings.TimerTitle',
294
337
  icon: 'Timer',
295
338
  keys: [
296
- 'sound.timerVolume',
339
+ 'chronological.timer.',
340
+ 'timer.',
297
341
  'sound.timerAlarmSound',
298
342
  'sound.timerAlarmTimeout',
299
- 'timer.',
343
+ 'sound.timerVolume',
300
344
  ],
345
+ appMode: 'ENROLLED',
301
346
  },
302
347
  {
303
348
  id: 'lockScreen',
304
349
  labelKey: 'Settings.LockScreen',
305
350
  icon: 'Lock',
306
351
  keys: ['lockScreen.'],
352
+ appMode: 'ENROLLED',
307
353
  },
308
354
  {
309
355
  id: 'touch',
@@ -344,6 +390,9 @@ export const EXCLUDED_DEVICE_SETTINGS: ReadonlySet<string> = new Set([
344
390
  'device.id',
345
391
  'device.devMenuEnabled',
346
392
  'device.authWarningDismissTtlDays',
393
+ 'notification.enabled',
394
+ 'notification.notifyAllCalendars',
395
+ 'notification.enabledCalendarIds',
347
396
  'notification.hasBeenPrompted',
348
397
  ]);
349
398
 
@@ -602,6 +651,33 @@ function matchesGroup(key: string, group: SettingsGroupDef): boolean {
602
651
  );
603
652
  }
604
653
 
654
+ /** Return the index of the first matching key pattern in the group, for sorting. */
655
+ function groupKeyIndex(key: string, keys: readonly string[]): number {
656
+ for (let i = 0; i < keys.length; i++) {
657
+ const matcher = keys[i];
658
+ if (key === matcher || (matcher.endsWith('.') && key.startsWith(matcher))) {
659
+ return i;
660
+ }
661
+ }
662
+ return keys.length;
663
+ }
664
+
665
+ /**
666
+ * Lazy-initialized map from setting key → declaration index in buildEntries.
667
+ * Used as a tiebreaker when multiple keys share the same prefix group index.
668
+ */
669
+ let _registryKeyOrder: Map<string, number> | undefined;
670
+ function registryKeyOrder(): Map<string, number> {
671
+ if (!_registryKeyOrder) {
672
+ _registryKeyOrder = new Map();
673
+ const keys = Object.keys(buildEntries(DEFAULT_REGISTRY_CONFIG));
674
+ for (let i = 0; i < keys.length; i++) {
675
+ _registryKeyOrder.set(keys[i], i);
676
+ }
677
+ }
678
+ return _registryKeyOrder;
679
+ }
680
+
605
681
  /**
606
682
  * Group parsed setting entries into user-facing groups matching the mobile
607
683
  * app's settings menu structure.
@@ -613,10 +689,11 @@ function matchesGroup(key: string, group: SettingsGroupDef): boolean {
613
689
  export function groupSettingsForDevice(
614
690
  allSettings: ParsedSettingEntry[],
615
691
  calendarType: CalendarType,
692
+ appMode?: 'ENROLLED',
616
693
  ): { id: SettingsGroupId; labelKey: string; icon: string; settings: ParsedSettingEntry[] }[] {
617
- // Filter excluded
694
+ // Filter excluded and hidden settings
618
695
  const settings = allSettings.filter(
619
- (s) => !EXCLUDED_DEVICE_SETTINGS.has(s.key),
696
+ (s) => !EXCLUDED_DEVICE_SETTINGS.has(s.key) && s.uiType !== 'HIDDEN',
620
697
  );
621
698
 
622
699
  // Calendar type filtering for event form keys
@@ -628,6 +705,14 @@ export function groupSettingsForDevice(
628
705
  if (!isChronological && s.key.startsWith('chronologicalEventForm.')) {
629
706
  return false;
630
707
  }
708
+ // Per-setting calendar type restriction
709
+ if (s.calendarType && s.calendarType !== calendarType) {
710
+ return false;
711
+ }
712
+ // Per-setting app mode restriction
713
+ if (s.appMode && s.appMode !== appMode) {
714
+ return false;
715
+ }
631
716
  return true;
632
717
  });
633
718
 
@@ -645,6 +730,11 @@ export function groupSettingsForDevice(
645
730
  continue;
646
731
  }
647
732
 
733
+ // Skip groups not relevant for current app mode
734
+ if (group.appMode && group.appMode !== appMode) {
735
+ continue;
736
+ }
737
+
648
738
  const matched = filteredSettings.filter(
649
739
  (s) => !claimed.has(s.key) && matchesGroup(s.key, group),
650
740
  );
@@ -652,6 +742,18 @@ export function groupSettingsForDevice(
652
742
  continue;
653
743
  }
654
744
 
745
+ // Sort matched settings by the order defined in group.keys,
746
+ // with registry declaration order as tiebreaker for prefix matches
747
+ const order = registryKeyOrder();
748
+ matched.sort((a, b) => {
749
+ const aIdx = groupKeyIndex(a.key, group.keys);
750
+ const bIdx = groupKeyIndex(b.key, group.keys);
751
+ if (aIdx !== bIdx) {
752
+ return aIdx - bIdx;
753
+ }
754
+ return (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity);
755
+ });
756
+
655
757
  for (const s of matched) {
656
758
  claimed.add(s.key);
657
759
  }
@@ -683,6 +785,16 @@ export type SettingDef<T = unknown> = {
683
785
  default: T;
684
786
  /** Whether this setting should be synced to the server. Default true. */
685
787
  sync: boolean;
788
+ /** UI component type for rendering this setting */
789
+ uiType: SettingUiType;
790
+ /** Available options for SELECT-type settings */
791
+ options?: readonly SettingOption<T>[];
792
+ /** Slider configuration for SLIDER-type settings */
793
+ sliderConfig?: SliderConfig;
794
+ /** Only show this setting for enrolled/kiosk devices (undefined = always) */
795
+ appMode?: 'ENROLLED';
796
+ /** Only show this setting for a specific calendar type (undefined = always) */
797
+ calendarType?: CalendarType;
686
798
  };
687
799
 
688
800
  // ---------------------------------------------------------------------------
@@ -705,6 +817,39 @@ export const DEFAULT_REGISTRY_CONFIG: RegistryConfig = {
705
817
  defaultLocale: 'nb',
706
818
  };
707
819
 
820
+ // ---------------------------------------------------------------------------
821
+ // Shared option arrays (reused across multiple settings)
822
+ // ---------------------------------------------------------------------------
823
+
824
+ const ALARM_SOUND_OPTIONS: readonly SettingOption<AlarmSound>[] = [
825
+ { value: 'none', labelKey: 'Settings.Option.AlarmSound.None' },
826
+ { value: 'alarm1', labelKey: 'Settings.Option.AlarmSound.Alarm1' },
827
+ { value: 'alarm2', labelKey: 'Settings.Option.AlarmSound.Alarm2' },
828
+ { value: 'alarm3', labelKey: 'Settings.Option.AlarmSound.Alarm3' },
829
+ { value: 'alarm4', labelKey: 'Settings.Option.AlarmSound.Alarm4' },
830
+ { value: 'alarm5', labelKey: 'Settings.Option.AlarmSound.Alarm5' },
831
+ { value: 'alarm6', labelKey: 'Settings.Option.AlarmSound.Alarm6' },
832
+ { value: 'alarm7', labelKey: 'Settings.Option.AlarmSound.Alarm7' },
833
+ { value: 'alarm8', labelKey: 'Settings.Option.AlarmSound.Alarm8' },
834
+ { value: 'alarm9', labelKey: 'Settings.Option.AlarmSound.Alarm9' },
835
+ ];
836
+
837
+ const ALARM_TIMEOUT_OPTIONS: readonly SettingOption<AlarmTimeout>[] = [
838
+ { value: 1, labelKey: 'Settings.Option.AlarmTimeout.1min' },
839
+ { value: 2, labelKey: 'Settings.Option.AlarmTimeout.2min' },
840
+ { value: 3, labelKey: 'Settings.Option.AlarmTimeout.3min' },
841
+ { value: 5, labelKey: 'Settings.Option.AlarmTimeout.5min' },
842
+ { value: 10, labelKey: 'Settings.Option.AlarmTimeout.10min' },
843
+ ];
844
+
845
+ const DAY_VIEW_ZOOM_OPTIONS: readonly SettingOption<CalendarDayViewCellZoom>[] = [
846
+ { value: 15, labelKey: 'Settings.Option.DayViewZoom.15min' },
847
+ { value: 30, labelKey: 'Settings.Option.DayViewZoom.30min' },
848
+ { value: 60, labelKey: 'Settings.Option.DayViewZoom.1hour' },
849
+ ];
850
+
851
+ const TIME_OF_DAY_SLIDER: SliderConfig = { min: 0, max: 23, step: 1 };
852
+
708
853
  // ---------------------------------------------------------------------------
709
854
  // Helper
710
855
  // ---------------------------------------------------------------------------
@@ -713,9 +858,35 @@ function def<T>(
713
858
  category: SettingsCategory,
714
859
  type: SettingsDataType,
715
860
  defaultValue: T,
716
- sync = true,
861
+ uiType: SettingUiType,
862
+ extra?: {
863
+ sync?: boolean;
864
+ options?: readonly SettingOption<T>[];
865
+ sliderConfig?: SliderConfig;
866
+ appMode?: 'ENROLLED';
867
+ calendarType?: CalendarType;
868
+ },
717
869
  ): SettingDef<T> {
718
- return { category, type, default: defaultValue, sync };
870
+ const result: SettingDef<T> = {
871
+ category,
872
+ type,
873
+ default: defaultValue,
874
+ sync: extra?.sync ?? true,
875
+ uiType,
876
+ };
877
+ if (extra?.options) {
878
+ result.options = extra.options;
879
+ }
880
+ if (extra?.sliderConfig) {
881
+ result.sliderConfig = extra.sliderConfig;
882
+ }
883
+ if (extra?.appMode) {
884
+ result.appMode = extra.appMode;
885
+ }
886
+ if (extra?.calendarType) {
887
+ result.calendarType = extra.calendarType;
888
+ }
889
+ return result;
719
890
  }
720
891
 
721
892
  // ---------------------------------------------------------------------------
@@ -731,9 +902,35 @@ function buildEntries(config: RegistryConfig) {
731
902
  'appearance',
732
903
  'string',
733
904
  config.defaultTheme,
905
+ 'CUSTOM_THEME_PICKER',
906
+ {
907
+ options: [
908
+ { value: 'light', labelKey: 'Settings.Option.Theme.Light' },
909
+ { value: 'dark', labelKey: 'Settings.Option.Theme.Dark' },
910
+ { value: 'system', labelKey: 'Settings.Option.Theme.System' },
911
+ ],
912
+ },
913
+ ),
914
+ 'appearance.clockType': def<ClockType>(
915
+ 'appearance',
916
+ 'string',
917
+ 'digital',
918
+ 'CUSTOM_CLOCK_TYPE',
919
+ {
920
+ options: [
921
+ { value: 'digital', labelKey: 'Settings.Option.ClockType.Digital' },
922
+ { value: 'analog', labelKey: 'Settings.Option.ClockType.Analog' },
923
+ ],
924
+ appMode: 'ENROLLED',
925
+ },
926
+ ),
927
+ 'appearance.enableDayColors': def<boolean>(
928
+ 'appearance',
929
+ 'boolean',
930
+ false,
931
+ 'TOGGLE',
932
+ { calendarType: 'chronological' },
734
933
  ),
735
- 'appearance.clockType': def<ClockType>('appearance', 'string', 'digital'),
736
- 'appearance.enableDayColors': def<boolean>('appearance', 'boolean', false),
737
934
 
738
935
  // ═══════════════════════════════════════════════════════════════════════
739
936
  // Calendar view
@@ -742,152 +939,348 @@ function buildEntries(config: RegistryConfig) {
742
939
  'calendarView',
743
940
  'string',
744
941
  'time-based',
942
+ 'CUSTOM_CALENDAR_TYPE',
943
+ {
944
+ options: [
945
+ {
946
+ value: 'chronological',
947
+ labelKey: 'Settings.Option.CalendarType.Chronological',
948
+ },
949
+ {
950
+ value: 'time-based',
951
+ labelKey: 'Settings.Option.CalendarType.TimeBased',
952
+ },
953
+ ],
954
+ },
955
+ ),
956
+ 'calendarView.view': def<CalendarViewMode>(
957
+ 'calendarView',
958
+ 'string',
959
+ 'day',
960
+ 'SELECT',
961
+ {
962
+ options: [
963
+ { value: 'day', labelKey: 'Settings.Option.CalendarView.Day' },
964
+ {
965
+ value: '3-days',
966
+ labelKey: 'Settings.Option.CalendarView.3Days',
967
+ },
968
+ {
969
+ value: '5-days',
970
+ labelKey: 'Settings.Option.CalendarView.5Days',
971
+ },
972
+ {
973
+ value: '7-days',
974
+ labelKey: 'Settings.Option.CalendarView.7Days',
975
+ },
976
+ { value: 'week', labelKey: 'Settings.Option.CalendarView.Week' },
977
+ {
978
+ value: 'month',
979
+ labelKey: 'Settings.Option.CalendarView.Month',
980
+ },
981
+ {
982
+ value: 'overview',
983
+ labelKey: 'Settings.Option.CalendarView.Overview',
984
+ },
985
+ ],
986
+ },
745
987
  ),
746
- 'calendarView.view': def<CalendarViewMode>('calendarView', 'string', 'day'),
747
988
  'calendarView.dayViewZoom': def<CalendarDayViewCellZoom>(
748
989
  'calendarView',
749
990
  'number',
750
991
  60,
992
+ 'SELECT',
993
+ { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
751
994
  ),
752
995
  'calendarView.weekViewZoom': def<CalendarDayViewCellZoom>(
753
996
  'calendarView',
754
997
  'number',
755
998
  60,
999
+ 'SELECT',
1000
+ { options: DAY_VIEW_ZOOM_OPTIONS, calendarType: 'time-based' },
1001
+ ),
1002
+ 'calendarView.splitView': def<boolean>(
1003
+ 'calendarView',
1004
+ 'boolean',
1005
+ false,
1006
+ 'TOGGLE',
756
1007
  ),
757
- 'calendarView.splitView': def<boolean>('calendarView', 'boolean', false),
758
1008
  'calendarView.showCalendarNames': def<boolean>(
759
1009
  'calendarView',
760
1010
  'boolean',
761
1011
  true,
1012
+ 'TOGGLE',
1013
+ ),
1014
+ 'calendarView.calendarColumns': def<unknown[]>(
1015
+ 'calendarView',
1016
+ 'json',
1017
+ [],
1018
+ 'CUSTOM_SPLIT_VIEW_CONFIG',
762
1019
  ),
763
- 'calendarView.calendarColumns': def<unknown[]>('calendarView', 'json', []),
764
1020
  'calendarView.autoReturnToTodayEnabled': def<boolean>(
765
1021
  'calendarView',
766
1022
  'boolean',
767
1023
  config.isEnrolled,
1024
+ 'TOGGLE',
768
1025
  ),
769
1026
  'calendarView.autoReturnToTodayTimeoutSeconds': def<number>(
770
1027
  'calendarView',
771
1028
  'number',
772
1029
  300,
1030
+ 'SLIDER',
1031
+ { sliderConfig: { min: 30, max: 600, step: 30 } },
773
1032
  ),
774
1033
  'calendarView.showWeatherOnEvents': def<boolean>(
775
1034
  'calendarView',
776
1035
  'boolean',
777
1036
  false,
1037
+ 'TOGGLE',
778
1038
  ),
779
1039
  'calendarView.showWeatherOnTimeline': def<boolean>(
780
1040
  'calendarView',
781
1041
  'boolean',
782
1042
  false,
1043
+ 'TOGGLE',
783
1044
  ),
784
1045
  'calendarView.weatherLocation': def<WeatherLocation | null>(
785
1046
  'calendarView',
786
1047
  'json',
787
1048
  null,
1049
+ 'CUSTOM_WEATHER_LOCATION',
788
1050
  ),
789
1051
 
790
1052
  // ═══════════════════════════════════════════════════════════════════════
791
1053
  // Event form field visibility (time-based)
792
1054
  // ═══════════════════════════════════════════════════════════════════════
793
- 'eventForm.recurrence': def<boolean>('eventForm', 'boolean', true),
794
- 'eventForm.reminders': def<boolean>('eventForm', 'boolean', true),
795
- 'eventForm.emailReminders': def<boolean>('eventForm', 'boolean', false),
796
- 'eventForm.location': def<boolean>('eventForm', 'boolean', true),
797
- 'eventForm.travelTime': def<boolean>('eventForm', 'boolean', false),
798
- 'eventForm.description': def<boolean>('eventForm', 'boolean', true),
799
- 'eventForm.checklist': def<boolean>('eventForm', 'boolean', true),
800
- 'eventForm.images': def<boolean>('eventForm', 'boolean', false),
801
- 'eventForm.audioClips': def<boolean>('eventForm', 'boolean', false),
1055
+ 'eventForm.recurrence': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1056
+ 'eventForm.location': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1057
+ 'eventForm.travelTime': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1058
+ 'eventForm.reminders': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1059
+ 'eventForm.emailReminders': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1060
+ 'eventForm.description': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1061
+ 'eventForm.checklist': def<boolean>('eventForm', 'boolean', true, 'TOGGLE'),
1062
+ 'eventForm.images': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
1063
+ 'eventForm.audioClips': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
802
1064
  'eventForm.notificationReceivers': def<boolean>(
803
1065
  'eventForm',
804
1066
  'boolean',
805
1067
  true,
1068
+ 'TOGGLE',
806
1069
  ),
807
- 'eventForm.visibility': def<boolean>('eventForm', 'boolean', false),
1070
+ 'eventForm.visibility': def<boolean>('eventForm', 'boolean', false, 'TOGGLE'),
808
1071
 
809
1072
  // ═══════════════════════════════════════════════════════════════════════
810
1073
  // Sound & alerts
811
1074
  // ═══════════════════════════════════════════════════════════════════════
812
- 'sound.timerVolume': def<number>('sound', 'number', 0.5),
813
- 'sound.reminderVolume': def<number>('sound', 'number', 0.5),
814
- 'sound.mediaVolume': def<number>('sound', 'number', 0.5),
815
- 'sound.alarmSound': def<AlarmSound>('sound', 'string', 'alarm1'),
816
- 'sound.reminderAlarmSound': def<AlarmSound>('sound', 'string', 'alarm1'),
817
- 'sound.timerAlarmSound': def<AlarmSound>('sound', 'string', 'alarm1'),
818
- 'sound.timerAlarmTimeout': def<AlarmTimeout>('sound', 'number', 3),
819
- 'sound.reminderAlarmTimeout': def<AlarmTimeout>('sound', 'number', 3),
820
- 'sound.allowCustomReminderSounds': def<boolean>('sound', 'boolean', false),
821
- 'sound.ttsEnabled': def<boolean>('sound', 'boolean', config.isEnrolled),
822
- 'sound.ttsRate': def<number>('sound', 'number', 1.0),
1075
+ 'sound.timerVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER'),
1076
+ 'sound.reminderVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
1077
+ 'sound.mediaVolume': def<number>('sound', 'number', 0.5, 'VOLUME_SLIDER', { appMode: 'ENROLLED' }),
1078
+ 'sound.alarmSound': def<AlarmSound>(
1079
+ 'sound',
1080
+ 'string',
1081
+ 'alarm1',
1082
+ 'SELECT',
1083
+ { options: ALARM_SOUND_OPTIONS },
1084
+ ),
1085
+ 'sound.reminderAlarmSound': def<AlarmSound>(
1086
+ 'sound',
1087
+ 'string',
1088
+ 'alarm1',
1089
+ 'SELECT',
1090
+ { options: ALARM_SOUND_OPTIONS },
1091
+ ),
1092
+ 'sound.timerAlarmSound': def<AlarmSound>(
1093
+ 'sound',
1094
+ 'string',
1095
+ 'alarm1',
1096
+ 'SELECT',
1097
+ { options: ALARM_SOUND_OPTIONS },
1098
+ ),
1099
+ 'sound.timerAlarmTimeout': def<AlarmTimeout>(
1100
+ 'sound',
1101
+ 'number',
1102
+ 3,
1103
+ 'SELECT',
1104
+ { options: ALARM_TIMEOUT_OPTIONS },
1105
+ ),
1106
+ 'sound.reminderAlarmTimeout': def<AlarmTimeout>(
1107
+ 'sound',
1108
+ 'number',
1109
+ 3,
1110
+ 'SELECT',
1111
+ { options: ALARM_TIMEOUT_OPTIONS },
1112
+ ),
1113
+ 'sound.allowCustomReminderSounds': def<boolean>(
1114
+ 'sound',
1115
+ 'boolean',
1116
+ false,
1117
+ 'TOGGLE',
1118
+ ),
1119
+ 'sound.ttsEnabled': def<boolean>(
1120
+ 'sound',
1121
+ 'boolean',
1122
+ config.isEnrolled,
1123
+ 'TOGGLE',
1124
+ { appMode: 'ENROLLED' },
1125
+ ),
1126
+ 'sound.ttsRate': def<number>(
1127
+ 'sound',
1128
+ 'number',
1129
+ 1.0,
1130
+ 'SLIDER',
1131
+ { sliderConfig: { min: 0.5, max: 2, step: 0.1 }, appMode: 'ENROLLED' },
1132
+ ),
823
1133
 
824
1134
  // ═══════════════════════════════════════════════════════════════════════
825
1135
  // Timer
826
1136
  // ═══════════════════════════════════════════════════════════════════════
827
- 'timer.showTimeRemaining': def<boolean>('timer', 'boolean', true),
828
- 'timer.showEndTime': def<boolean>('timer', 'boolean', true),
829
- 'timer.showRestartButton': def<boolean>('timer', 'boolean', true),
830
- 'timer.showPauseButton': def<boolean>('timer', 'boolean', true),
1137
+ 'timer.showTimeRemaining': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1138
+ 'timer.showEndTime': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1139
+ 'timer.showRestartButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
1140
+ 'timer.showPauseButton': def<boolean>('timer', 'boolean', true, 'TOGGLE'),
831
1141
 
832
1142
  // ═══════════════════════════════════════════════════════════════════════
833
1143
  // Lock screen
834
1144
  // ═══════════════════════════════════════════════════════════════════════
835
- 'lockScreen.pin': def<string>('lockScreen', 'string', ''),
836
1145
  'lockScreen.inactivityLockEnabled': def<boolean>(
837
1146
  'lockScreen',
838
1147
  'boolean',
839
1148
  false,
1149
+ 'TOGGLE',
840
1150
  ),
841
1151
  'lockScreen.inactivityTimeoutMinutes': def<InactivityTimeoutMinutes>(
842
1152
  'lockScreen',
843
1153
  'number',
844
1154
  5,
845
- ),
1155
+ 'SELECT',
1156
+ {
1157
+ options: [
1158
+ { value: 1, labelKey: 'Settings.Option.InactivityTimeout.1min' },
1159
+ { value: 5, labelKey: 'Settings.Option.InactivityTimeout.5min' },
1160
+ { value: 10, labelKey: 'Settings.Option.InactivityTimeout.10min' },
1161
+ { value: 15, labelKey: 'Settings.Option.InactivityTimeout.15min' },
1162
+ { value: 30, labelKey: 'Settings.Option.InactivityTimeout.30min' },
1163
+ { value: 45, labelKey: 'Settings.Option.InactivityTimeout.45min' },
1164
+ ],
1165
+ },
1166
+ ),
1167
+ 'lockScreen.pin': def<string>('lockScreen', 'string', '', 'PIN_INPUT'),
846
1168
  'lockScreen.clockDisplay': def<LockScreenClockDisplay>(
847
1169
  'lockScreen',
848
1170
  'string',
849
1171
  'digital',
1172
+ 'SELECT',
1173
+ {
1174
+ options: [
1175
+ { value: 'none', labelKey: 'Settings.Option.ClockDisplay.None' },
1176
+ {
1177
+ value: 'digital',
1178
+ labelKey: 'Settings.Option.ClockDisplay.Digital',
1179
+ },
1180
+ {
1181
+ value: 'analog',
1182
+ labelKey: 'Settings.Option.ClockDisplay.Analog',
1183
+ },
1184
+ ],
1185
+ },
1186
+ ),
1187
+ 'lockScreen.showHourNumbers': def<boolean>(
1188
+ 'lockScreen',
1189
+ 'boolean',
1190
+ true,
1191
+ 'TOGGLE',
1192
+ ),
1193
+ 'lockScreen.showDate': def<boolean>(
1194
+ 'lockScreen',
1195
+ 'boolean',
1196
+ true,
1197
+ 'TOGGLE',
850
1198
  ),
851
- 'lockScreen.showDate': def<boolean>('lockScreen', 'boolean', true),
852
- 'lockScreen.showHourNumbers': def<boolean>('lockScreen', 'boolean', true),
853
1199
  'lockScreen.imageMode': def<LockScreenImageMode>(
854
1200
  'lockScreen',
855
1201
  'string',
856
1202
  'none',
1203
+ 'SELECT',
1204
+ {
1205
+ options: [
1206
+ { value: 'none', labelKey: 'Settings.Option.ImageMode.None' },
1207
+ {
1208
+ value: 'background',
1209
+ labelKey: 'Settings.Option.ImageMode.Background',
1210
+ },
1211
+ {
1212
+ value: 'photoFrame',
1213
+ labelKey: 'Settings.Option.ImageMode.PhotoFrame',
1214
+ },
1215
+ ],
1216
+ },
857
1217
  ),
858
1218
  'lockScreen.backgroundImage': def<string | null>(
859
1219
  'lockScreen',
860
1220
  'json',
861
1221
  null,
862
- ),
863
- 'lockScreen.photoFrameImages': def<LockScreenImage[]>(
864
- 'lockScreen',
865
- 'json',
866
- [],
1222
+ 'CUSTOM_IMAGE',
867
1223
  ),
868
1224
  'lockScreen.photoFrameIntervalSeconds': def<PhotoFrameIntervalSeconds>(
869
1225
  'lockScreen',
870
1226
  'number',
871
1227
  60,
1228
+ 'SELECT',
1229
+ {
1230
+ options: [
1231
+ { value: 30, labelKey: 'Settings.Option.PhotoFrameInterval.30sec' },
1232
+ { value: 60, labelKey: 'Settings.Option.PhotoFrameInterval.1min' },
1233
+ { value: 120, labelKey: 'Settings.Option.PhotoFrameInterval.2min' },
1234
+ { value: 300, labelKey: 'Settings.Option.PhotoFrameInterval.5min' },
1235
+ ],
1236
+ },
1237
+ ),
1238
+
1239
+ 'lockScreen.photoFrameImages': def<LockScreenImage[]>(
1240
+ 'lockScreen',
1241
+ 'json',
1242
+ [],
1243
+ 'CUSTOM_IMAGE_ARRAY',
872
1244
  ),
873
1245
 
874
1246
  // ═══════════════════════════════════════════════════════════════════════
875
1247
  // Touch / gestures
876
1248
  // ═══════════════════════════════════════════════════════════════════════
877
- 'touch.enableTapToCreate': def<boolean>('touch', 'boolean', false),
878
- 'touch.enableDragDrop': def<boolean>('touch', 'boolean', false),
1249
+ 'touch.enableTapToCreate': def<boolean>('touch', 'boolean', false, 'TOGGLE'),
1250
+ 'touch.enableDragDrop': def<boolean>('touch', 'boolean', false, 'TOGGLE'),
879
1251
 
880
1252
  // ═══════════════════════════════════════════════════════════════════════
881
1253
  // Device (not synced unless noted)
882
1254
  // ═══════════════════════════════════════════════════════════════════════
883
- 'device.id': def<string>('device', 'string', '', false),
884
- 'device.timePickerMode': def<TimePickerMode>('device', 'string', 'dials'),
885
- 'device.devMenuEnabled': def<boolean>('device', 'boolean', false, false),
1255
+ 'device.id': def<string>('device', 'string', '', 'HIDDEN', { sync: false }),
1256
+ 'device.timePickerMode': def<TimePickerMode>(
1257
+ 'device',
1258
+ 'string',
1259
+ 'dials',
1260
+ 'SELECT',
1261
+ {
1262
+ options: [
1263
+ { value: 'dials', labelKey: 'Settings.Option.TimePickerMode.Dials' },
1264
+ {
1265
+ value: 'keypad',
1266
+ labelKey: 'Settings.Option.TimePickerMode.Keypad',
1267
+ },
1268
+ ],
1269
+ },
1270
+ ),
1271
+ 'device.devMenuEnabled': def<boolean>(
1272
+ 'device',
1273
+ 'boolean',
1274
+ false,
1275
+ 'HIDDEN',
1276
+ { sync: false },
1277
+ ),
886
1278
  'device.authWarningDismissTtlDays': def<number>(
887
1279
  'device',
888
1280
  'number',
889
1281
  3,
890
- false,
1282
+ 'HIDDEN',
1283
+ { sync: false },
891
1284
  ),
892
1285
 
893
1286
  // ═══════════════════════════════════════════════════════════════════════
@@ -897,27 +1290,42 @@ function buildEntries(config: RegistryConfig) {
897
1290
  'language',
898
1291
  'string',
899
1292
  config.defaultLocale,
1293
+ 'CUSTOM_LANGUAGE_PICKER',
1294
+ {
1295
+ options: [
1296
+ { value: 'nb', labelKey: 'Settings.Option.Language.Norwegian' },
1297
+ { value: 'en', labelKey: 'Settings.Option.Language.English' },
1298
+ ],
1299
+ },
900
1300
  ),
901
1301
 
902
1302
  // ═══════════════════════════════════════════════════════════════════════
903
1303
  // Notifications
904
1304
  // ═══════════════════════════════════════════════════════════════════════
905
- 'notification.enabled': def<boolean>('notification', 'boolean', false),
1305
+ 'notification.enabled': def<boolean>(
1306
+ 'notification',
1307
+ 'boolean',
1308
+ false,
1309
+ 'TOGGLE',
1310
+ ),
906
1311
  'notification.notifyAllCalendars': def<boolean>(
907
1312
  'notification',
908
1313
  'boolean',
909
1314
  true,
1315
+ 'TOGGLE',
910
1316
  ),
911
1317
  'notification.enabledCalendarIds': def<string[]>(
912
1318
  'notification',
913
1319
  'json',
914
1320
  [],
1321
+ 'CUSTOM_CALENDAR_IDS',
915
1322
  ),
916
1323
  'notification.hasBeenPrompted': def<boolean>(
917
1324
  'notification',
918
1325
  'boolean',
919
1326
  false,
920
- false,
1327
+ 'HIDDEN',
1328
+ { sync: false },
921
1329
  ),
922
1330
 
923
1331
  // ═══════════════════════════════════════════════════════════════════════
@@ -928,225 +1336,292 @@ function buildEntries(config: RegistryConfig) {
928
1336
  'chronological',
929
1337
  'boolean',
930
1338
  true,
1339
+ 'TOGGLE',
931
1340
  ),
932
- 'chronological.header.showClock': def<boolean>(
1341
+ 'chronological.header.showCurrentYearInDate': def<boolean>(
933
1342
  'chronological',
934
1343
  'boolean',
935
- true,
1344
+ false,
1345
+ 'TOGGLE',
936
1346
  ),
937
- 'chronological.header.showCurrentYearInDate': def<boolean>(
1347
+ 'chronological.header.showClock': def<boolean>(
938
1348
  'chronological',
939
1349
  'boolean',
940
- false,
1350
+ true,
1351
+ 'TOGGLE',
941
1352
  ),
942
1353
  'chronological.header.showTimeOfDay': def<boolean>(
943
1354
  'chronological',
944
1355
  'boolean',
945
1356
  false,
1357
+ 'TOGGLE',
946
1358
  ),
947
1359
  // Footer
948
1360
  'chronological.footer.showMenuButton': def<boolean>(
949
1361
  'chronological',
950
1362
  'boolean',
951
1363
  true,
1364
+ 'TOGGLE',
952
1365
  ),
953
1366
  'chronological.footer.showViewSwitcherDay': def<boolean>(
954
1367
  'chronological',
955
1368
  'boolean',
956
1369
  true,
1370
+ 'TOGGLE',
957
1371
  ),
958
1372
  'chronological.footer.showViewSwitcherWeek': def<boolean>(
959
1373
  'chronological',
960
1374
  'boolean',
961
1375
  true,
1376
+ 'TOGGLE',
962
1377
  ),
963
1378
  'chronological.footer.showViewSwitcherMonth': def<boolean>(
964
1379
  'chronological',
965
1380
  'boolean',
966
1381
  true,
1382
+ 'TOGGLE',
967
1383
  ),
968
1384
  'chronological.footer.showTimerButton': def<boolean>(
969
1385
  'chronological',
970
1386
  'boolean',
971
1387
  true,
1388
+ 'TOGGLE',
972
1389
  ),
973
1390
  'chronological.footer.showNewEventButton': def<boolean>(
974
1391
  'chronological',
975
1392
  'boolean',
976
1393
  true,
1394
+ 'TOGGLE',
977
1395
  ),
978
1396
  'chronological.footer.showSettingsButton': def<boolean>(
979
1397
  'chronological',
980
1398
  'boolean',
981
1399
  true,
1400
+ 'TOGGLE',
982
1401
  ),
983
1402
  // Timer features
984
1403
  'chronological.timer.showNewCountdown': def<boolean>(
985
1404
  'chronological',
986
1405
  'boolean',
987
1406
  true,
1407
+ 'TOGGLE',
988
1408
  ),
989
1409
  'chronological.timer.showFromTemplate': def<boolean>(
990
1410
  'chronological',
991
1411
  'boolean',
992
1412
  true,
1413
+ 'TOGGLE',
993
1414
  ),
994
- 'chronological.timer.showEditTemplate': def<boolean>(
1415
+ 'chronological.timer.showAddTemplate': def<boolean>(
995
1416
  'chronological',
996
1417
  'boolean',
997
1418
  true,
1419
+ 'TOGGLE',
998
1420
  ),
999
- 'chronological.timer.showDeleteTemplate': def<boolean>(
1421
+ 'chronological.timer.showEditTemplate': def<boolean>(
1000
1422
  'chronological',
1001
1423
  'boolean',
1002
1424
  true,
1425
+ 'TOGGLE',
1003
1426
  ),
1004
- 'chronological.timer.showAddTemplate': def<boolean>(
1427
+ 'chronological.timer.showDeleteTemplate': def<boolean>(
1005
1428
  'chronological',
1006
1429
  'boolean',
1007
1430
  true,
1431
+ 'TOGGLE',
1008
1432
  ),
1009
1433
  // Menu
1010
1434
  'chronological.menu.showSettingsButton': def<boolean>(
1011
1435
  'chronological',
1012
1436
  'boolean',
1013
1437
  true,
1438
+ 'TOGGLE',
1014
1439
  ),
1015
1440
  // Quick settings
1016
1441
  'chronological.quickSettings.showTimerVolume': def<boolean>(
1017
1442
  'chronological',
1018
1443
  'boolean',
1019
1444
  true,
1445
+ 'TOGGLE',
1020
1446
  ),
1021
1447
  'chronological.quickSettings.showReminderVolume': def<boolean>(
1022
1448
  'chronological',
1023
1449
  'boolean',
1024
1450
  true,
1451
+ 'TOGGLE',
1025
1452
  ),
1026
1453
  'chronological.quickSettings.showMediaVolume': def<boolean>(
1027
1454
  'chronological',
1028
1455
  'boolean',
1029
1456
  true,
1457
+ 'TOGGLE',
1030
1458
  ),
1031
1459
  'chronological.quickSettings.showBrightness': def<boolean>(
1032
1460
  'chronological',
1033
1461
  'boolean',
1034
1462
  true,
1463
+ 'TOGGLE',
1035
1464
  ),
1036
1465
  'chronological.quickSettings.showLockScreen': def<boolean>(
1037
1466
  'chronological',
1038
1467
  'boolean',
1039
1468
  true,
1469
+ 'TOGGLE',
1040
1470
  ),
1041
1471
  // Time-of-day periods
1042
1472
  'chronological.timeOfDay.morningStart': def<number>(
1043
1473
  'chronological',
1044
1474
  'number',
1045
1475
  6,
1476
+ 'SLIDER',
1477
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1046
1478
  ),
1047
1479
  'chronological.timeOfDay.forenoonStart': def<number>(
1048
1480
  'chronological',
1049
1481
  'number',
1050
1482
  9,
1483
+ 'SLIDER',
1484
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1051
1485
  ),
1052
1486
  'chronological.timeOfDay.afternoonStart': def<number>(
1053
1487
  'chronological',
1054
1488
  'number',
1055
1489
  12,
1490
+ 'SLIDER',
1491
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1056
1492
  ),
1057
1493
  'chronological.timeOfDay.eveningStart': def<number>(
1058
1494
  'chronological',
1059
1495
  'number',
1060
1496
  18,
1497
+ 'SLIDER',
1498
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1061
1499
  ),
1062
1500
  'chronological.timeOfDay.nightStart': def<number>(
1063
1501
  'chronological',
1064
1502
  'number',
1065
1503
  0,
1504
+ 'SLIDER',
1505
+ { sliderConfig: TIME_OF_DAY_SLIDER },
1066
1506
  ),
1067
1507
 
1068
1508
  // ═══════════════════════════════════════════════════════════════════════
1069
1509
  // Chronological event form
1070
1510
  // ═══════════════════════════════════════════════════════════════════════
1071
- // Toggleable fields
1072
- 'chronologicalEventForm.field.description': def<boolean>(
1511
+ // Fixed fields
1512
+ 'chronologicalEventForm.fixedField.allDay': def<boolean>(
1073
1513
  'chronologicalEventForm',
1074
1514
  'boolean',
1075
1515
  true,
1516
+ 'TOGGLE',
1076
1517
  ),
1077
- 'chronologicalEventForm.field.recurrence': def<boolean>(
1518
+ 'chronologicalEventForm.fixedField.endTime': def<boolean>(
1078
1519
  'chronologicalEventForm',
1079
1520
  'boolean',
1080
1521
  true,
1522
+ 'TOGGLE',
1081
1523
  ),
1082
- 'chronologicalEventForm.field.acknowledge': def<boolean>(
1524
+ 'chronologicalEventForm.fixedField.visibility': def<boolean>(
1083
1525
  'chronologicalEventForm',
1084
1526
  'boolean',
1085
1527
  true,
1528
+ 'TOGGLE',
1086
1529
  ),
1087
- 'chronologicalEventForm.field.checklist': def<boolean>(
1530
+ // Toggleable fields
1531
+ 'chronologicalEventForm.field.acknowledge': def<boolean>(
1088
1532
  'chronologicalEventForm',
1089
1533
  'boolean',
1090
1534
  true,
1535
+ 'TOGGLE',
1091
1536
  ),
1092
- 'chronologicalEventForm.field.extraImages': def<boolean>(
1537
+ 'chronologicalEventForm.field.description': def<boolean>(
1093
1538
  'chronologicalEventForm',
1094
1539
  'boolean',
1095
1540
  true,
1541
+ 'TOGGLE',
1096
1542
  ),
1097
- 'chronologicalEventForm.field.reminders': def<boolean>(
1543
+ 'chronologicalEventForm.field.recurrence': def<boolean>(
1098
1544
  'chronologicalEventForm',
1099
1545
  'boolean',
1100
1546
  true,
1547
+ 'TOGGLE',
1101
1548
  ),
1102
- 'chronologicalEventForm.field.audioClips': def<boolean>(
1549
+ 'chronologicalEventForm.field.checklist': def<boolean>(
1103
1550
  'chronologicalEventForm',
1104
1551
  'boolean',
1105
1552
  true,
1553
+ 'TOGGLE',
1106
1554
  ),
1107
- // Fixed fields
1108
- 'chronologicalEventForm.fixedField.allDay': def<boolean>(
1555
+ 'chronologicalEventForm.field.extraImages': def<boolean>(
1109
1556
  'chronologicalEventForm',
1110
1557
  'boolean',
1111
1558
  true,
1559
+ 'TOGGLE',
1112
1560
  ),
1113
- 'chronologicalEventForm.fixedField.endTime': def<boolean>(
1561
+ 'chronologicalEventForm.field.reminders': def<boolean>(
1114
1562
  'chronologicalEventForm',
1115
1563
  'boolean',
1116
1564
  true,
1565
+ 'TOGGLE',
1117
1566
  ),
1118
- 'chronologicalEventForm.fixedField.visibility': def<boolean>(
1567
+ 'chronologicalEventForm.field.audioClips': def<boolean>(
1119
1568
  'chronologicalEventForm',
1120
1569
  'boolean',
1121
1570
  true,
1571
+ 'TOGGLE',
1122
1572
  ),
1123
1573
  // Suggest end time
1124
1574
  'chronologicalEventForm.suggestEndTime.enabled': def<boolean>(
1125
1575
  'chronologicalEventForm',
1126
1576
  'boolean',
1127
1577
  false,
1578
+ 'TOGGLE',
1128
1579
  ),
1129
1580
  'chronologicalEventForm.suggestEndTime.value': def<number>(
1130
1581
  'chronologicalEventForm',
1131
1582
  'number',
1132
1583
  30,
1584
+ 'SLIDER',
1585
+ { sliderConfig: { min: 5, max: 480, step: 5 } },
1133
1586
  ),
1134
1587
  'chronologicalEventForm.suggestEndTime.unit': def<SuggestEndTimeUnit>(
1135
1588
  'chronologicalEventForm',
1136
1589
  'string',
1137
1590
  'minutes',
1591
+ 'SELECT',
1592
+ {
1593
+ options: [
1594
+ {
1595
+ value: 'minutes',
1596
+ labelKey: 'Settings.Option.SuggestEndTimeUnit.Minutes',
1597
+ },
1598
+ {
1599
+ value: 'hours',
1600
+ labelKey: 'Settings.Option.SuggestEndTimeUnit.Hours',
1601
+ },
1602
+ ],
1603
+ },
1138
1604
  ),
1139
1605
  // Default visibility
1140
1606
  'chronologicalEventForm.defaultVisibility': def<EventVisibility>(
1141
1607
  'chronologicalEventForm',
1142
1608
  'string',
1143
1609
  'Private',
1610
+ 'SELECT',
1611
+ {
1612
+ options: [
1613
+ { value: 'Public', labelKey: 'Settings.Option.Visibility.Public' },
1614
+ { value: 'Private', labelKey: 'Settings.Option.Visibility.Private' },
1615
+ { value: 'Custom', labelKey: 'Settings.Option.Visibility.Custom' },
1616
+ ],
1617
+ },
1144
1618
  ),
1145
1619
  // Reminder presets
1146
1620
  'chronologicalEventForm.reminderPresets.timed': def<number[]>(
1147
1621
  'chronologicalEventForm',
1148
1622
  'json',
1149
1623
  [5, 15, 30, 60, 120, 1440],
1624
+ 'CUSTOM_REMINDER_PRESETS',
1150
1625
  ),
1151
1626
  'chronologicalEventForm.reminderPresets.allDay': def<AllDayPreset[]>(
1152
1627
  'chronologicalEventForm',
@@ -1156,6 +1631,7 @@ function buildEntries(config: RegistryConfig) {
1156
1631
  { daysBefore: 1, time: '18:00' },
1157
1632
  { daysBefore: 2, time: '09:00' },
1158
1633
  ],
1634
+ 'CUSTOM_REMINDER_PRESETS',
1159
1635
  ),
1160
1636
  } as const satisfies Record<string, SettingDef>;
1161
1637
  }
@@ -1232,8 +1708,21 @@ export class SettingsRegistry {
1232
1708
  case 'number':
1233
1709
  return String(value ?? 0);
1234
1710
  case 'boolean':
1711
+ if (typeof value === 'string') {
1712
+ return value === 'true' ? 'true' : 'false';
1713
+ }
1235
1714
  return value ? 'true' : 'false';
1236
1715
  case 'json':
1716
+ // If already a serialized JSON string, return as-is to avoid
1717
+ // double-wrapping (e.g. JSON.stringify("[]") → "\"[]\"")
1718
+ if (typeof value === 'string') {
1719
+ try {
1720
+ JSON.parse(value);
1721
+ return value;
1722
+ } catch {
1723
+ return JSON.stringify(value);
1724
+ }
1725
+ }
1237
1726
  return JSON.stringify(value);
1238
1727
  }
1239
1728
  }
@@ -1257,7 +1746,17 @@ export class SettingsRegistry {
1257
1746
  return (raw === 'true') as SettingValue<K>;
1258
1747
  case 'json':
1259
1748
  try {
1260
- return JSON.parse(raw) as SettingValue<K>;
1749
+ let result: unknown = JSON.parse(raw);
1750
+ // Unwrap multiply-escaped JSON strings caused by repeated
1751
+ // double-serialization (each push/pull cycle added a layer)
1752
+ while (typeof result === 'string') {
1753
+ try {
1754
+ result = JSON.parse(result);
1755
+ } catch {
1756
+ break;
1757
+ }
1758
+ }
1759
+ return result as SettingValue<K>;
1261
1760
  } catch {
1262
1761
  return this.getDefault(key);
1263
1762
  }
@@ -1316,6 +1815,16 @@ export type ParsedSettingEntry = {
1316
1815
  descriptionKey?: string;
1317
1816
  /** The setting value */
1318
1817
  value: unknown;
1818
+ /** UI component type for rendering this setting */
1819
+ uiType: SettingUiType;
1820
+ /** Available options for SELECT-type settings */
1821
+ options?: readonly SettingOption[];
1822
+ /** Slider configuration for SLIDER-type settings */
1823
+ sliderConfig?: SliderConfig;
1824
+ /** Only show this setting for enrolled/kiosk devices (undefined = always) */
1825
+ appMode?: 'ENROLLED';
1826
+ /** Only show this setting for a specific calendar type (undefined = always) */
1827
+ calendarType?: CalendarType;
1319
1828
  };
1320
1829
 
1321
1830
  export type ParsedSettingsGroup = {
@@ -1374,6 +1883,9 @@ export function parseSettingsSnapshot(
1374
1883
  }
1375
1884
 
1376
1885
  const labelDef = SETTINGS_LABELS[key];
1886
+ const entryDef = key in registry.entries
1887
+ ? registry.entries[key as SettingKey]
1888
+ : undefined;
1377
1889
  groups.get(category)!.push({
1378
1890
  key,
1379
1891
  name,
@@ -1381,6 +1893,11 @@ export function parseSettingsSnapshot(
1381
1893
  labelKey: labelDef?.labelKey,
1382
1894
  descriptionKey: labelDef?.descriptionKey,
1383
1895
  value,
1896
+ uiType: entryDef?.uiType ?? 'TEXT_INPUT',
1897
+ ...(entryDef?.options && { options: entryDef.options as readonly SettingOption[] }),
1898
+ ...(entryDef?.sliderConfig && { sliderConfig: entryDef.sliderConfig }),
1899
+ ...(entryDef?.appMode && { appMode: entryDef.appMode }),
1900
+ ...(entryDef?.calendarType && { calendarType: entryDef.calendarType }),
1384
1901
  });
1385
1902
  }
1386
1903
 
@@ -1424,6 +1941,51 @@ export function formatSettingValue(value: unknown): string {
1424
1941
  return String(value);
1425
1942
  }
1426
1943
 
1944
+ /**
1945
+ * Serialize a settings object (with mixed native types) into a
1946
+ * string-values-only snapshot suitable for pushing to a mobile device.
1947
+ *
1948
+ * Uses the registry's `serialize()` method for known keys so that
1949
+ * booleans become "true"/"false", numbers become digit strings, etc.
1950
+ * Unknown keys are converted with `String(value)`.
1951
+ */
1952
+ export function serializeSettingsSnapshot(
1953
+ settings: Record<string, unknown>,
1954
+ registry: SettingsRegistry,
1955
+ ): Record<string, string> {
1956
+ const result: Record<string, string> = {};
1957
+ for (const [key, value] of Object.entries(settings)) {
1958
+ if (key in registry.entries) {
1959
+ result[key] = registry.serialize(key as SettingKey, value);
1960
+ } else {
1961
+ result[key] = String(value ?? '');
1962
+ }
1963
+ }
1964
+ return result;
1965
+ }
1966
+
1967
+ /**
1968
+ * Deserialize a settings snapshot (string values from the server/DB) into
1969
+ * native-typed values using the registry.
1970
+ *
1971
+ * This ensures local state always contains native types (boolean, number, etc.)
1972
+ * so that subsequent `serializeSettingsSnapshot` calls produce correct results.
1973
+ */
1974
+ export function deserializeSettingsSnapshot(
1975
+ snapshot: Record<string, unknown>,
1976
+ registry: SettingsRegistry = defaultRegistry,
1977
+ ): Record<string, unknown> {
1978
+ const result: Record<string, unknown> = {};
1979
+ for (const [key, value] of Object.entries(snapshot)) {
1980
+ if (typeof value === 'string' && key in registry.entries) {
1981
+ result[key] = registry.deserialize(key as SettingKey, value);
1982
+ } else {
1983
+ result[key] = value;
1984
+ }
1985
+ }
1986
+ return result;
1987
+ }
1988
+
1427
1989
  // ---------------------------------------------------------------------------
1428
1990
  // Default registry instance (non-enrolled, system theme, nb locale)
1429
1991
  // ---------------------------------------------------------------------------