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.
- package/README.md +52 -34
- package/cli/index.ts +1 -1
- package/cli/init.ts +48 -211
- package/docs/stress-test-maturity-report.md +97 -0
- package/examples/todo-orbit/AGENTS.md +127 -0
- package/examples/todo-orbit/CLAUDE.md +75 -0
- package/examples/todo-orbit/README.md +62 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/README.md +14 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/build.gradle.kts +58 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/proguard-rules.pro +1 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/AndroidManifest.xml +20 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/MainActivity.kt +14 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/TodoOrbitApp.kt +345 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/AppLogic.kt +231 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Models.kt +169 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/support/Strings.kt +8 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/components/CommonComponents.kt +185 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/AnalyticsScreen.kt +193 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/SettingsScreen.kt +102 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/screens/TasksScreen.kt +342 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/sheets/EditorSheets.kt +344 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/java/uz/rsteam/todoorbit/ui/theme/TodoOrbitTheme.kt +59 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values/strings.xml +148 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/app/src/main/res/values-ru/strings.xml +154 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/build.gradle.kts +4 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/gradle.properties +4 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/gradlew +248 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/gradlew.bat +93 -0
- package/examples/todo-orbit/generated/android/Todo Orbit/settings.gradle.kts +18 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/README.md +29 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/en.lproj/Localizable.strings +118 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Resources/ru.lproj/Localizable.strings +118 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/App/TodoOrbitApp.swift +50 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/OrbitChrome.swift +204 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/SchedulePreviewView.swift +126 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Components/TrendChartView.swift +70 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/RecurringRuleSheet.swift +123 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Flows/TaskEditorSheet.swift +60 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Models/DomainModels.swift +238 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/AnalyticsView.swift +94 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/SettingsView.swift +74 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Screens/TasksHomeView.swift +363 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/Sources/TodoOrbit/Support/AppModel.swift +324 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.pbxproj +408 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/TodoOrbit.xcodeproj/xcshareddata/xcschemes/TodoOrbit.xcscheme +79 -0
- package/examples/todo-orbit/generated/ios/Todo Orbit/project.yml +24 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/index.html +16 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/package-lock.json +1087 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/package.json +24 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/src/App.tsx +2114 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/src/main.tsx +13 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/src/styles.css +886 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/tsconfig.json +19 -0
- package/examples/todo-orbit/generated/web/Todo Orbit/vite.config.ts +6 -0
- package/examples/todo-orbit/openuispec/README.md +158 -0
- package/examples/todo-orbit/openuispec/contracts/.gitkeep +0 -0
- package/examples/todo-orbit/openuispec/contracts/action_trigger.yaml +28 -0
- package/examples/todo-orbit/openuispec/contracts/collection.yaml +32 -0
- package/examples/todo-orbit/openuispec/contracts/data_display.yaml +38 -0
- package/examples/todo-orbit/openuispec/contracts/feedback.yaml +32 -0
- package/examples/todo-orbit/openuispec/contracts/input_field.yaml +52 -0
- package/examples/todo-orbit/openuispec/contracts/nav_container.yaml +47 -0
- package/examples/todo-orbit/openuispec/contracts/surface.yaml +28 -0
- package/examples/todo-orbit/openuispec/contracts/x_schedule_preview.yaml +134 -0
- package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +139 -0
- package/examples/todo-orbit/openuispec/flows/.gitkeep +0 -0
- package/examples/todo-orbit/openuispec/flows/create_recurring_rule.yaml +253 -0
- package/examples/todo-orbit/openuispec/flows/create_task.yaml +118 -0
- package/examples/todo-orbit/openuispec/flows/edit_task.yaml +126 -0
- package/examples/todo-orbit/openuispec/locales/.gitkeep +0 -0
- package/examples/todo-orbit/openuispec/locales/en.json +150 -0
- package/examples/todo-orbit/openuispec/locales/ru.json +150 -0
- package/examples/todo-orbit/openuispec/openuispec.yaml +122 -0
- package/examples/todo-orbit/openuispec/platform/.gitkeep +0 -0
- package/examples/todo-orbit/openuispec/platform/android.yaml +19 -0
- package/examples/todo-orbit/openuispec/platform/ios.yaml +20 -0
- package/examples/todo-orbit/openuispec/platform/web.yaml +22 -0
- package/examples/todo-orbit/openuispec/screens/.gitkeep +0 -0
- package/examples/todo-orbit/openuispec/screens/analytics.yaml +140 -0
- package/examples/todo-orbit/openuispec/screens/home.yaml +173 -0
- package/examples/todo-orbit/openuispec/screens/settings.yaml +149 -0
- package/examples/todo-orbit/openuispec/screens/task_detail.yaml +223 -0
- package/examples/todo-orbit/openuispec/tokens/.gitkeep +0 -0
- package/examples/todo-orbit/openuispec/tokens/color.yaml +93 -0
- package/examples/todo-orbit/openuispec/tokens/elevation.yaml +25 -0
- package/examples/todo-orbit/openuispec/tokens/icons.yaml +92 -0
- package/examples/todo-orbit/openuispec/tokens/layout.yaml +107 -0
- package/examples/todo-orbit/openuispec/tokens/motion.yaml +39 -0
- package/examples/todo-orbit/openuispec/tokens/spacing.yaml +18 -0
- package/examples/todo-orbit/openuispec/tokens/themes.yaml +23 -0
- package/examples/todo-orbit/openuispec/tokens/typography.yaml +52 -0
- package/package.json +1 -1
- package/schema/screen.schema.json +9 -0
- package/schema/validate.ts +0 -2
- package/spec/openuispec-v0.1.md +129 -27
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
package uz.rsteam.todoorbit
|
|
2
|
+
|
|
3
|
+
import androidx.appcompat.app.AppCompatDelegate
|
|
4
|
+
import androidx.compose.animation.Crossfade
|
|
5
|
+
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
6
|
+
import androidx.compose.foundation.layout.Arrangement
|
|
7
|
+
import androidx.compose.foundation.layout.BoxWithConstraints
|
|
8
|
+
import androidx.compose.foundation.layout.Column
|
|
9
|
+
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
10
|
+
import androidx.compose.foundation.layout.Row
|
|
11
|
+
import androidx.compose.foundation.layout.Spacer
|
|
12
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
13
|
+
import androidx.compose.foundation.layout.height
|
|
14
|
+
import androidx.compose.foundation.layout.padding
|
|
15
|
+
import androidx.compose.material.icons.Icons
|
|
16
|
+
import androidx.compose.material.icons.automirrored.outlined.TrendingUp
|
|
17
|
+
import androidx.compose.material.icons.outlined.Checklist
|
|
18
|
+
import androidx.compose.material.icons.outlined.Settings
|
|
19
|
+
import androidx.compose.material3.AlertDialog
|
|
20
|
+
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
21
|
+
import androidx.compose.material3.Icon
|
|
22
|
+
import androidx.compose.material3.MaterialTheme
|
|
23
|
+
import androidx.compose.material3.NavigationBar
|
|
24
|
+
import androidx.compose.material3.NavigationBarItem
|
|
25
|
+
import androidx.compose.material3.NavigationRail
|
|
26
|
+
import androidx.compose.material3.NavigationRailItem
|
|
27
|
+
import androidx.compose.material3.Scaffold
|
|
28
|
+
import androidx.compose.material3.SnackbarHost
|
|
29
|
+
import androidx.compose.material3.SnackbarHostState
|
|
30
|
+
import androidx.compose.material3.Surface
|
|
31
|
+
import androidx.compose.material3.Text
|
|
32
|
+
import androidx.compose.material3.TextButton
|
|
33
|
+
import androidx.compose.material3.TopAppBar
|
|
34
|
+
import androidx.compose.runtime.Composable
|
|
35
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
36
|
+
import androidx.compose.runtime.getValue
|
|
37
|
+
import androidx.compose.runtime.mutableStateListOf
|
|
38
|
+
import androidx.compose.runtime.mutableStateOf
|
|
39
|
+
import androidx.compose.runtime.remember
|
|
40
|
+
import androidx.compose.runtime.rememberCoroutineScope
|
|
41
|
+
import androidx.compose.runtime.saveable.rememberSaveable
|
|
42
|
+
import androidx.compose.runtime.setValue
|
|
43
|
+
import androidx.compose.ui.Modifier
|
|
44
|
+
import androidx.compose.ui.res.stringResource
|
|
45
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
46
|
+
import androidx.compose.ui.unit.dp
|
|
47
|
+
import androidx.core.os.LocaleListCompat
|
|
48
|
+
import kotlinx.coroutines.launch
|
|
49
|
+
import uz.rsteam.todoorbit.ui.theme.TodoOrbitTheme
|
|
50
|
+
import java.time.LocalDate
|
|
51
|
+
import java.time.LocalDateTime
|
|
52
|
+
|
|
53
|
+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
|
54
|
+
@Composable
|
|
55
|
+
fun TodoOrbitGeneratedApp() {
|
|
56
|
+
var tab by rememberSaveable { mutableStateOf(ScreenTab.Tasks) }
|
|
57
|
+
var filter by rememberSaveable { mutableStateOf(TaskFilter.All) }
|
|
58
|
+
var searchQuery by rememberSaveable { mutableStateOf("") }
|
|
59
|
+
var analyticsPeriod by rememberSaveable { mutableStateOf(AnalyticsPeriod.Week) }
|
|
60
|
+
var preferences by rememberSaveable(stateSaver = PreferencesStateSaver) { mutableStateOf(PreferencesState()) }
|
|
61
|
+
var selectedTaskId by rememberSaveable { mutableStateOf(sampleTasks().first().id) }
|
|
62
|
+
var overlay by remember { mutableStateOf<OverlayState?>(null) }
|
|
63
|
+
val snackbarHostState = remember { SnackbarHostState() }
|
|
64
|
+
val scope = rememberCoroutineScope()
|
|
65
|
+
val tasks = remember { mutableStateListOf(*sampleTasks().toTypedArray()) }
|
|
66
|
+
val recurringRules = remember { mutableStateListOf<RecurringRuleModel>() }
|
|
67
|
+
val createTaskSuccessMessage = stringResource(R.string.create_task_success)
|
|
68
|
+
val editTaskSuccessMessage = stringResource(R.string.edit_task_success)
|
|
69
|
+
val recurringRuleSuccessMessage = stringResource(R.string.recurring_rule_success)
|
|
70
|
+
|
|
71
|
+
LaunchedEffect(preferences.locale) {
|
|
72
|
+
AppCompatDelegate.setApplicationLocales(
|
|
73
|
+
LocaleListCompat.forLanguageTags(preferences.locale.toLanguageTag())
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
TodoOrbitTheme(darkTheme = preferences.themeMode == ThemeMode.Dark) {
|
|
78
|
+
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
|
79
|
+
BoxWithConstraints {
|
|
80
|
+
val useRail = maxWidth >= 900.dp
|
|
81
|
+
val useSplitView = useRail && this@BoxWithConstraints.maxWidth >= 1200.dp
|
|
82
|
+
val visibleTasks = remember(tasks.toList(), filter, searchQuery) {
|
|
83
|
+
tasks.filter { task ->
|
|
84
|
+
val matchesFilter = when (filter) {
|
|
85
|
+
TaskFilter.All -> true
|
|
86
|
+
TaskFilter.Open -> task.status == TaskStatus.Open
|
|
87
|
+
TaskFilter.Done -> task.status == TaskStatus.Done
|
|
88
|
+
}
|
|
89
|
+
val matchesSearch = listOf(task.title, task.notes).joinToString(" ").contains(searchQuery, ignoreCase = true)
|
|
90
|
+
matchesFilter && matchesSearch
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
val selectedTask = tasks.firstOrNull { it.id == selectedTaskId } ?: tasks.firstOrNull()
|
|
94
|
+
val overview = analyticsOverview(tasks)
|
|
95
|
+
val trendSeries = trendPoints(tasks, analyticsPeriod, preferences.locale)
|
|
96
|
+
val overdueTasks = tasks.filter { it.status == TaskStatus.Open && it.dueDate?.isBefore(LocalDate.now()) == true }
|
|
97
|
+
|
|
98
|
+
Scaffold(
|
|
99
|
+
topBar = {
|
|
100
|
+
TopAppBar(
|
|
101
|
+
title = {
|
|
102
|
+
Column {
|
|
103
|
+
Text(stringResource(R.string.app_name), fontWeight = FontWeight.Bold)
|
|
104
|
+
Text(
|
|
105
|
+
text = when (tab) {
|
|
106
|
+
ScreenTab.Tasks -> stringResource(R.string.app_path_home)
|
|
107
|
+
ScreenTab.Analytics -> stringResource(R.string.app_path_analytics)
|
|
108
|
+
ScreenTab.Settings -> stringResource(R.string.app_path_settings)
|
|
109
|
+
},
|
|
110
|
+
style = MaterialTheme.typography.labelSmall,
|
|
111
|
+
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
},
|
|
117
|
+
bottomBar = {
|
|
118
|
+
if (!useRail) {
|
|
119
|
+
NavigationBar {
|
|
120
|
+
NavigationBarItem(
|
|
121
|
+
selected = tab == ScreenTab.Tasks,
|
|
122
|
+
onClick = { tab = ScreenTab.Tasks },
|
|
123
|
+
icon = { Icon(Icons.Outlined.Checklist, contentDescription = null) },
|
|
124
|
+
label = { Text(stringResource(R.string.nav_tasks)) }
|
|
125
|
+
)
|
|
126
|
+
NavigationBarItem(
|
|
127
|
+
selected = tab == ScreenTab.Analytics,
|
|
128
|
+
onClick = { tab = ScreenTab.Analytics },
|
|
129
|
+
icon = { Icon(Icons.AutoMirrored.Outlined.TrendingUp, contentDescription = null) },
|
|
130
|
+
label = { Text(stringResource(R.string.nav_analytics)) }
|
|
131
|
+
)
|
|
132
|
+
NavigationBarItem(
|
|
133
|
+
selected = tab == ScreenTab.Settings,
|
|
134
|
+
onClick = { tab = ScreenTab.Settings },
|
|
135
|
+
icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
|
|
136
|
+
label = { Text(stringResource(R.string.nav_settings)) }
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
snackbarHost = { SnackbarHost(snackbarHostState) }
|
|
142
|
+
) { innerPadding ->
|
|
143
|
+
Row(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
|
144
|
+
if (useRail) {
|
|
145
|
+
NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)) {
|
|
146
|
+
Spacer(Modifier.height(12.dp))
|
|
147
|
+
NavigationRailItem(
|
|
148
|
+
selected = tab == ScreenTab.Tasks,
|
|
149
|
+
onClick = { tab = ScreenTab.Tasks },
|
|
150
|
+
icon = { Icon(Icons.Outlined.Checklist, contentDescription = null) },
|
|
151
|
+
label = { Text(stringResource(R.string.nav_tasks)) }
|
|
152
|
+
)
|
|
153
|
+
NavigationRailItem(
|
|
154
|
+
selected = tab == ScreenTab.Analytics,
|
|
155
|
+
onClick = { tab = ScreenTab.Analytics },
|
|
156
|
+
icon = { Icon(Icons.AutoMirrored.Outlined.TrendingUp, contentDescription = null) },
|
|
157
|
+
label = { Text(stringResource(R.string.nav_analytics)) }
|
|
158
|
+
)
|
|
159
|
+
NavigationRailItem(
|
|
160
|
+
selected = tab == ScreenTab.Settings,
|
|
161
|
+
onClick = { tab = ScreenTab.Settings },
|
|
162
|
+
icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
|
|
163
|
+
label = { Text(stringResource(R.string.nav_settings)) }
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Crossfade(targetState = tab, modifier = Modifier.fillMaxSize(), label = "tab-crossfade") { activeTab ->
|
|
169
|
+
when (activeTab) {
|
|
170
|
+
ScreenTab.Tasks -> {
|
|
171
|
+
TasksScreen(
|
|
172
|
+
tasks = visibleTasks,
|
|
173
|
+
allTasks = tasks,
|
|
174
|
+
filter = filter,
|
|
175
|
+
searchQuery = searchQuery,
|
|
176
|
+
selectedTask = selectedTask,
|
|
177
|
+
locale = preferences.locale,
|
|
178
|
+
useSplitView = useSplitView,
|
|
179
|
+
onSearchChange = { searchQuery = it },
|
|
180
|
+
onFilterChange = { filter = it },
|
|
181
|
+
onSelectTask = { selectedTaskId = it.id },
|
|
182
|
+
onToggleTask = { task ->
|
|
183
|
+
val index = tasks.indexOfFirst { it.id == task.id }
|
|
184
|
+
if (index >= 0) {
|
|
185
|
+
tasks[index] = task.copy(
|
|
186
|
+
status = if (task.status == TaskStatus.Done) TaskStatus.Open else TaskStatus.Done,
|
|
187
|
+
updatedAt = LocalDateTime.now()
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
onCreateTask = { overlay = OverlayState.CreateTask },
|
|
192
|
+
onEditTask = { overlay = OverlayState.EditTask(it.id) },
|
|
193
|
+
onDeleteTask = {
|
|
194
|
+
tasks.removeAll { task -> task.id == it.id }
|
|
195
|
+
selectedTaskId = tasks.firstOrNull()?.id.orEmpty()
|
|
196
|
+
},
|
|
197
|
+
onMoreInfo = { overlay = OverlayState.TaskMeta(it.id) }
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
ScreenTab.Analytics -> {
|
|
202
|
+
AnalyticsScreen(
|
|
203
|
+
period = analyticsPeriod,
|
|
204
|
+
overview = overview,
|
|
205
|
+
trendSeries = trendSeries,
|
|
206
|
+
overdueTasks = overdueTasks,
|
|
207
|
+
onPeriodChange = { analyticsPeriod = it },
|
|
208
|
+
locale = preferences.locale
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
ScreenTab.Settings -> {
|
|
213
|
+
SettingsScreen(
|
|
214
|
+
preferences = preferences,
|
|
215
|
+
recurringRules = recurringRules,
|
|
216
|
+
onPreferencesChange = { preferences = it },
|
|
217
|
+
onOpenRecurringRule = { overlay = OverlayState.RecurringRule },
|
|
218
|
+
locale = preferences.locale
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
when (val currentOverlay = overlay) {
|
|
227
|
+
OverlayState.CreateTask -> {
|
|
228
|
+
TaskEditorSheet(
|
|
229
|
+
draft = TaskDraft(),
|
|
230
|
+
title = stringResource(R.string.create_task_title),
|
|
231
|
+
saveLabel = stringResource(R.string.create_task_save),
|
|
232
|
+
onDismiss = { overlay = null },
|
|
233
|
+
onSubmit = { draft ->
|
|
234
|
+
tasks.add(
|
|
235
|
+
0,
|
|
236
|
+
TaskModel(
|
|
237
|
+
id = "task-${System.currentTimeMillis()}",
|
|
238
|
+
title = draft.title.trim(),
|
|
239
|
+
notes = draft.notes.trim(),
|
|
240
|
+
status = TaskStatus.Open,
|
|
241
|
+
priority = draft.priority,
|
|
242
|
+
dueDate = draft.dueDate,
|
|
243
|
+
createdAt = LocalDateTime.now(),
|
|
244
|
+
updatedAt = LocalDateTime.now()
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
selectedTaskId = tasks.first().id
|
|
248
|
+
overlay = null
|
|
249
|
+
scope.launch {
|
|
250
|
+
snackbarHostState.showSnackbar(createTaskSuccessMessage)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
is OverlayState.EditTask -> {
|
|
257
|
+
val task = tasks.firstOrNull { it.id == currentOverlay.taskId }
|
|
258
|
+
if (task != null) {
|
|
259
|
+
TaskEditorSheet(
|
|
260
|
+
draft = TaskDraft(
|
|
261
|
+
title = task.title,
|
|
262
|
+
notes = task.notes,
|
|
263
|
+
priority = task.priority,
|
|
264
|
+
dueDate = task.dueDate
|
|
265
|
+
),
|
|
266
|
+
title = stringResource(R.string.edit_task_title),
|
|
267
|
+
saveLabel = stringResource(R.string.edit_task_save),
|
|
268
|
+
onDismiss = { overlay = null },
|
|
269
|
+
onSubmit = { draft ->
|
|
270
|
+
val index = tasks.indexOfFirst { it.id == task.id }
|
|
271
|
+
if (index >= 0) {
|
|
272
|
+
tasks[index] = task.copy(
|
|
273
|
+
title = draft.title.trim(),
|
|
274
|
+
notes = draft.notes.trim(),
|
|
275
|
+
priority = draft.priority,
|
|
276
|
+
dueDate = draft.dueDate,
|
|
277
|
+
updatedAt = LocalDateTime.now()
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
overlay = null
|
|
281
|
+
scope.launch {
|
|
282
|
+
snackbarHostState.showSnackbar(editTaskSuccessMessage)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
OverlayState.RecurringRule -> {
|
|
290
|
+
RecurringRuleSheet(
|
|
291
|
+
preferences = preferences,
|
|
292
|
+
rules = recurringRules,
|
|
293
|
+
locale = preferences.locale,
|
|
294
|
+
onDismiss = { overlay = null },
|
|
295
|
+
onSubmit = { rule ->
|
|
296
|
+
recurringRules.add(0, rule)
|
|
297
|
+
overlay = null
|
|
298
|
+
scope.launch {
|
|
299
|
+
snackbarHostState.showSnackbar(recurringRuleSuccessMessage)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
is OverlayState.TaskMeta -> {
|
|
306
|
+
val task = tasks.firstOrNull { it.id == currentOverlay.taskId }
|
|
307
|
+
if (task != null) {
|
|
308
|
+
AlertDialog(
|
|
309
|
+
onDismissRequest = { overlay = null },
|
|
310
|
+
title = { Text(stringResource(R.string.task_detail_more_info)) },
|
|
311
|
+
text = {
|
|
312
|
+
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
313
|
+
LabelValue(
|
|
314
|
+
stringResource(R.string.task_detail_status),
|
|
315
|
+
stringResource(task.status.labelRes)
|
|
316
|
+
)
|
|
317
|
+
LabelValue(
|
|
318
|
+
stringResource(R.string.task_detail_priority),
|
|
319
|
+
stringResource(task.priority.labelRes)
|
|
320
|
+
)
|
|
321
|
+
LabelValue(
|
|
322
|
+
stringResource(R.string.task_detail_created),
|
|
323
|
+
formatAbsolute(task.createdAt.toLocalDate(), preferences.locale)
|
|
324
|
+
)
|
|
325
|
+
LabelValue(
|
|
326
|
+
stringResource(R.string.task_detail_updated),
|
|
327
|
+
formatAbsolute(task.updatedAt.toLocalDate(), preferences.locale)
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
confirmButton = {
|
|
332
|
+
TextButton(onClick = { overlay = null }) {
|
|
333
|
+
Text(stringResource(R.string.common_cancel))
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
null -> Unit
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
package uz.rsteam.todoorbit
|
|
2
|
+
|
|
3
|
+
import java.time.LocalDate
|
|
4
|
+
import java.time.LocalDateTime
|
|
5
|
+
import java.time.format.DateTimeFormatter
|
|
6
|
+
import java.time.format.FormatStyle
|
|
7
|
+
import java.util.Locale
|
|
8
|
+
import kotlin.math.roundToInt
|
|
9
|
+
|
|
10
|
+
data class AnalyticsOverview(
|
|
11
|
+
val completedToday: Int,
|
|
12
|
+
val openTasks: Int,
|
|
13
|
+
val overdueTasks: Int,
|
|
14
|
+
val completionRate: Int
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
fun analyticsOverview(tasks: List<TaskModel>): AnalyticsOverview {
|
|
18
|
+
val today = LocalDate.now()
|
|
19
|
+
val completedToday = tasks.count { it.status == TaskStatus.Done && it.updatedAt.toLocalDate() == today }
|
|
20
|
+
val openTasks = tasks.count { it.status == TaskStatus.Open }
|
|
21
|
+
val overdueTasks = tasks.count { it.status == TaskStatus.Open && it.dueDate?.isBefore(today) == true }
|
|
22
|
+
val completionRate = if (tasks.isEmpty()) 0 else (((tasks.size - openTasks).toFloat() / tasks.size) * 100f).roundToInt()
|
|
23
|
+
return AnalyticsOverview(completedToday, openTasks, overdueTasks, completionRate)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fun trendPoints(tasks: List<TaskModel>, period: AnalyticsPeriod, locale: UiLocale): List<TrendPoint> {
|
|
27
|
+
val length = when (period) {
|
|
28
|
+
AnalyticsPeriod.Week -> 7
|
|
29
|
+
AnalyticsPeriod.Month -> 6
|
|
30
|
+
AnalyticsPeriod.Quarter -> 8
|
|
31
|
+
}
|
|
32
|
+
val formatter = when (locale) {
|
|
33
|
+
UiLocale.En -> DateTimeFormatter.ofPattern(if (period == AnalyticsPeriod.Week) "EEE" else "MMM d", Locale.ENGLISH)
|
|
34
|
+
UiLocale.Ru -> DateTimeFormatter.ofPattern(if (period == AnalyticsPeriod.Week) "EEE" else "d MMM", Locale("ru"))
|
|
35
|
+
}
|
|
36
|
+
val today = LocalDate.now()
|
|
37
|
+
return (0 until length).map { index ->
|
|
38
|
+
val cursor = today.minusDays(((length - index - 1) * if (period == AnalyticsPeriod.Week) 1 else 5).toLong())
|
|
39
|
+
TrendPoint(
|
|
40
|
+
label = cursor.format(formatter),
|
|
41
|
+
completed = tasks.count { it.status == TaskStatus.Done && !it.updatedAt.toLocalDate().isAfter(cursor) },
|
|
42
|
+
created = tasks.count { !it.createdAt.toLocalDate().isAfter(cursor) }
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun validateRuleDraft(
|
|
48
|
+
draft: RuleDraft,
|
|
49
|
+
preferences: PreferencesState,
|
|
50
|
+
rules: List<RecurringRuleModel>
|
|
51
|
+
): Map<String, UiText> {
|
|
52
|
+
val errors = mutableMapOf<String, UiText>()
|
|
53
|
+
val trimmedName = draft.name.trim()
|
|
54
|
+
if (trimmedName.length < 4) {
|
|
55
|
+
errors["name"] = UiText.Resource(R.string.validation_rule_name_min_length, listOf(4))
|
|
56
|
+
} else if (trimmedName == "Default") {
|
|
57
|
+
errors["name"] = UiText.Resource(R.string.validation_rule_name_reserved)
|
|
58
|
+
} else if (rules.any { it.name.equals(trimmedName, ignoreCase = true) }) {
|
|
59
|
+
errors["name"] = UiText.Resource(R.string.validation_rule_name_taken)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (draft.confirmName.trim() != trimmedName) {
|
|
63
|
+
errors["confirmName"] = UiText.Resource(R.string.validation_match_field)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val interval = draft.interval.toIntOrNull()
|
|
67
|
+
when {
|
|
68
|
+
interval == null || interval < 1 -> errors["interval"] = UiText.Resource(R.string.validation_min_value, listOf(1))
|
|
69
|
+
interval > 30 -> errors["interval"] = UiText.Resource(R.string.validation_max_value, listOf(30))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (draft.cadence == null) {
|
|
73
|
+
errors["cadence"] = UiText.Resource(R.string.validation_fix_errors)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (draft.cadence == Cadence.Weekly && draft.weekday == null) {
|
|
77
|
+
errors["weekday"] = UiText.Resource(R.string.validation_fix_errors)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (draft.cadence == Cadence.Monthly) {
|
|
81
|
+
val monthDay = draft.monthDay.toIntOrNull()
|
|
82
|
+
when {
|
|
83
|
+
monthDay == null || monthDay < 1 -> errors["monthDay"] = UiText.Resource(R.string.validation_min_value, listOf(1))
|
|
84
|
+
monthDay > 28 -> errors["monthDay"] = UiText.Resource(R.string.validation_month_day_max)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (draft.hasEndDate && draft.endDate != null && draft.endDate.isBefore(draft.startDate)) {
|
|
89
|
+
errors["endDate"] = UiText.Resource(R.string.validation_end_date_after_start)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (preferences.remindersEnabled && !Regex("^([01]\\d|2[0-3]):[0-5]\\d$").matches(draft.remindAt)) {
|
|
93
|
+
errors["remindAt"] = UiText.Resource(R.string.validation_time_format)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (draft.enableSummary && draft.summaryChannel == null) {
|
|
97
|
+
errors["summaryChannel"] = UiText.Resource(R.string.validation_fix_errors)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return errors
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fun schedulePreview(draft: RuleDraft): SchedulePreviewState {
|
|
104
|
+
val interval = draft.interval.toIntOrNull() ?: return SchedulePreviewState(PreviewMode.Invalid)
|
|
105
|
+
val cadence = draft.cadence ?: return SchedulePreviewState(PreviewMode.Invalid)
|
|
106
|
+
if (interval < 1) return SchedulePreviewState(PreviewMode.Invalid)
|
|
107
|
+
if (draft.hasEndDate && draft.endDate != null && draft.endDate.isBefore(draft.startDate)) {
|
|
108
|
+
return SchedulePreviewState(PreviewMode.Invalid)
|
|
109
|
+
}
|
|
110
|
+
if (cadence == Cadence.Weekly && draft.weekday == null) return SchedulePreviewState(PreviewMode.Invalid)
|
|
111
|
+
val monthlyDay = if (cadence == Cadence.Monthly) draft.monthDay.toIntOrNull() ?: return SchedulePreviewState(PreviewMode.Invalid) else null
|
|
112
|
+
|
|
113
|
+
val result = mutableListOf<LocalDate>()
|
|
114
|
+
var cursor = draft.startDate
|
|
115
|
+
repeat(4) {
|
|
116
|
+
val next = when (cadence) {
|
|
117
|
+
Cadence.Daily -> cursor
|
|
118
|
+
Cadence.Weekly -> nextWeekdayFrom(cursor, draft.weekday!!)
|
|
119
|
+
Cadence.Monthly -> nextMonthlyFrom(cursor, monthlyDay!!)
|
|
120
|
+
}
|
|
121
|
+
if (draft.endDate != null && next.isAfter(draft.endDate)) return@repeat
|
|
122
|
+
result += next
|
|
123
|
+
cursor = when (cadence) {
|
|
124
|
+
Cadence.Daily -> next.plusDays(interval.toLong())
|
|
125
|
+
Cadence.Weekly -> next.plusWeeks(interval.toLong())
|
|
126
|
+
Cadence.Monthly -> next.plusMonths(interval.toLong())
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return if (result.isEmpty()) SchedulePreviewState(PreviewMode.Empty) else SchedulePreviewState(PreviewMode.Ready, result)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fun formatAbsolute(date: LocalDate, locale: UiLocale): String {
|
|
134
|
+
val formatter = when (locale) {
|
|
135
|
+
UiLocale.En -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.ENGLISH)
|
|
136
|
+
UiLocale.Ru -> DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale("ru"))
|
|
137
|
+
}
|
|
138
|
+
return formatter.format(date)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fun parseLocalDateOrNull(value: String): LocalDate? {
|
|
142
|
+
return runCatching {
|
|
143
|
+
value.takeIf(String::isNotBlank)?.let(LocalDate::parse)
|
|
144
|
+
}.getOrNull()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private fun nextWeekdayFrom(date: LocalDate, weekday: Weekday): LocalDate {
|
|
148
|
+
var cursor = date
|
|
149
|
+
while (cursor.dayOfWeek.value != weekdayToDayNumber(weekday)) {
|
|
150
|
+
cursor = cursor.plusDays(1)
|
|
151
|
+
}
|
|
152
|
+
return cursor
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun nextMonthlyFrom(date: LocalDate, day: Int): LocalDate {
|
|
156
|
+
val base = date.withDayOfMonth(1)
|
|
157
|
+
val candidate = base.withDayOfMonth(day.coerceAtMost(base.lengthOfMonth()))
|
|
158
|
+
return if (candidate.isBefore(date)) {
|
|
159
|
+
base.plusMonths(1).withDayOfMonth(day.coerceAtMost(base.plusMonths(1).lengthOfMonth()))
|
|
160
|
+
} else {
|
|
161
|
+
candidate
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun weekdayToDayNumber(weekday: Weekday): Int {
|
|
166
|
+
return when (weekday) {
|
|
167
|
+
Weekday.Mon -> 1
|
|
168
|
+
Weekday.Tue -> 2
|
|
169
|
+
Weekday.Wed -> 3
|
|
170
|
+
Weekday.Thu -> 4
|
|
171
|
+
Weekday.Fri -> 5
|
|
172
|
+
Weekday.Sat -> 6
|
|
173
|
+
Weekday.Sun -> 7
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fun sampleTasks(): List<TaskModel> {
|
|
178
|
+
val now = LocalDateTime.now()
|
|
179
|
+
return listOf(
|
|
180
|
+
TaskModel(
|
|
181
|
+
id = "task-1",
|
|
182
|
+
title = "Prepare bilingual launch notes",
|
|
183
|
+
notes = "Document the web, iOS, and Android behavior differences before review.",
|
|
184
|
+
status = TaskStatus.Open,
|
|
185
|
+
priority = Priority.High,
|
|
186
|
+
dueDate = LocalDate.now().plusDays(2),
|
|
187
|
+
createdAt = now.minusDays(6),
|
|
188
|
+
updatedAt = now.minusDays(1)
|
|
189
|
+
),
|
|
190
|
+
TaskModel(
|
|
191
|
+
id = "task-2",
|
|
192
|
+
title = "Review recurring-rule validation",
|
|
193
|
+
notes = "Confirm async uniqueness checks and cross-field constraints.",
|
|
194
|
+
status = TaskStatus.Done,
|
|
195
|
+
priority = Priority.Medium,
|
|
196
|
+
dueDate = LocalDate.now().minusDays(1),
|
|
197
|
+
createdAt = now.minusDays(5),
|
|
198
|
+
updatedAt = now
|
|
199
|
+
),
|
|
200
|
+
TaskModel(
|
|
201
|
+
id = "task-3",
|
|
202
|
+
title = "Polish analytics empty states",
|
|
203
|
+
notes = "Ensure chart and overdue list degrade gracefully on zero-data snapshots.",
|
|
204
|
+
status = TaskStatus.Open,
|
|
205
|
+
priority = Priority.Medium,
|
|
206
|
+
dueDate = LocalDate.now().plusDays(5),
|
|
207
|
+
createdAt = now.minusDays(4),
|
|
208
|
+
updatedAt = now.minusDays(2)
|
|
209
|
+
),
|
|
210
|
+
TaskModel(
|
|
211
|
+
id = "task-4",
|
|
212
|
+
title = "Regenerate drift snapshots",
|
|
213
|
+
notes = "Refresh ios, android, and web state after spec edits.",
|
|
214
|
+
status = TaskStatus.Open,
|
|
215
|
+
priority = Priority.Low,
|
|
216
|
+
dueDate = LocalDate.now().minusDays(3),
|
|
217
|
+
createdAt = now.minusDays(3),
|
|
218
|
+
updatedAt = now.minusDays(3)
|
|
219
|
+
),
|
|
220
|
+
TaskModel(
|
|
221
|
+
id = "task-5",
|
|
222
|
+
title = "Prototype schedule preview contract",
|
|
223
|
+
notes = "Use derived occurrences to prove custom-contract generation.",
|
|
224
|
+
status = TaskStatus.Done,
|
|
225
|
+
priority = Priority.High,
|
|
226
|
+
dueDate = LocalDate.now().plusDays(1),
|
|
227
|
+
createdAt = now.minusDays(8),
|
|
228
|
+
updatedAt = now.minusDays(1)
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
}
|