openuispec 0.1.25 → 0.1.27
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/README.md +44 -2
- package/cli/index.ts +21 -3
- package/cli/init.ts +24 -8
- package/docs/implementation-notes.md +115 -0
- package/docs/release-notes-v0.1.26.md +64 -0
- package/docs/release-notes-v0.1.27.md +28 -0
- package/drift/index.ts +375 -18
- package/examples/todo-orbit/AGENTS.md +11 -4
- package/examples/todo-orbit/CLAUDE.md +11 -4
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +69 -18
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +5 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +5 -2
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +1 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +3 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +2 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +1 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +1 -1
- package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +59 -6
- package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +40 -0
- package/examples/todo-orbit/openuispec/README.md +24 -131
- package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +3 -0
- package/examples/todo-orbit/openuispec/flows/create_task.yaml +1 -0
- package/examples/todo-orbit/openuispec/flows/edit_task.yaml +1 -0
- package/examples/todo-orbit/openuispec/locales/en.json +1 -0
- package/examples/todo-orbit/openuispec/locales/ru.json +1 -0
- package/examples/todo-orbit/openuispec/screens/task_detail.yaml +1 -0
- package/examples/todo-orbit/openuispec/tokens/icons.yaml +6 -0
- package/package.json +6 -1
- package/prepare/index.ts +391 -0
- package/schema/semantic-lint.ts +592 -0
- package/schema/validate.ts +8 -9
- package/status/index.ts +187 -0
|
@@ -4,8 +4,6 @@ import androidx.compose.foundation.background
|
|
|
4
4
|
import androidx.compose.foundation.layout.Arrangement
|
|
5
5
|
import androidx.compose.foundation.layout.Box
|
|
6
6
|
import androidx.compose.foundation.layout.Column
|
|
7
|
-
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
8
|
-
import androidx.compose.foundation.layout.FlowRow
|
|
9
7
|
import androidx.compose.foundation.layout.Row
|
|
10
8
|
import androidx.compose.foundation.layout.Spacer
|
|
11
9
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
@@ -13,19 +11,27 @@ import androidx.compose.foundation.layout.padding
|
|
|
13
11
|
import androidx.compose.foundation.layout.size
|
|
14
12
|
import androidx.compose.foundation.layout.width
|
|
15
13
|
import androidx.compose.foundation.shape.CircleShape
|
|
16
|
-
import androidx.compose.material.icons.Icons
|
|
17
|
-
import androidx.compose.material.icons.outlined.Translate
|
|
18
14
|
import androidx.compose.material3.Card
|
|
19
15
|
import androidx.compose.material3.CardDefaults
|
|
16
|
+
import androidx.compose.material3.DropdownMenu
|
|
17
|
+
import androidx.compose.material3.DropdownMenuItem
|
|
20
18
|
import androidx.compose.material3.ElevatedCard
|
|
21
|
-
import androidx.compose.material3.
|
|
22
|
-
import androidx.compose.material3.
|
|
19
|
+
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
20
|
+
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
21
|
+
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|
23
22
|
import androidx.compose.material3.MaterialTheme
|
|
24
23
|
import androidx.compose.material3.OutlinedCard
|
|
25
24
|
import androidx.compose.material3.OutlinedTextField
|
|
25
|
+
import androidx.compose.material3.SegmentedButton
|
|
26
|
+
import androidx.compose.material3.SegmentedButtonDefaults
|
|
27
|
+
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
26
28
|
import androidx.compose.material3.Switch
|
|
27
29
|
import androidx.compose.material3.Text
|
|
28
30
|
import androidx.compose.runtime.Composable
|
|
31
|
+
import androidx.compose.runtime.getValue
|
|
32
|
+
import androidx.compose.runtime.mutableStateOf
|
|
33
|
+
import androidx.compose.runtime.remember
|
|
34
|
+
import androidx.compose.runtime.setValue
|
|
29
35
|
import androidx.compose.ui.Alignment
|
|
30
36
|
import androidx.compose.ui.Modifier
|
|
31
37
|
import androidx.compose.ui.graphics.Color
|
|
@@ -75,9 +81,7 @@ fun LanguageSelector(current: UiLocale, onSelected: (UiLocale) -> Unit) {
|
|
|
75
81
|
UiLocale.En.name to stringResource(R.string.settings_language_en),
|
|
76
82
|
UiLocale.Ru.name to stringResource(R.string.settings_language_ru)
|
|
77
83
|
),
|
|
78
|
-
|
|
79
|
-
Icon(Icons.Outlined.Translate, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
80
|
-
},
|
|
84
|
+
style = EnumSelectorStyle.Segmented,
|
|
81
85
|
onSelected = { selected -> onSelected(UiLocale.entries.first { it.name == selected }) }
|
|
82
86
|
)
|
|
83
87
|
}
|
|
@@ -91,29 +95,76 @@ fun ThemeSelector(current: ThemeMode, onSelected: (ThemeMode) -> Unit) {
|
|
|
91
95
|
ThemeMode.Light.name to stringResource(R.string.settings_theme_light),
|
|
92
96
|
ThemeMode.Dark.name to stringResource(R.string.settings_theme_dark)
|
|
93
97
|
),
|
|
98
|
+
style = EnumSelectorStyle.Segmented,
|
|
94
99
|
onSelected = { selected -> onSelected(ThemeMode.entries.first { it.name == selected }) }
|
|
95
100
|
)
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
|
|
103
|
+
enum class EnumSelectorStyle {
|
|
104
|
+
Segmented,
|
|
105
|
+
Dropdown
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@OptIn(ExperimentalMaterial3Api::class)
|
|
99
109
|
@Composable
|
|
100
110
|
fun EnumSelector(
|
|
101
111
|
title: String,
|
|
102
112
|
current: String,
|
|
103
113
|
options: List<Pair<String, String>>,
|
|
114
|
+
style: EnumSelectorStyle = EnumSelectorStyle.Segmented,
|
|
104
115
|
leadingIcon: @Composable (() -> Unit)? = null,
|
|
105
116
|
onSelected: (String) -> Unit
|
|
106
117
|
) {
|
|
107
118
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
108
119
|
Text(title, style = MaterialTheme.typography.titleSmall)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
when (style) {
|
|
121
|
+
EnumSelectorStyle.Segmented -> {
|
|
122
|
+
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
123
|
+
options.forEachIndexed { index, (value, label) ->
|
|
124
|
+
SegmentedButton(
|
|
125
|
+
selected = value == current,
|
|
126
|
+
onClick = { onSelected(value) },
|
|
127
|
+
shape = SegmentedButtonDefaults.itemShape(index = index, count = options.size),
|
|
128
|
+
label = { Text(label) }
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
EnumSelectorStyle.Dropdown -> {
|
|
135
|
+
var expanded by remember { mutableStateOf(false) }
|
|
136
|
+
val selectedLabel = options.firstOrNull { it.first == current }?.second.orEmpty()
|
|
137
|
+
ExposedDropdownMenuBox(
|
|
138
|
+
expanded = expanded,
|
|
139
|
+
onExpandedChange = { expanded = !expanded }
|
|
140
|
+
) {
|
|
141
|
+
OutlinedTextField(
|
|
142
|
+
value = selectedLabel,
|
|
143
|
+
onValueChange = {},
|
|
144
|
+
modifier = Modifier
|
|
145
|
+
.menuAnchor()
|
|
146
|
+
.fillMaxWidth(),
|
|
147
|
+
readOnly = true,
|
|
148
|
+
label = { Text(title) },
|
|
149
|
+
leadingIcon = leadingIcon,
|
|
150
|
+
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
|
151
|
+
shape = MaterialTheme.shapes.medium
|
|
152
|
+
)
|
|
153
|
+
DropdownMenu(
|
|
154
|
+
expanded = expanded,
|
|
155
|
+
onDismissRequest = { expanded = false }
|
|
156
|
+
) {
|
|
157
|
+
options.forEach { (value, label) ->
|
|
158
|
+
DropdownMenuItem(
|
|
159
|
+
text = { Text(label) },
|
|
160
|
+
onClick = {
|
|
161
|
+
expanded = false
|
|
162
|
+
onSelected(value)
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
117
168
|
}
|
|
118
169
|
}
|
|
119
170
|
}
|
|
@@ -235,6 +235,11 @@ fun TaskDetailPane(
|
|
|
235
235
|
item {
|
|
236
236
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
|
237
237
|
Column(modifier = Modifier.fillMaxWidth(0.72f)) {
|
|
238
|
+
Text(
|
|
239
|
+
stringResource(R.string.task_detail_title),
|
|
240
|
+
style = MaterialTheme.typography.labelLarge,
|
|
241
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
242
|
+
)
|
|
238
243
|
Text(task.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
|
239
244
|
Text(
|
|
240
245
|
task.dueDate?.let { formatAbsolute(it, locale) } ?: stringResource(R.string.task_detail_no_due_date),
|
|
@@ -80,6 +80,7 @@ fun TaskEditorSheet(
|
|
|
80
80
|
title = stringResource(R.string.create_task_field_priority),
|
|
81
81
|
current = localDraft.priority.name,
|
|
82
82
|
options = priorityOptions,
|
|
83
|
+
style = EnumSelectorStyle.Segmented,
|
|
83
84
|
onSelected = { selected ->
|
|
84
85
|
localDraft = localDraft.copy(priority = Priority.entries.first { it.name == selected })
|
|
85
86
|
}
|
|
@@ -185,11 +186,11 @@ fun RecurringRuleSheet(
|
|
|
185
186
|
title = stringResource(R.string.recurring_rule_field_cadence),
|
|
186
187
|
current = draft.cadence?.name.orEmpty(),
|
|
187
188
|
options = listOf(
|
|
188
|
-
"" to "—",
|
|
189
189
|
Cadence.Daily.name to dailyCadenceLabel,
|
|
190
190
|
Cadence.Weekly.name to weeklyCadenceLabel,
|
|
191
191
|
Cadence.Monthly.name to monthlyCadenceLabel
|
|
192
192
|
),
|
|
193
|
+
style = EnumSelectorStyle.Segmented,
|
|
193
194
|
onSelected = {
|
|
194
195
|
draft = draft.copy(
|
|
195
196
|
cadence = Cadence.entries.firstOrNull { entry -> entry.name == it },
|
|
@@ -211,6 +212,7 @@ fun RecurringRuleSheet(
|
|
|
211
212
|
title = stringResource(R.string.recurring_rule_field_weekday),
|
|
212
213
|
current = draft.weekday?.name.orEmpty(),
|
|
213
214
|
options = weekdayOptions,
|
|
215
|
+
style = EnumSelectorStyle.Dropdown,
|
|
214
216
|
onSelected = { draft = draft.copy(weekday = Weekday.entries.firstOrNull { day -> day.name == it }) }
|
|
215
217
|
)
|
|
216
218
|
}
|
|
@@ -266,7 +268,8 @@ fun RecurringRuleSheet(
|
|
|
266
268
|
EnumSelector(
|
|
267
269
|
title = stringResource(R.string.recurring_rule_field_summary_channel),
|
|
268
270
|
current = draft.summaryChannel?.name.orEmpty(),
|
|
269
|
-
options = summaryChannelOptions,
|
|
271
|
+
options = summaryChannelOptions.drop(1),
|
|
272
|
+
style = EnumSelectorStyle.Segmented,
|
|
270
273
|
onSelected = {
|
|
271
274
|
draft = draft.copy(summaryChannel = SummaryChannel.entries.firstOrNull { item -> item.name == it })
|
|
272
275
|
}
|
package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<string name="home_empty_title">Nothing to do</string>
|
|
19
19
|
<string name="home_empty_body">Add a task or switch filters to see more items.</string>
|
|
20
20
|
<string name="task_detail_no_due_date">No deadline</string>
|
|
21
|
+
<string name="task_detail_title">Task details</string>
|
|
21
22
|
<string name="task_detail_status">Status</string>
|
|
22
23
|
<string name="task_detail_priority">Priority</string>
|
|
23
24
|
<string name="task_detail_notes">Notes</string>
|
package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<string name="home_empty_title">Список пуст</string>
|
|
19
19
|
<string name="home_empty_body">Добавьте задачу или смените фильтр, чтобы увидеть элементы.</string>
|
|
20
20
|
<string name="task_detail_no_due_date">Без срока</string>
|
|
21
|
+
<string name="task_detail_title">Детали задачи</string>
|
|
21
22
|
<string name="task_detail_status">Статус</string>
|
|
22
23
|
<string name="task_detail_priority">Приоритет</string>
|
|
23
24
|
<string name="task_detail_notes">Заметки</string>
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"analytics.overdue_section" = "Overdue review";
|
|
27
27
|
"analytics.overdue_subtitle" = "Tasks that need attention first.";
|
|
28
28
|
"analytics.empty_overdue_body" = "Everything important is on track.";
|
|
29
|
+
"task_detail.title" = "Task details";
|
|
29
30
|
"task_detail.status" = "Status";
|
|
30
31
|
"task_detail.priority" = "Priority";
|
|
31
32
|
"task_detail.notes" = "Notes";
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"analytics.overdue_section" = "Просроченные задачи";
|
|
27
27
|
"analytics.overdue_subtitle" = "Задачи, которым нужно уделить внимание в первую очередь.";
|
|
28
28
|
"analytics.empty_overdue_body" = "Все важные задачи идут по плану.";
|
|
29
|
+
"task_detail.title" = "Детали задачи";
|
|
29
30
|
"task_detail.status" = "Статус";
|
|
30
31
|
"task_detail.priority" = "Приоритет";
|
|
31
32
|
"task_detail.notes" = "Заметки";
|
|
@@ -34,6 +34,7 @@ struct RecurringRuleSheet: View {
|
|
|
34
34
|
.tag(RecurrenceCadence?.some(cadence))
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
.pickerStyle(.segmented)
|
|
37
38
|
errorText("cadence")
|
|
38
39
|
|
|
39
40
|
TextField(model.string("recurring_rule.field_interval"), text: $draft.interval)
|
|
@@ -47,6 +48,7 @@ struct RecurringRuleSheet: View {
|
|
|
47
48
|
Text(model.label(for: weekday)).tag(Weekday?.some(weekday))
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
.pickerStyle(.menu)
|
|
50
52
|
errorText("weekday")
|
|
51
53
|
}
|
|
52
54
|
|
|
@@ -79,6 +81,7 @@ struct RecurringRuleSheet: View {
|
|
|
79
81
|
.tag(SummaryChannel?.some(channel))
|
|
80
82
|
}
|
|
81
83
|
}
|
|
84
|
+
.pickerStyle(.segmented)
|
|
82
85
|
errorText("summaryChannel")
|
|
83
86
|
}
|
|
84
87
|
}
|
package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift
CHANGED
|
@@ -20,11 +20,13 @@ struct SettingsView: View {
|
|
|
20
20
|
Text(model.string("settings.language_en")).tag(AppLocale.en)
|
|
21
21
|
Text(model.string("settings.language_ru")).tag(AppLocale.ru)
|
|
22
22
|
}
|
|
23
|
+
.pickerStyle(.segmented)
|
|
23
24
|
|
|
24
25
|
Picker(model.string("settings.theme"), selection: $draft.theme) {
|
|
25
26
|
Text(model.string("settings.theme_light")).tag(ThemePreference.light)
|
|
26
27
|
Text(model.string("settings.theme_dark")).tag(ThemePreference.dark)
|
|
27
28
|
}
|
|
29
|
+
.pickerStyle(.segmented)
|
|
28
30
|
|
|
29
31
|
Toggle(model.string("settings.reminders"), isOn: $draft.remindersEnabled)
|
|
30
32
|
Toggle(model.string("settings.daily_summary"), isOn: $draft.dailySummaryEnabled)
|
package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift
CHANGED
|
@@ -286,7 +286,7 @@ final class AppModel: ObservableObject {
|
|
|
286
286
|
guard let date else { return string("task_detail.no_due_date") }
|
|
287
287
|
let formatter = RelativeDateTimeFormatter()
|
|
288
288
|
formatter.locale = locale
|
|
289
|
-
formatter.unitsStyle = .
|
|
289
|
+
formatter.unitsStyle = .abbreviated
|
|
290
290
|
return formatter.localizedString(for: date, relativeTo: .now)
|
|
291
291
|
}
|
|
292
292
|
|
|
@@ -105,6 +105,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
105
105
|
"analytics.empty_trend": "No trend data yet.",
|
|
106
106
|
"analytics.empty_overdue": "No overdue tasks",
|
|
107
107
|
"analytics.empty_overdue_body": "Everything important is on track.",
|
|
108
|
+
"task_detail.title": "Task details",
|
|
108
109
|
"task_detail.status": "Status",
|
|
109
110
|
"task_detail.priority": "Priority",
|
|
110
111
|
"task_detail.notes": "Notes",
|
|
@@ -249,6 +250,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|
|
249
250
|
"analytics.empty_trend": "Данные тренда пока отсутствуют.",
|
|
250
251
|
"analytics.empty_overdue": "Просроченных задач нет",
|
|
251
252
|
"analytics.empty_overdue_body": "Все важные задачи идут по плану.",
|
|
253
|
+
"task_detail.title": "Детали задачи",
|
|
252
254
|
"task_detail.status": "Статус",
|
|
253
255
|
"task_detail.priority": "Приоритет",
|
|
254
256
|
"task_detail.notes": "Заметки",
|
|
@@ -617,6 +619,7 @@ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void
|
|
|
617
619
|
const navigate = useNavigate();
|
|
618
620
|
const isDesktop = useIsDesktop();
|
|
619
621
|
const t = useTranslator();
|
|
622
|
+
useDocumentTitle(t("nav.tasks"));
|
|
620
623
|
const filteredTasks = useMemo(
|
|
621
624
|
() => filterTasks(tasks, activeFilter, deferredSearch),
|
|
622
625
|
[activeFilter, deferredSearch, tasks]
|
|
@@ -720,7 +723,7 @@ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void
|
|
|
720
723
|
|
|
721
724
|
<div className="task-copy">
|
|
722
725
|
<strong>{task.title}</strong>
|
|
723
|
-
<span>{formatRelativeDate(task.dueDate, useAppStore.getState().locale, t("task_detail.no_due_date"))}</span>
|
|
726
|
+
<span>{formatRelativeDate(task.dueDate, useAppStore.getState().locale, t("task_detail.no_due_date"), "short")}</span>
|
|
724
727
|
</div>
|
|
725
728
|
|
|
726
729
|
<span className="priority-dot" style={{ background: priorityAccent[task.priority] }} />
|
|
@@ -743,6 +746,8 @@ function HomeScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => void
|
|
|
743
746
|
function TaskDetailRoute({ onOpenModal }: { onOpenModal: (modal: ModalState) => void }) {
|
|
744
747
|
const { taskId } = useParams();
|
|
745
748
|
const task = useAppStore((state) => state.tasks.find((entry) => entry.id === taskId));
|
|
749
|
+
const t = useTranslator();
|
|
750
|
+
useDocumentTitle(t("task_detail.title"));
|
|
746
751
|
|
|
747
752
|
if (!task) {
|
|
748
753
|
return <Navigate to="/" replace />;
|
|
@@ -840,6 +845,7 @@ function AnalyticsScreen() {
|
|
|
840
845
|
const [period, setPeriod] = useState<Period>("week");
|
|
841
846
|
const t = useTranslator();
|
|
842
847
|
const locale = useAppStore((state) => state.locale);
|
|
848
|
+
useDocumentTitle(t("nav.analytics"));
|
|
843
849
|
const overview = getAnalyticsOverview(tasks);
|
|
844
850
|
const trend = getTrendSeries(tasks, period, locale);
|
|
845
851
|
const overdue = getOverdueTasks(tasks);
|
|
@@ -917,6 +923,7 @@ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => v
|
|
|
917
923
|
const savePreferences = useAppStore((state) => state.savePreferences);
|
|
918
924
|
const pushToast = useAppStore((state) => state.pushToast);
|
|
919
925
|
const t = useTranslator();
|
|
926
|
+
useDocumentTitle(t("nav.settings"));
|
|
920
927
|
const [form, setForm] = useState(preferences);
|
|
921
928
|
|
|
922
929
|
useEffect(() => {
|
|
@@ -935,7 +942,7 @@ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => v
|
|
|
935
942
|
|
|
936
943
|
<div className="settings-grid">
|
|
937
944
|
<section className="cut-surface form-stack">
|
|
938
|
-
<
|
|
945
|
+
<SegmentedField
|
|
939
946
|
label={t("settings.language")}
|
|
940
947
|
value={form.locale}
|
|
941
948
|
onChange={(value) => setForm((current) => ({ ...current, locale: value as Locale }))}
|
|
@@ -945,7 +952,7 @@ function SettingsScreen({ onOpenModal }: { onOpenModal: (modal: ModalState) => v
|
|
|
945
952
|
]}
|
|
946
953
|
/>
|
|
947
954
|
|
|
948
|
-
<
|
|
955
|
+
<SegmentedField
|
|
949
956
|
label={t("settings.theme")}
|
|
950
957
|
value={form.theme}
|
|
951
958
|
onChange={(value) => setForm((current) => ({ ...current, theme: value as Theme }))}
|
|
@@ -1067,7 +1074,7 @@ function TaskFormModal({
|
|
|
1067
1074
|
onChange={setNotes}
|
|
1068
1075
|
placeholder={t("create_task.field_notes_placeholder")}
|
|
1069
1076
|
/>
|
|
1070
|
-
<
|
|
1077
|
+
<SegmentedField
|
|
1071
1078
|
label={taskId ? t("edit_task.field_priority") : t("create_task.field_priority")}
|
|
1072
1079
|
value={priority}
|
|
1073
1080
|
onChange={(value) => setPriority(value as Priority)}
|
|
@@ -1218,7 +1225,7 @@ function RecurringRuleModal({ onClose }: { onClose: () => void }) {
|
|
|
1218
1225
|
error={confirmTouched ? errors.confirmName : ""}
|
|
1219
1226
|
/>
|
|
1220
1227
|
|
|
1221
|
-
<
|
|
1228
|
+
<SegmentedField
|
|
1222
1229
|
label={t("recurring_rule.field_cadence")}
|
|
1223
1230
|
value={draft.cadence}
|
|
1224
1231
|
onChange={(value) =>
|
|
@@ -1316,7 +1323,7 @@ function RecurringRuleModal({ onClose }: { onClose: () => void }) {
|
|
|
1316
1323
|
/>
|
|
1317
1324
|
|
|
1318
1325
|
{draft.enableSummary ? (
|
|
1319
|
-
<
|
|
1326
|
+
<SegmentedField
|
|
1320
1327
|
label={t("recurring_rule.field_summary_channel")}
|
|
1321
1328
|
value={draft.summaryChannel}
|
|
1322
1329
|
onChange={(value) =>
|
|
@@ -1677,6 +1684,46 @@ function SelectField({
|
|
|
1677
1684
|
);
|
|
1678
1685
|
}
|
|
1679
1686
|
|
|
1687
|
+
function SegmentedField({
|
|
1688
|
+
label,
|
|
1689
|
+
value,
|
|
1690
|
+
onChange,
|
|
1691
|
+
options,
|
|
1692
|
+
error
|
|
1693
|
+
}: {
|
|
1694
|
+
label: string;
|
|
1695
|
+
value: string;
|
|
1696
|
+
onChange: (value: string) => void;
|
|
1697
|
+
options: Array<{ label: string; value: string }>;
|
|
1698
|
+
error?: string;
|
|
1699
|
+
}) {
|
|
1700
|
+
return (
|
|
1701
|
+
<div className="field-block">
|
|
1702
|
+
<span className="field-label">{label}</span>
|
|
1703
|
+
<div className={`segmented-control ${error ? "error" : ""}`} role="radiogroup" aria-label={label}>
|
|
1704
|
+
{options
|
|
1705
|
+
.filter((option) => option.value !== "")
|
|
1706
|
+
.map((option) => {
|
|
1707
|
+
const selected = option.value === value;
|
|
1708
|
+
return (
|
|
1709
|
+
<button
|
|
1710
|
+
key={option.value}
|
|
1711
|
+
aria-checked={selected}
|
|
1712
|
+
className={`segmented-option ${selected ? "selected" : ""}`}
|
|
1713
|
+
onClick={() => onChange(option.value)}
|
|
1714
|
+
role="radio"
|
|
1715
|
+
type="button"
|
|
1716
|
+
>
|
|
1717
|
+
{option.label}
|
|
1718
|
+
</button>
|
|
1719
|
+
);
|
|
1720
|
+
})}
|
|
1721
|
+
</div>
|
|
1722
|
+
{error ? <span className="field-error">{error}</span> : null}
|
|
1723
|
+
</div>
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1680
1727
|
function DateField({
|
|
1681
1728
|
label,
|
|
1682
1729
|
value,
|
|
@@ -1774,6 +1821,12 @@ function NavItem({
|
|
|
1774
1821
|
);
|
|
1775
1822
|
}
|
|
1776
1823
|
|
|
1824
|
+
function useDocumentTitle(title: string) {
|
|
1825
|
+
useEffect(() => {
|
|
1826
|
+
document.title = `${title} | Todo Orbit`;
|
|
1827
|
+
}, [title]);
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1777
1830
|
function useTranslator() {
|
|
1778
1831
|
const locale = useAppStore((state) => state.locale);
|
|
1779
1832
|
return (key: string, params?: Record<string, string | number>) => {
|
|
@@ -323,6 +323,46 @@ button {
|
|
|
323
323
|
opacity: 0.5;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
.segmented-control,
|
|
327
|
+
.segmented-inline {
|
|
328
|
+
display: flex;
|
|
329
|
+
flex-wrap: wrap;
|
|
330
|
+
gap: 8px;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.segmented-control {
|
|
334
|
+
padding: 6px;
|
|
335
|
+
border: 1px solid var(--border);
|
|
336
|
+
border-radius: 20px;
|
|
337
|
+
background: rgba(255, 255, 255, 0.74);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.segmented-control.error {
|
|
341
|
+
border-color: rgba(220, 38, 38, 0.4);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.segmented-option {
|
|
345
|
+
appearance: none;
|
|
346
|
+
-webkit-appearance: none;
|
|
347
|
+
border: 0;
|
|
348
|
+
outline: none;
|
|
349
|
+
border-radius: 14px;
|
|
350
|
+
background: transparent;
|
|
351
|
+
color: var(--text-secondary);
|
|
352
|
+
font-weight: 700;
|
|
353
|
+
padding: 10px 14px;
|
|
354
|
+
transition: background 180ms ease, color 180ms ease, transform 180ms ease;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.segmented-option:hover {
|
|
358
|
+
transform: translateY(-1px);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.segmented-option.selected {
|
|
362
|
+
background: rgba(15, 118, 110, 0.12);
|
|
363
|
+
color: var(--text);
|
|
364
|
+
}
|
|
365
|
+
|
|
326
366
|
.input-shell input,
|
|
327
367
|
.input-shell select,
|
|
328
368
|
.input-shell textarea {
|