openuispec 0.1.18 → 0.1.20

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.
Files changed (98) hide show
  1. package/README.md +52 -34
  2. package/cli/index.ts +1 -1
  3. package/cli/init.ts +48 -211
  4. package/docs/stress-test-maturity-report.md +97 -0
  5. package/examples/todo-orbit/AGENTS.md +127 -0
  6. package/examples/todo-orbit/CLAUDE.md +75 -0
  7. package/examples/todo-orbit/README.md +62 -0
  8. package/examples/todo-orbit/generated/android/Todo Orbit/README.md +14 -0
  9. package/examples/todo-orbit/generated/android/Todo Orbit/app/build.gradle.kts +58 -0
  10. package/examples/todo-orbit/generated/android/Todo Orbit/app/proguard-rules.pro +1 -0
  11. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/AndroidManifest.xml +20 -0
  12. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/MainActivity.kt +14 -0
  13. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/TodoOrbitApp.kt +345 -0
  14. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/AppLogic.kt +231 -0
  15. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Models.kt +169 -0
  16. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Strings.kt +8 -0
  17. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +185 -0
  18. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/AnalyticsScreen.kt +193 -0
  19. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/SettingsScreen.kt +102 -0
  20. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +342 -0
  21. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +344 -0
  22. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/theme/TodoOrbitTheme.kt +59 -0
  23. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +148 -0
  24. package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +154 -0
  25. package/examples/todo-orbit/generated/android/Todo Orbit/build.gradle.kts +4 -0
  26. package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.jar +0 -0
  27. package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.properties +7 -0
  28. package/examples/todo-orbit/generated/android/Todo Orbit/gradle.properties +4 -0
  29. package/examples/todo-orbit/generated/android/Todo Orbit/gradlew +248 -0
  30. package/examples/todo-orbit/generated/android/Todo Orbit/gradlew.bat +93 -0
  31. package/examples/todo-orbit/generated/android/Todo Orbit/settings.gradle.kts +18 -0
  32. package/examples/todo-orbit/generated/ios/Todo Orbit/README.md +29 -0
  33. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +118 -0
  34. package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +118 -0
  35. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/App/TodoOrbitApp.swift +50 -0
  36. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/OrbitChrome.swift +204 -0
  37. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/SchedulePreviewView.swift +126 -0
  38. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/TrendChartView.swift +70 -0
  39. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +123 -0
  40. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +60 -0
  41. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Models/DomainModels.swift +238 -0
  42. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/AnalyticsView.swift +94 -0
  43. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +74 -0
  44. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +363 -0
  45. package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +324 -0
  46. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +408 -0
  47. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  48. package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +79 -0
  49. package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +24 -0
  50. package/examples/todo-orbit/generated/web/Todo Orbit/index.html +16 -0
  51. package/examples/todo-orbit/generated/web/Todo Orbit/package-lock.json +1087 -0
  52. package/examples/todo-orbit/generated/web/Todo Orbit/package.json +24 -0
  53. package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +2114 -0
  54. package/examples/todo-orbit/generated/web/Todo Orbit/src/main.tsx +13 -0
  55. package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +886 -0
  56. package/examples/todo-orbit/generated/web/Todo Orbit/tsconfig.json +19 -0
  57. package/examples/todo-orbit/generated/web/Todo Orbit/vite.config.ts +6 -0
  58. package/examples/todo-orbit/openuispec/README.md +158 -0
  59. package/examples/todo-orbit/openuispec/contracts/.gitkeep +0 -0
  60. package/examples/todo-orbit/openuispec/contracts/action_trigger.yaml +28 -0
  61. package/examples/todo-orbit/openuispec/contracts/collection.yaml +32 -0
  62. package/examples/todo-orbit/openuispec/contracts/data_display.yaml +38 -0
  63. package/examples/todo-orbit/openuispec/contracts/feedback.yaml +32 -0
  64. package/examples/todo-orbit/openuispec/contracts/input_field.yaml +52 -0
  65. package/examples/todo-orbit/openuispec/contracts/nav_container.yaml +47 -0
  66. package/examples/todo-orbit/openuispec/contracts/surface.yaml +28 -0
  67. package/examples/todo-orbit/openuispec/contracts/x_schedule_preview.yaml +134 -0
  68. package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +139 -0
  69. package/examples/todo-orbit/openuispec/flows/.gitkeep +0 -0
  70. package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +253 -0
  71. package/examples/todo-orbit/openuispec/flows/create_task.yaml +118 -0
  72. package/examples/todo-orbit/openuispec/flows/edit_task.yaml +126 -0
  73. package/examples/todo-orbit/openuispec/locales/.gitkeep +0 -0
  74. package/examples/todo-orbit/openuispec/locales/en.json +150 -0
  75. package/examples/todo-orbit/openuispec/locales/ru.json +150 -0
  76. package/examples/todo-orbit/openuispec/openuispec.yaml +122 -0
  77. package/examples/todo-orbit/openuispec/platform/.gitkeep +0 -0
  78. package/examples/todo-orbit/openuispec/platform/android.yaml +19 -0
  79. package/examples/todo-orbit/openuispec/platform/ios.yaml +20 -0
  80. package/examples/todo-orbit/openuispec/platform/web.yaml +22 -0
  81. package/examples/todo-orbit/openuispec/screens/.gitkeep +0 -0
  82. package/examples/todo-orbit/openuispec/screens/analytics.yaml +140 -0
  83. package/examples/todo-orbit/openuispec/screens/home.yaml +173 -0
  84. package/examples/todo-orbit/openuispec/screens/settings.yaml +149 -0
  85. package/examples/todo-orbit/openuispec/screens/task_detail.yaml +223 -0
  86. package/examples/todo-orbit/openuispec/tokens/.gitkeep +0 -0
  87. package/examples/todo-orbit/openuispec/tokens/color.yaml +93 -0
  88. package/examples/todo-orbit/openuispec/tokens/elevation.yaml +25 -0
  89. package/examples/todo-orbit/openuispec/tokens/icons.yaml +92 -0
  90. package/examples/todo-orbit/openuispec/tokens/layout.yaml +107 -0
  91. package/examples/todo-orbit/openuispec/tokens/motion.yaml +39 -0
  92. package/examples/todo-orbit/openuispec/tokens/spacing.yaml +18 -0
  93. package/examples/todo-orbit/openuispec/tokens/themes.yaml +23 -0
  94. package/examples/todo-orbit/openuispec/tokens/typography.yaml +52 -0
  95. package/package.json +1 -1
  96. package/schema/screen.schema.json +9 -0
  97. package/schema/validate.ts +0 -2
  98. package/spec/openuispec-v0.1.md +129 -27
@@ -0,0 +1,342 @@
1
+ package uz.rsteam.todoorbit
2
+
3
+ import androidx.compose.animation.animateContentSize
4
+ import androidx.compose.foundation.ExperimentalFoundationApi
5
+ import androidx.compose.foundation.background
6
+ import androidx.compose.foundation.layout.BoxWithConstraints
7
+ import androidx.compose.foundation.layout.Arrangement
8
+ import androidx.compose.foundation.layout.Box
9
+ import androidx.compose.foundation.layout.Column
10
+ import androidx.compose.foundation.layout.ExperimentalLayoutApi
11
+ import androidx.compose.foundation.layout.PaddingValues
12
+ import androidx.compose.foundation.layout.Row
13
+ import androidx.compose.foundation.layout.Spacer
14
+ import androidx.compose.foundation.layout.fillMaxHeight
15
+ import androidx.compose.foundation.layout.fillMaxSize
16
+ import androidx.compose.foundation.layout.fillMaxWidth
17
+ import androidx.compose.foundation.layout.height
18
+ import androidx.compose.foundation.layout.padding
19
+ import androidx.compose.foundation.layout.size
20
+ import androidx.compose.foundation.layout.width
21
+ import androidx.compose.foundation.lazy.LazyColumn
22
+ import androidx.compose.foundation.lazy.grid.GridCells
23
+ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
24
+ import androidx.compose.foundation.lazy.items
25
+ import androidx.compose.foundation.shape.CircleShape
26
+ import androidx.compose.material.icons.Icons
27
+ import androidx.compose.material.icons.outlined.Add
28
+ import androidx.compose.material.icons.outlined.CheckCircle
29
+ import androidx.compose.material.icons.outlined.Delete
30
+ import androidx.compose.material.icons.outlined.Edit
31
+ import androidx.compose.material.icons.outlined.Info
32
+ import androidx.compose.material.icons.outlined.Sort
33
+ import androidx.compose.material3.AlertDialog
34
+ import androidx.compose.material3.AssistChip
35
+ import androidx.compose.material3.Button
36
+ import androidx.compose.material3.CardDefaults
37
+ import androidx.compose.material3.Checkbox
38
+ import androidx.compose.material3.ElevatedCard
39
+ import androidx.compose.material3.FilterChip
40
+ import androidx.compose.material3.Icon
41
+ import androidx.compose.material3.MaterialTheme
42
+ import androidx.compose.material3.OutlinedButton
43
+ import androidx.compose.material3.OutlinedCard
44
+ import androidx.compose.material3.OutlinedTextField
45
+ import androidx.compose.material3.Text
46
+ import androidx.compose.material3.TextButton
47
+ import androidx.compose.runtime.Composable
48
+ import androidx.compose.runtime.getValue
49
+ import androidx.compose.runtime.mutableStateOf
50
+ import androidx.compose.runtime.remember
51
+ import androidx.compose.runtime.setValue
52
+ import androidx.compose.ui.Alignment
53
+ import androidx.compose.ui.Modifier
54
+ import androidx.compose.ui.res.pluralStringResource
55
+ import androidx.compose.ui.res.stringResource
56
+ import androidx.compose.ui.text.font.FontWeight
57
+ import androidx.compose.ui.text.style.TextOverflow
58
+ import androidx.compose.ui.unit.dp
59
+ import java.time.LocalDate
60
+ import java.time.temporal.ChronoUnit
61
+
62
+ @OptIn(ExperimentalLayoutApi::class)
63
+ @Composable
64
+ fun TasksScreen(
65
+ tasks: List<TaskModel>,
66
+ allTasks: List<TaskModel>,
67
+ filter: TaskFilter,
68
+ searchQuery: String,
69
+ selectedTask: TaskModel?,
70
+ locale: UiLocale,
71
+ useSplitView: Boolean,
72
+ onSearchChange: (String) -> Unit,
73
+ onFilterChange: (TaskFilter) -> Unit,
74
+ onSelectTask: (TaskModel) -> Unit,
75
+ onToggleTask: (TaskModel) -> Unit,
76
+ onCreateTask: () -> Unit,
77
+ onEditTask: (TaskModel) -> Unit,
78
+ onDeleteTask: (TaskModel) -> Unit,
79
+ onMoreInfo: (TaskModel) -> Unit
80
+ ) {
81
+ val counts = remember(allTasks) {
82
+ mapOf(
83
+ TaskFilter.All to allTasks.size,
84
+ TaskFilter.Open to allTasks.count { it.status == TaskStatus.Open },
85
+ TaskFilter.Done to allTasks.count { it.status == TaskStatus.Done }
86
+ )
87
+ }
88
+ val detailPanePadding = 20.dp
89
+
90
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
91
+ val listPaneWidth = if (useSplitView) maxWidth * 0.42f else maxWidth
92
+ val detailPaneWidth = (maxWidth - listPaneWidth - detailPanePadding * 2).coerceAtLeast(0.dp)
93
+
94
+ Row(modifier = Modifier.fillMaxSize()) {
95
+ LazyColumn(
96
+ modifier = Modifier.width(listPaneWidth).fillMaxHeight(),
97
+ verticalArrangement = Arrangement.spacedBy(16.dp),
98
+ contentPadding = PaddingValues(20.dp)
99
+ ) {
100
+ item {
101
+ HeroCard(
102
+ title = stringResource(R.string.home_title),
103
+ subtitle = homeSummaryText(counts[TaskFilter.Open] ?: 0, counts[TaskFilter.All] ?: 0)
104
+ )
105
+ }
106
+ item {
107
+ OutlinedTextField(
108
+ value = searchQuery,
109
+ onValueChange = onSearchChange,
110
+ modifier = Modifier.fillMaxWidth(),
111
+ leadingIcon = { Icon(Icons.Outlined.Sort, contentDescription = null) },
112
+ label = { Text(stringResource(R.string.home_search_label)) },
113
+ placeholder = { Text(stringResource(R.string.home_search_placeholder)) },
114
+ shape = MaterialTheme.shapes.medium
115
+ )
116
+ }
117
+ item {
118
+ androidx.compose.foundation.layout.FlowRow(
119
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
120
+ verticalArrangement = Arrangement.spacedBy(8.dp)
121
+ ) {
122
+ TaskFilter.entries.forEach { current ->
123
+ FilterChip(
124
+ selected = current == filter,
125
+ onClick = { onFilterChange(current) },
126
+ label = {
127
+ val label = when (current) {
128
+ TaskFilter.All -> stringResource(R.string.home_filter_all)
129
+ TaskFilter.Open -> stringResource(R.string.home_filter_open)
130
+ TaskFilter.Done -> stringResource(R.string.home_filter_done)
131
+ }
132
+ Text("$label (${counts[current] ?: 0})")
133
+ }
134
+ )
135
+ }
136
+ }
137
+ }
138
+ if (tasks.isEmpty()) {
139
+ item {
140
+ EmptyCard(
141
+ stringResource(R.string.home_empty_title),
142
+ stringResource(R.string.home_empty_body)
143
+ )
144
+ }
145
+ } else {
146
+ items(tasks, key = { it.id }) { task ->
147
+ TaskRow(
148
+ task = task,
149
+ selected = selectedTask?.id == task.id,
150
+ onSelect = { onSelectTask(task) },
151
+ onToggle = { onToggleTask(task) }
152
+ )
153
+ }
154
+ }
155
+ item { Spacer(Modifier.height(80.dp)) }
156
+ }
157
+
158
+ if (useSplitView && selectedTask != null) {
159
+ TaskDetailPane(
160
+ modifier = Modifier.width(detailPaneWidth).fillMaxHeight().padding(detailPanePadding),
161
+ locale = locale,
162
+ task = selectedTask,
163
+ onEdit = { onEditTask(selectedTask) },
164
+ onToggle = { onToggleTask(selectedTask) },
165
+ onDelete = { onDeleteTask(selectedTask) },
166
+ onMoreInfo = { onMoreInfo(selectedTask) }
167
+ )
168
+ }
169
+ }
170
+ }
171
+
172
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd) {
173
+ Button(onClick = onCreateTask, modifier = Modifier.padding(24.dp), shape = MaterialTheme.shapes.medium) {
174
+ Icon(Icons.Outlined.Add, contentDescription = null)
175
+ Spacer(Modifier.width(8.dp))
176
+ Text(stringResource(R.string.home_new_task))
177
+ }
178
+ }
179
+ }
180
+
181
+ @Composable
182
+ fun TaskRow(
183
+ task: TaskModel,
184
+ selected: Boolean,
185
+ onSelect: () -> Unit,
186
+ onToggle: () -> Unit
187
+ ) {
188
+ val containerColor =
189
+ if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surface
190
+
191
+ OutlinedCard(
192
+ modifier = Modifier.animateContentSize(),
193
+ shape = MaterialTheme.shapes.medium,
194
+ colors = CardDefaults.outlinedCardColors(containerColor = containerColor),
195
+ onClick = onSelect
196
+ ) {
197
+ Row(
198
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
199
+ verticalAlignment = Alignment.CenterVertically,
200
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
201
+ ) {
202
+ Checkbox(checked = task.status == TaskStatus.Done, onCheckedChange = { onToggle() })
203
+ Column(modifier = Modifier.fillMaxWidth(0.85f)) {
204
+ Text(task.title, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
205
+ Text(
206
+ text = task.dueDate?.let { relativeDateText(it) } ?: stringResource(R.string.task_detail_no_due_date),
207
+ style = MaterialTheme.typography.bodySmall,
208
+ color = MaterialTheme.colorScheme.onSurfaceVariant
209
+ )
210
+ }
211
+ Box(modifier = Modifier.size(12.dp).background(task.priority.color, CircleShape))
212
+ }
213
+ }
214
+ }
215
+
216
+ @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
217
+ @Composable
218
+ fun TaskDetailPane(
219
+ modifier: Modifier = Modifier,
220
+ locale: UiLocale,
221
+ task: TaskModel,
222
+ onEdit: () -> Unit,
223
+ onToggle: () -> Unit,
224
+ onDelete: () -> Unit,
225
+ onMoreInfo: () -> Unit
226
+ ) {
227
+ var confirmDelete by remember { mutableStateOf(false) }
228
+
229
+ ElevatedCard(modifier = modifier, shape = MaterialTheme.shapes.large) {
230
+ LazyColumn(
231
+ modifier = Modifier.fillMaxSize(),
232
+ verticalArrangement = Arrangement.spacedBy(16.dp),
233
+ contentPadding = PaddingValues(20.dp)
234
+ ) {
235
+ item {
236
+ Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
237
+ Column(modifier = Modifier.fillMaxWidth(0.72f)) {
238
+ Text(task.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
239
+ Text(
240
+ task.dueDate?.let { formatAbsolute(it, locale) } ?: stringResource(R.string.task_detail_no_due_date),
241
+ color = MaterialTheme.colorScheme.onSurfaceVariant
242
+ )
243
+ }
244
+ AssistChip(
245
+ onClick = {},
246
+ label = { Text(stringResource(task.status.labelRes)) },
247
+ leadingIcon = { Icon(Icons.Outlined.CheckCircle, contentDescription = null) }
248
+ )
249
+ }
250
+ }
251
+ item {
252
+ LazyVerticalGrid(
253
+ columns = GridCells.Fixed(2),
254
+ modifier = Modifier.height(220.dp),
255
+ verticalArrangement = Arrangement.spacedBy(12.dp),
256
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
257
+ userScrollEnabled = false
258
+ ) {
259
+ item { StatCard(stringResource(R.string.task_detail_status), stringResource(task.status.labelRes)) }
260
+ item { StatCard(stringResource(R.string.task_detail_priority), stringResource(task.priority.labelRes)) }
261
+ item { StatCard(stringResource(R.string.task_detail_created), formatAbsolute(task.createdAt.toLocalDate(), locale)) }
262
+ item { StatCard(stringResource(R.string.task_detail_updated), formatAbsolute(task.updatedAt.toLocalDate(), locale)) }
263
+ }
264
+ }
265
+ if (task.notes.isNotBlank()) {
266
+ item {
267
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
268
+ Text(stringResource(R.string.task_detail_notes), style = MaterialTheme.typography.titleMedium)
269
+ Text(task.notes)
270
+ }
271
+ }
272
+ }
273
+ item {
274
+ androidx.compose.foundation.layout.FlowRow(
275
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
276
+ verticalArrangement = Arrangement.spacedBy(8.dp)
277
+ ) {
278
+ Button(onClick = onEdit, shape = MaterialTheme.shapes.medium) {
279
+ Icon(Icons.Outlined.Edit, contentDescription = null)
280
+ Spacer(Modifier.width(8.dp))
281
+ Text(stringResource(R.string.task_detail_edit))
282
+ }
283
+ OutlinedButton(onClick = onToggle, shape = MaterialTheme.shapes.medium) {
284
+ Text(stringResource(R.string.task_detail_toggle_status))
285
+ }
286
+ OutlinedButton(onClick = onMoreInfo, shape = MaterialTheme.shapes.medium) {
287
+ Icon(Icons.Outlined.Info, contentDescription = null)
288
+ Spacer(Modifier.width(8.dp))
289
+ Text(stringResource(R.string.task_detail_more_info))
290
+ }
291
+ OutlinedButton(onClick = { confirmDelete = true }, shape = MaterialTheme.shapes.medium) {
292
+ Icon(Icons.Outlined.Delete, contentDescription = null)
293
+ Spacer(Modifier.width(8.dp))
294
+ Text(stringResource(R.string.task_detail_delete))
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ if (confirmDelete) {
302
+ AlertDialog(
303
+ onDismissRequest = { confirmDelete = false },
304
+ title = { Text(stringResource(R.string.task_detail_delete_title)) },
305
+ text = { Text(stringResource(R.string.task_detail_delete_message)) },
306
+ dismissButton = {
307
+ TextButton(onClick = { confirmDelete = false }) {
308
+ Text(stringResource(R.string.common_cancel))
309
+ }
310
+ },
311
+ confirmButton = {
312
+ TextButton(
313
+ onClick = {
314
+ confirmDelete = false
315
+ onDelete()
316
+ }
317
+ ) {
318
+ Text(stringResource(R.string.common_delete))
319
+ }
320
+ }
321
+ )
322
+ }
323
+ }
324
+
325
+ @Composable
326
+ private fun homeSummaryText(open: Int, total: Int): String {
327
+ return if (open == 0) {
328
+ stringResource(R.string.home_summary_done)
329
+ } else {
330
+ pluralStringResource(R.plurals.home_summary_open, open, open, total)
331
+ }
332
+ }
333
+
334
+ @Composable
335
+ private fun relativeDateText(date: LocalDate): String {
336
+ val days = ChronoUnit.DAYS.between(LocalDate.now(), date)
337
+ return when {
338
+ days == 0L -> stringResource(R.string.common_today)
339
+ days > 0L -> pluralStringResource(R.plurals.common_in_days, days.toInt(), days.toInt())
340
+ else -> pluralStringResource(R.plurals.common_days_ago, (-days).toInt(), (-days).toInt())
341
+ }
342
+ }
@@ -0,0 +1,344 @@
1
+ package uz.rsteam.todoorbit
2
+
3
+ import androidx.compose.animation.AnimatedVisibility
4
+ import androidx.compose.foundation.layout.Arrangement
5
+ import androidx.compose.foundation.layout.Column
6
+ import androidx.compose.foundation.layout.Row
7
+ import androidx.compose.foundation.layout.Spacer
8
+ import androidx.compose.foundation.layout.fillMaxWidth
9
+ import androidx.compose.foundation.layout.padding
10
+ import androidx.compose.foundation.layout.width
11
+ import androidx.compose.material3.Button
12
+ import androidx.compose.material3.ElevatedCard
13
+ import androidx.compose.material3.ExperimentalMaterial3Api
14
+ import androidx.compose.material3.MaterialTheme
15
+ import androidx.compose.material3.ModalBottomSheet
16
+ import androidx.compose.material3.OutlinedCard
17
+ import androidx.compose.material3.OutlinedTextField
18
+ import androidx.compose.material3.Text
19
+ import androidx.compose.runtime.Composable
20
+ import androidx.compose.runtime.LaunchedEffect
21
+ import androidx.compose.runtime.getValue
22
+ import androidx.compose.runtime.mutableStateOf
23
+ import androidx.compose.runtime.remember
24
+ import androidx.compose.runtime.setValue
25
+ import androidx.compose.ui.Modifier
26
+ import androidx.compose.ui.res.stringResource
27
+ import androidx.compose.ui.text.font.FontWeight
28
+ import androidx.compose.ui.unit.dp
29
+ import kotlinx.coroutines.delay
30
+
31
+ @OptIn(ExperimentalMaterial3Api::class)
32
+ @Composable
33
+ fun TaskEditorSheet(
34
+ draft: TaskDraft,
35
+ title: String,
36
+ saveLabel: String,
37
+ onDismiss: () -> Unit,
38
+ onSubmit: (TaskDraft) -> Unit
39
+ ) {
40
+ val minLengthError = stringResource(R.string.validation_min_length, 2)
41
+ val priorityOptions = Priority.entries.map { it.name to stringResource(it.labelRes) }
42
+ var localDraft by remember(draft) { mutableStateOf(draft) }
43
+ var titleError by remember { mutableStateOf<String?>(null) }
44
+
45
+ ModalBottomSheet(onDismissRequest = onDismiss) {
46
+ Column(
47
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp).padding(bottom = 24.dp),
48
+ verticalArrangement = Arrangement.spacedBy(16.dp)
49
+ ) {
50
+ Text(title, style = MaterialTheme.typography.headlineSmall)
51
+ OutlinedTextField(
52
+ value = localDraft.title,
53
+ onValueChange = {
54
+ localDraft = localDraft.copy(title = it)
55
+ titleError = if (it.trim().length < 2) {
56
+ minLengthError
57
+ } else {
58
+ null
59
+ }
60
+ },
61
+ modifier = Modifier.fillMaxWidth(),
62
+ label = { Text(stringResource(R.string.create_task_field_title)) },
63
+ placeholder = { Text(stringResource(R.string.create_task_field_title_placeholder)) },
64
+ shape = MaterialTheme.shapes.medium,
65
+ isError = titleError != null
66
+ )
67
+ AnimatedVisibility(visible = titleError != null) {
68
+ Text(titleError.orEmpty(), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
69
+ }
70
+ OutlinedTextField(
71
+ value = localDraft.notes,
72
+ onValueChange = { localDraft = localDraft.copy(notes = it) },
73
+ modifier = Modifier.fillMaxWidth(),
74
+ label = { Text(stringResource(R.string.create_task_field_notes)) },
75
+ placeholder = { Text(stringResource(R.string.create_task_field_notes_placeholder)) },
76
+ shape = MaterialTheme.shapes.medium,
77
+ minLines = 4
78
+ )
79
+ EnumSelector(
80
+ title = stringResource(R.string.create_task_field_priority),
81
+ current = localDraft.priority.name,
82
+ options = priorityOptions,
83
+ onSelected = { selected ->
84
+ localDraft = localDraft.copy(priority = Priority.entries.first { it.name == selected })
85
+ }
86
+ )
87
+ OutlinedTextField(
88
+ value = localDraft.dueDate?.toString().orEmpty(),
89
+ onValueChange = { entered ->
90
+ localDraft = localDraft.copy(dueDate = parseLocalDateOrNull(entered))
91
+ },
92
+ modifier = Modifier.fillMaxWidth(),
93
+ label = { Text(stringResource(R.string.create_task_field_due_date)) },
94
+ placeholder = { Text(stringResource(R.string.create_task_field_due_date_placeholder)) },
95
+ shape = MaterialTheme.shapes.medium
96
+ )
97
+ Button(
98
+ onClick = {
99
+ if (localDraft.title.trim().length >= 2) {
100
+ onSubmit(localDraft)
101
+ } else {
102
+ titleError = minLengthError
103
+ }
104
+ },
105
+ modifier = Modifier.fillMaxWidth(),
106
+ shape = MaterialTheme.shapes.medium
107
+ ) {
108
+ Text(saveLabel)
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ @OptIn(ExperimentalMaterial3Api::class)
115
+ @Composable
116
+ fun RecurringRuleSheet(
117
+ preferences: PreferencesState,
118
+ rules: List<RecurringRuleModel>,
119
+ locale: UiLocale,
120
+ onDismiss: () -> Unit,
121
+ onSubmit: (RecurringRuleModel) -> Unit
122
+ ) {
123
+ val dailyCadenceLabel = stringResource(R.string.recurring_rule_cadence_daily)
124
+ val weeklyCadenceLabel = stringResource(R.string.recurring_rule_cadence_weekly)
125
+ val monthlyCadenceLabel = stringResource(R.string.recurring_rule_cadence_monthly)
126
+ val weekdayOptions = listOf(
127
+ "" to "—",
128
+ Weekday.Mon.name to stringResource(R.string.weekday_mon),
129
+ Weekday.Tue.name to stringResource(R.string.weekday_tue),
130
+ Weekday.Wed.name to stringResource(R.string.weekday_wed),
131
+ Weekday.Thu.name to stringResource(R.string.weekday_thu),
132
+ Weekday.Fri.name to stringResource(R.string.weekday_fri),
133
+ Weekday.Sat.name to stringResource(R.string.weekday_sat),
134
+ Weekday.Sun.name to stringResource(R.string.weekday_sun)
135
+ )
136
+ val summaryChannelOptions = listOf(
137
+ "" to "—",
138
+ SummaryChannel.Push.name to stringResource(R.string.recurring_rule_summary_push),
139
+ SummaryChannel.Email.name to stringResource(R.string.recurring_rule_summary_email)
140
+ )
141
+ var draft by remember { mutableStateOf(RuleDraft()) }
142
+ var errors by remember { mutableStateOf<Map<String, UiText>>(emptyMap()) }
143
+ var checkingName by remember { mutableStateOf(false) }
144
+ val preview = remember(draft) { schedulePreview(draft) }
145
+
146
+ LaunchedEffect(draft.name, rules) {
147
+ val trimmed = draft.name.trim()
148
+ if (trimmed.length < 4 || trimmed == "Default") {
149
+ checkingName = false
150
+ return@LaunchedEffect
151
+ }
152
+ checkingName = true
153
+ delay(450)
154
+ checkingName = false
155
+ if (rules.any { it.name.equals(trimmed, ignoreCase = true) }) {
156
+ errors = errors + ("name" to UiText.Resource(R.string.validation_rule_name_taken))
157
+ }
158
+ }
159
+
160
+ ModalBottomSheet(onDismissRequest = onDismiss) {
161
+ Column(
162
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp).padding(bottom = 24.dp),
163
+ verticalArrangement = Arrangement.spacedBy(16.dp)
164
+ ) {
165
+ Text(stringResource(R.string.recurring_rule_title), style = MaterialTheme.typography.headlineSmall)
166
+ Text(stringResource(R.string.recurring_rule_subtitle), color = MaterialTheme.colorScheme.onSurfaceVariant)
167
+
168
+ RuleTextField(
169
+ label = stringResource(R.string.recurring_rule_field_name),
170
+ value = draft.name,
171
+ placeholder = stringResource(R.string.recurring_rule_field_name_placeholder),
172
+ error = errors["name"]?.resolve(),
173
+ helper = if (checkingName) stringResource(R.string.recurring_rule_checking_name) else null,
174
+ onValueChange = { draft = draft.copy(name = it) }
175
+ )
176
+ RuleTextField(
177
+ label = stringResource(R.string.recurring_rule_field_confirm_name),
178
+ value = draft.confirmName,
179
+ placeholder = stringResource(R.string.recurring_rule_field_confirm_name_placeholder),
180
+ error = errors["confirmName"]?.resolve(),
181
+ onValueChange = { draft = draft.copy(confirmName = it) }
182
+ )
183
+
184
+ EnumSelector(
185
+ title = stringResource(R.string.recurring_rule_field_cadence),
186
+ current = draft.cadence?.name.orEmpty(),
187
+ options = listOf(
188
+ "" to "—",
189
+ Cadence.Daily.name to dailyCadenceLabel,
190
+ Cadence.Weekly.name to weeklyCadenceLabel,
191
+ Cadence.Monthly.name to monthlyCadenceLabel
192
+ ),
193
+ onSelected = {
194
+ draft = draft.copy(
195
+ cadence = Cadence.entries.firstOrNull { entry -> entry.name == it },
196
+ weekday = if (it == Cadence.Weekly.name) draft.weekday else null,
197
+ monthDay = if (it == Cadence.Monthly.name) draft.monthDay else ""
198
+ )
199
+ }
200
+ )
201
+ RuleTextField(
202
+ label = stringResource(R.string.recurring_rule_field_interval),
203
+ value = draft.interval,
204
+ helper = stringResource(R.string.recurring_rule_field_interval_helper),
205
+ error = errors["interval"]?.resolve(),
206
+ onValueChange = { draft = draft.copy(interval = it) }
207
+ )
208
+
209
+ AnimatedVisibility(visible = draft.cadence == Cadence.Weekly) {
210
+ EnumSelector(
211
+ title = stringResource(R.string.recurring_rule_field_weekday),
212
+ current = draft.weekday?.name.orEmpty(),
213
+ options = weekdayOptions,
214
+ onSelected = { draft = draft.copy(weekday = Weekday.entries.firstOrNull { day -> day.name == it }) }
215
+ )
216
+ }
217
+ AnimatedVisibility(visible = draft.cadence == Cadence.Monthly) {
218
+ RuleTextField(
219
+ label = stringResource(R.string.recurring_rule_field_month_day),
220
+ value = draft.monthDay,
221
+ helper = stringResource(R.string.recurring_rule_field_month_day_helper),
222
+ error = errors["monthDay"]?.resolve(),
223
+ onValueChange = { draft = draft.copy(monthDay = it) }
224
+ )
225
+ }
226
+
227
+ RuleTextField(
228
+ label = stringResource(R.string.recurring_rule_field_start_date),
229
+ value = draft.startDate.toString(),
230
+ error = errors["startDate"]?.resolve(),
231
+ onValueChange = { text ->
232
+ parseLocalDateOrNull(text)?.also { draft = draft.copy(startDate = it) }
233
+ }
234
+ )
235
+ SettingsToggle(
236
+ title = stringResource(R.string.recurring_rule_field_has_end_date),
237
+ subtitle = stringResource(R.string.recurring_rule_field_has_end_date_helper),
238
+ checked = draft.hasEndDate,
239
+ onCheckedChange = { draft = draft.copy(hasEndDate = it) }
240
+ )
241
+ AnimatedVisibility(visible = draft.hasEndDate) {
242
+ RuleTextField(
243
+ label = stringResource(R.string.recurring_rule_field_end_date),
244
+ value = draft.endDate?.toString().orEmpty(),
245
+ error = errors["endDate"]?.resolve(),
246
+ onValueChange = { text -> draft = draft.copy(endDate = parseLocalDateOrNull(text)) }
247
+ )
248
+ }
249
+ if (preferences.remindersEnabled) {
250
+ RuleTextField(
251
+ label = stringResource(R.string.recurring_rule_field_remind_at),
252
+ value = draft.remindAt,
253
+ placeholder = stringResource(R.string.recurring_rule_field_remind_at_placeholder),
254
+ helper = stringResource(R.string.recurring_rule_field_remind_at_helper),
255
+ error = errors["remindAt"]?.resolve(),
256
+ onValueChange = { draft = draft.copy(remindAt = it) }
257
+ )
258
+ }
259
+ SettingsToggle(
260
+ title = stringResource(R.string.recurring_rule_field_enable_summary),
261
+ subtitle = stringResource(R.string.recurring_rule_field_enable_summary_helper),
262
+ checked = draft.enableSummary,
263
+ onCheckedChange = { draft = draft.copy(enableSummary = it) }
264
+ )
265
+ AnimatedVisibility(visible = draft.enableSummary) {
266
+ EnumSelector(
267
+ title = stringResource(R.string.recurring_rule_field_summary_channel),
268
+ current = draft.summaryChannel?.name.orEmpty(),
269
+ options = summaryChannelOptions,
270
+ onSelected = {
271
+ draft = draft.copy(summaryChannel = SummaryChannel.entries.firstOrNull { item -> item.name == it })
272
+ }
273
+ )
274
+ }
275
+
276
+ SchedulePreviewCard(previewState = preview, locale = locale)
277
+
278
+ Button(
279
+ onClick = {
280
+ val validation = validateRuleDraft(draft, preferences, rules)
281
+ errors = validation
282
+ if (validation.isEmpty()) {
283
+ onSubmit(
284
+ RecurringRuleModel(
285
+ id = "rule-${System.currentTimeMillis()}",
286
+ name = draft.name.trim(),
287
+ cadence = draft.cadence!!,
288
+ interval = draft.interval.toInt(),
289
+ weekday = draft.weekday,
290
+ monthDay = draft.monthDay.toIntOrNull(),
291
+ startDate = draft.startDate,
292
+ endDate = if (draft.hasEndDate) draft.endDate else null,
293
+ remindAt = draft.remindAt.takeIf { it.isNotBlank() },
294
+ summaryChannel = if (draft.enableSummary) draft.summaryChannel else null
295
+ )
296
+ )
297
+ }
298
+ },
299
+ modifier = Modifier.fillMaxWidth(),
300
+ shape = MaterialTheme.shapes.medium
301
+ ) {
302
+ Text(stringResource(R.string.recurring_rule_save))
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ @Composable
309
+ fun SchedulePreviewCard(
310
+ previewState: SchedulePreviewState,
311
+ locale: UiLocale
312
+ ) {
313
+ ElevatedCard(shape = MaterialTheme.shapes.large) {
314
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
315
+ Text(stringResource(R.string.recurring_preview_title), style = MaterialTheme.typography.titleMedium)
316
+ when (previewState.mode) {
317
+ PreviewMode.Invalid -> {
318
+ Text(stringResource(R.string.recurring_preview_invalid), color = MaterialTheme.colorScheme.error)
319
+ }
320
+
321
+ PreviewMode.Empty -> {
322
+ Text(stringResource(R.string.recurring_preview_empty), color = MaterialTheme.colorScheme.onSurfaceVariant)
323
+ }
324
+
325
+ PreviewMode.Ready -> {
326
+ previewState.occurrences.forEachIndexed { index, date ->
327
+ OutlinedCard(shape = MaterialTheme.shapes.medium) {
328
+ Row(
329
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
330
+ horizontalArrangement = Arrangement.SpaceBetween
331
+ ) {
332
+ Text(
333
+ if (index == 0) stringResource(R.string.recurring_preview_next) else "+$index",
334
+ fontWeight = FontWeight.SemiBold
335
+ )
336
+ Text(formatAbsolute(date, locale))
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }