openuispec 0.1.17 → 0.1.19
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/docs/stress-test-maturity-report.md +97 -0
- package/examples/taskflow/screens/profile_edit.yaml +2 -0
- package/examples/todo-orbit/AGENTS.md +127 -0
- package/examples/todo-orbit/CLAUDE.md +127 -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 +139 -0
- package/examples/todo-orbit/openuispec/screens/home.yaml +172 -0
- package/examples/todo-orbit/openuispec/screens/settings.yaml +148 -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/validate.ts +271 -4
- package/spec/openuispec-v0.1.md +80 -13
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
@MainActor
|
|
5
|
+
final class AppModel: ObservableObject {
|
|
6
|
+
@Published var preferences = Preferences(
|
|
7
|
+
locale: .en,
|
|
8
|
+
theme: .light,
|
|
9
|
+
remindersEnabled: true,
|
|
10
|
+
dailySummaryEnabled: false
|
|
11
|
+
)
|
|
12
|
+
@Published var tasks: [Task] = Task.seed()
|
|
13
|
+
@Published var rules: [RecurringRule] = []
|
|
14
|
+
@Published var selectedTaskID: UUID?
|
|
15
|
+
@Published var toast: ToastMessage?
|
|
16
|
+
|
|
17
|
+
init() {
|
|
18
|
+
selectedTaskID = tasks.first?.id
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var locale: Locale { Locale(identifier: preferences.locale.rawValue) }
|
|
22
|
+
|
|
23
|
+
func string(_ key: String) -> String {
|
|
24
|
+
let bundle = Bundle.main.path(
|
|
25
|
+
forResource: preferences.locale.rawValue,
|
|
26
|
+
ofType: "lproj"
|
|
27
|
+
).flatMap { Bundle(path: $0) } ?? .main
|
|
28
|
+
return bundle.localizedString(forKey: key, value: key, table: nil)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func format(_ key: String, _ arguments: CVarArg...) -> String {
|
|
32
|
+
let format = string(key)
|
|
33
|
+
return String(format: format, locale: locale, arguments: arguments)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func homeSummary() -> String {
|
|
37
|
+
let open = tasks.filter { $0.status == .open }.count
|
|
38
|
+
let total = tasks.count
|
|
39
|
+
if preferences.locale == .ru {
|
|
40
|
+
if open == 0 { return string("home.summary.done") }
|
|
41
|
+
return format("home.summary.remaining", open, total)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if open == 0 { return string("home.summary.done") }
|
|
45
|
+
return format("home.summary.remaining", open, total)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func taskCount(for filter: TaskFilter) -> Int {
|
|
49
|
+
switch filter {
|
|
50
|
+
case .all:
|
|
51
|
+
return tasks.count
|
|
52
|
+
case .open:
|
|
53
|
+
return tasks.filter { $0.status == .open }.count
|
|
54
|
+
case .done:
|
|
55
|
+
return tasks.filter { $0.status == .done }.count
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func filteredTasks(filter: TaskFilter, search: String) -> [Task] {
|
|
60
|
+
tasks.filter { task in
|
|
61
|
+
let matchesFilter: Bool
|
|
62
|
+
switch filter {
|
|
63
|
+
case .all: matchesFilter = true
|
|
64
|
+
case .open: matchesFilter = task.status == .open
|
|
65
|
+
case .done: matchesFilter = task.status == .done
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let query = search.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
69
|
+
let matchesSearch = query.isEmpty || "\(task.title) \(task.notes)".lowercased().contains(query)
|
|
70
|
+
return matchesFilter && matchesSearch
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func task(id: UUID?) -> Task? {
|
|
75
|
+
guard let id else { return nil }
|
|
76
|
+
return tasks.first(where: { $0.id == id })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func binding(for taskID: UUID) -> Binding<Task>? {
|
|
80
|
+
guard let index = tasks.firstIndex(where: { $0.id == taskID }) else { return nil }
|
|
81
|
+
return Binding(
|
|
82
|
+
get: { self.tasks[index] },
|
|
83
|
+
set: { self.tasks[index] = $0 }
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func savePreferences(_ draft: Preferences) {
|
|
88
|
+
preferences = draft
|
|
89
|
+
showToast(level: .success, text: string("settings.saved"))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func makeTaskDraft(for task: Task?) -> TaskEditorDraft {
|
|
93
|
+
TaskEditorDraft(
|
|
94
|
+
title: task?.title ?? "",
|
|
95
|
+
notes: task?.notes ?? "",
|
|
96
|
+
priority: task?.priority ?? .medium,
|
|
97
|
+
dueDate: task?.dueDate
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func submitTask(_ draft: TaskEditorDraft, editing taskID: UUID?) -> String? {
|
|
102
|
+
let title = draft.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
103
|
+
guard title.count >= 2 else {
|
|
104
|
+
return format("validation.min_length", 2)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if let taskID, let index = tasks.firstIndex(where: { $0.id == taskID }) {
|
|
108
|
+
tasks[index].title = title
|
|
109
|
+
tasks[index].notes = draft.notes.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
110
|
+
tasks[index].priority = draft.priority
|
|
111
|
+
tasks[index].dueDate = draft.dueDate
|
|
112
|
+
tasks[index].updatedAt = .now
|
|
113
|
+
showToast(level: .success, text: string("edit_task.success"))
|
|
114
|
+
} else {
|
|
115
|
+
let next = Task(
|
|
116
|
+
id: UUID(),
|
|
117
|
+
title: title,
|
|
118
|
+
notes: draft.notes.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
119
|
+
status: .open,
|
|
120
|
+
priority: draft.priority,
|
|
121
|
+
dueDate: draft.dueDate,
|
|
122
|
+
createdAt: .now,
|
|
123
|
+
updatedAt: .now
|
|
124
|
+
)
|
|
125
|
+
tasks.insert(next, at: 0)
|
|
126
|
+
selectedTaskID = next.id
|
|
127
|
+
showToast(level: .success, text: string("create_task.success"))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func toggleTaskStatus(_ taskID: UUID) {
|
|
134
|
+
guard let index = tasks.firstIndex(where: { $0.id == taskID }) else { return }
|
|
135
|
+
tasks[index].status = tasks[index].status == .done ? .open : .done
|
|
136
|
+
tasks[index].updatedAt = .now
|
|
137
|
+
showToast(level: .success, text: string("task_detail.updated_feedback"))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func deleteTask(_ taskID: UUID) {
|
|
141
|
+
tasks.removeAll { $0.id == taskID }
|
|
142
|
+
if selectedTaskID == taskID {
|
|
143
|
+
selectedTaskID = tasks.first?.id
|
|
144
|
+
}
|
|
145
|
+
showToast(level: .success, text: string("task_detail.deleted_feedback"))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func addRecurringRule(from draft: RecurringRuleDraft) -> [String: String] {
|
|
149
|
+
var errors: [String: String] = [:]
|
|
150
|
+
|
|
151
|
+
let trimmedName = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
152
|
+
if trimmedName.count < 4 {
|
|
153
|
+
errors["name"] = format("validation.rule_name_min_length", 4)
|
|
154
|
+
} else if trimmedName == "Default" {
|
|
155
|
+
errors["name"] = string("validation.rule_name_reserved")
|
|
156
|
+
} else if rules.contains(where: { $0.name.caseInsensitiveCompare(trimmedName) == .orderedSame }) {
|
|
157
|
+
errors["name"] = string("validation.rule_name_taken")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if draft.confirmName.trimmingCharacters(in: .whitespacesAndNewlines) != trimmedName {
|
|
161
|
+
errors["confirmName"] = string("validation.match_field")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
guard let cadence = draft.cadence else {
|
|
165
|
+
errors["cadence"] = string("validation.required")
|
|
166
|
+
return errors
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
guard let interval = Int(draft.interval), interval >= 1 else {
|
|
170
|
+
errors["interval"] = format("validation.min_value", 1)
|
|
171
|
+
return errors
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if interval > 30 {
|
|
175
|
+
errors["interval"] = format("validation.max_value", 30)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if cadence == .weekly, draft.weekday == nil {
|
|
179
|
+
errors["weekday"] = string("validation.required")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
var monthDayValue: Int?
|
|
183
|
+
if cadence == .monthly {
|
|
184
|
+
guard let day = Int(draft.monthDay), day >= 1 else {
|
|
185
|
+
errors["monthDay"] = format("validation.min_value", 1)
|
|
186
|
+
return errors
|
|
187
|
+
}
|
|
188
|
+
if day > 28 {
|
|
189
|
+
errors["monthDay"] = string("validation.month_day_max")
|
|
190
|
+
} else {
|
|
191
|
+
monthDayValue = day
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if draft.hasEndDate && draft.endDate < draft.startDate {
|
|
196
|
+
errors["endDate"] = string("validation.end_date_after_start")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if preferences.remindersEnabled {
|
|
200
|
+
let regex = try? NSRegularExpression(pattern: "^([01]\\d|2[0-3]):[0-5]\\d$")
|
|
201
|
+
let range = NSRange(location: 0, length: draft.remindAt.utf16.count)
|
|
202
|
+
let matches = regex?.firstMatch(in: draft.remindAt, options: [], range: range) != nil
|
|
203
|
+
if !matches {
|
|
204
|
+
errors["remindAt"] = string("validation.time_format")
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if draft.enableSummary && draft.summaryChannel == nil {
|
|
209
|
+
errors["summaryChannel"] = string("validation.required")
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
guard errors.isEmpty else { return errors }
|
|
213
|
+
|
|
214
|
+
let rule = RecurringRule(
|
|
215
|
+
id: UUID(),
|
|
216
|
+
name: trimmedName,
|
|
217
|
+
cadence: cadence,
|
|
218
|
+
interval: interval,
|
|
219
|
+
weekday: draft.weekday,
|
|
220
|
+
monthDay: monthDayValue,
|
|
221
|
+
startDate: draft.startDate,
|
|
222
|
+
endDate: draft.hasEndDate ? draft.endDate : nil,
|
|
223
|
+
remindAt: preferences.remindersEnabled ? draft.remindAt : nil,
|
|
224
|
+
summaryChannel: draft.enableSummary ? draft.summaryChannel : nil
|
|
225
|
+
)
|
|
226
|
+
rules.insert(rule, at: 0)
|
|
227
|
+
showToast(level: .success, text: string("recurring_rule.success"))
|
|
228
|
+
return [:]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
func analyticsSnapshot() -> AnalyticsSnapshot {
|
|
232
|
+
let calendar = Calendar.current
|
|
233
|
+
let startOfToday = calendar.startOfDay(for: .now)
|
|
234
|
+
let completedToday = tasks.filter {
|
|
235
|
+
$0.status == .done && $0.updatedAt >= startOfToday
|
|
236
|
+
}.count
|
|
237
|
+
let openTasks = tasks.filter { $0.status == .open }.count
|
|
238
|
+
let overdueTasks = overdueTasks().count
|
|
239
|
+
let completionRate = tasks.isEmpty ? 0 : Int(((Double(tasks.count - openTasks) / Double(tasks.count)) * 100).rounded())
|
|
240
|
+
return AnalyticsSnapshot(
|
|
241
|
+
completedToday: completedToday,
|
|
242
|
+
openTasks: openTasks,
|
|
243
|
+
overdueTasks: overdueTasks,
|
|
244
|
+
completionRate: completionRate
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func trendSeries(period: AnalyticsPeriod) -> [TrendPoint] {
|
|
249
|
+
let calendar = Calendar.current
|
|
250
|
+
let formatter = DateFormatter()
|
|
251
|
+
formatter.locale = locale
|
|
252
|
+
formatter.dateFormat = period == .week ? "E" : "MMM d"
|
|
253
|
+
let length: Int = switch period {
|
|
254
|
+
case .week: 7
|
|
255
|
+
case .month: 6
|
|
256
|
+
case .quarter: 8
|
|
257
|
+
}
|
|
258
|
+
let strideDays: Int = period == .week ? 1 : 5
|
|
259
|
+
|
|
260
|
+
return (0..<length).map { index in
|
|
261
|
+
let offset = length - index - 1
|
|
262
|
+
let pointDate = calendar.date(byAdding: .day, value: -(offset * strideDays), to: .now) ?? .now
|
|
263
|
+
let completed = tasks.filter { $0.status == .done && $0.updatedAt <= pointDate }.count
|
|
264
|
+
let created = tasks.filter { $0.createdAt <= pointDate }.count
|
|
265
|
+
return TrendPoint(label: formatter.string(from: pointDate), completed: completed, created: created)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
func overdueTasks() -> [Task] {
|
|
270
|
+
let startOfToday = Calendar.current.startOfDay(for: .now)
|
|
271
|
+
return tasks.filter { task in
|
|
272
|
+
task.status == .open && (task.dueDate.map { $0 < startOfToday } ?? false)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func formatDate(_ date: Date?) -> String {
|
|
277
|
+
guard let date else { return string("task_detail.no_due_date") }
|
|
278
|
+
let formatter = DateFormatter()
|
|
279
|
+
formatter.locale = locale
|
|
280
|
+
formatter.dateStyle = .medium
|
|
281
|
+
formatter.timeStyle = .none
|
|
282
|
+
return formatter.string(from: date)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
func formatRelativeDueDate(_ date: Date?) -> String {
|
|
286
|
+
guard let date else { return string("task_detail.no_due_date") }
|
|
287
|
+
let formatter = RelativeDateTimeFormatter()
|
|
288
|
+
formatter.locale = locale
|
|
289
|
+
formatter.unitsStyle = .full
|
|
290
|
+
return formatter.localizedString(for: date, relativeTo: .now)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func label(for priority: TaskPriority) -> String { string("priority.\(priority.rawValue)") }
|
|
294
|
+
func label(for status: TaskStatus) -> String { string("status.\(status.rawValue)") }
|
|
295
|
+
func label(for weekday: Weekday) -> String { string("weekday.\(weekday.rawValue)") }
|
|
296
|
+
|
|
297
|
+
func describe(rule: RecurringRule) -> String {
|
|
298
|
+
switch rule.cadence {
|
|
299
|
+
case .daily:
|
|
300
|
+
return "\(string("recurring_rule.cadence_daily")) · \(formatDate(rule.startDate))"
|
|
301
|
+
case .weekly:
|
|
302
|
+
return "\(string("recurring_rule.cadence_weekly")) · \(label(for: rule.weekday ?? .mon))"
|
|
303
|
+
case .monthly:
|
|
304
|
+
return "\(string("recurring_rule.cadence_monthly")) · \(rule.monthDay ?? 1)"
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func showToast(level: ToastMessage.Level, text: String) {
|
|
309
|
+
withAnimation(.spring(duration: 0.32)) {
|
|
310
|
+
toast = ToastMessage(level: level, text: text)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_Concurrency.Task { [weak self] in
|
|
314
|
+
try? await _Concurrency.Task.sleep(for: .seconds(2.5))
|
|
315
|
+
guard let self else { return }
|
|
316
|
+
await MainActor.run {
|
|
317
|
+
withAnimation(.easeOut(duration: 0.2)) {
|
|
318
|
+
self.toast = nil
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
}
|