forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathConversationListView.swift
More file actions
235 lines (208 loc) · 7.17 KB
/
Copy pathConversationListView.swift
File metadata and controls
235 lines (208 loc) · 7.17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import OmiTheme
import SwiftUI
/// List view showing conversations grouped by date
struct ConversationListView: View {
let conversations: [ServerConversation]
let isLoading: Bool
let error: String?
let folders: [Folder]
var isCompactView: Bool = true
let onSelect: (ServerConversation) -> Void
let onRefresh: () -> Void
let onMoveToFolder: (String, String?) async -> Void
// Multi-select support
var isMultiSelectMode: Bool = false
var selectedIds: Set<String> = []
var onToggleSelection: ((String) -> Void)? = nil
/// When true, renders without its own ScrollView (for embedding in an outer ScrollView)
var embedded: Bool = false
var appState: AppState
private static let groupDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "MMM d, yyyy"
return f
}()
/// Flat list item — either a section header or a conversation row.
/// Using a single flat ForEach avoids nested ForEach attribute graph depth which can cause
/// SwiftUI layout comparison hangs (AG::LayoutDescriptor::compare) on refresh.
private enum ListItem: Identifiable {
case header(key: String, isFirst: Bool)
case conversation(ServerConversation)
var id: String {
switch self {
case .header(let key, _): return "header_\(key)"
case .conversation(let c): return c.id
}
}
}
/// Flat ordered list of headers + conversations, grouped by date.
private var flatListItems: [ListItem] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
let formatter = Self.groupDateFormatter
var groups: [String: [ServerConversation]] = [:]
var groupDates: [String: Date] = ["Today": today, "Yesterday": yesterday]
for conversation in conversations {
let conversationDate = calendar.startOfDay(for: conversation.createdAt)
let groupKey: String
if conversationDate == today {
groupKey = "Today"
} else if conversationDate == yesterday {
groupKey = "Yesterday"
} else {
groupKey = formatter.string(from: conversation.createdAt)
groupDates[groupKey] = conversationDate
}
groups[groupKey, default: []].append(conversation)
}
// Sort groups: Today first, then Yesterday, then by date descending
let sortedKeys = groups.keys.sorted { key1, key2 in
if key1 == "Today" { return true }
if key2 == "Today" { return false }
if key1 == "Yesterday" { return true }
if key2 == "Yesterday" { return false }
let date1 = groupDates[key1] ?? .distantPast
let date2 = groupDates[key2] ?? .distantPast
return date1 > date2
}
var items: [ListItem] = []
for (index, key) in sortedKeys.enumerated() {
guard let convos = groups[key] else { continue }
items.append(.header(key: key, isFirst: index == 0))
for conv in convos {
items.append(.conversation(conv))
}
}
return items
}
var body: some View {
Group {
if isLoading && conversations.isEmpty {
loadingView
} else if let error = error, conversations.isEmpty {
errorView(error)
} else if conversations.isEmpty {
emptyView
} else {
conversationList
}
}
}
private var loadingView: some View {
VStack(spacing: OmiSpacing.lg) {
ProgressView()
.scaleEffect(1.2)
.tint(Ink.secondary)
Text("Loading conversations...")
.scaledFont(size: OmiType.body)
.foregroundColor(Ink.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorView(_: String) -> some View {
VStack(spacing: OmiSpacing.lg) {
Image(systemName: "exclamationmark.triangle")
.scaledFont(size: OmiType.hero)
.foregroundColor(PageGlass.warning)
Text("Failed to load conversations")
.scaledFont(size: OmiType.subheading, weight: .medium)
.foregroundColor(Ink.primary)
Text("Check your connection and try again.")
.scaledFont(size: OmiType.body)
.foregroundColor(Ink.secondary)
.multilineTextAlignment(.center)
Button(action: onRefresh) {
Text("Try Again")
.scaledFont(size: OmiType.body, weight: .medium)
.foregroundColor(Ink.primary)
.padding(.horizontal, OmiSpacing.xl)
.padding(.vertical, OmiSpacing.sm)
.glassChip(isActive: true)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(OmiSpacing.section)
}
private var emptyView: some View {
VStack(spacing: OmiSpacing.lg) {
Image(systemName: "bubble.left.and.bubble.right")
.scaledFont(size: 48)
.foregroundColor(Ink.secondary)
Text("No Conversations")
.scaledFont(size: OmiType.heading, weight: .semibold)
.foregroundColor(Ink.primary)
Text("Start recording to capture your first conversation")
.scaledFont(size: OmiType.body)
.foregroundColor(Ink.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(OmiSpacing.section)
}
private var conversationListContent: some View {
let items = flatListItems
return LazyVStack(alignment: .leading, spacing: OmiSpacing.md) {
ForEach(items) { item in
switch item {
case .header(let key, let isFirst):
Text(key)
.scaledFont(size: OmiType.body, weight: .semibold)
.foregroundColor(Ink.secondary)
.padding(.top, isFirst ? 0 : OmiSpacing.lg)
.padding(.bottom, OmiSpacing.xs)
case .conversation(let conversation):
ConversationRowView(
conversation: conversation,
onTap: { onSelect(conversation) },
folders: folders,
onMoveToFolder: onMoveToFolder,
isCompactView: isCompactView,
isMultiSelectMode: isMultiSelectMode,
isSelected: selectedIds.contains(conversation.id),
onToggleSelection: { onToggleSelection?(conversation.id) },
appState: appState
)
}
}
}
.padding(.horizontal, PagePanelVerticalRhythm.horizontalPadding)
.padding(.top, PagePanelVerticalRhythm.contentGap)
.padding(.bottom, PagePanelVerticalRhythm.contentBottomPadding)
// Keyed on identity only: a finished capture slides into the list as one
// row change rather than a repaint, and field updates stay animation-free.
.omiAnimation(.easeInOut(duration: 0.25), value: conversations.map(\.id))
}
private var conversationList: some View {
Group {
if embedded {
conversationListContent
} else {
ScrollView {
conversationListContent
}
.refreshable {
onRefresh()
}
.glassScrollFade()
}
}
}
}
#if canImport(PreviewsMacros)
#Preview {
ConversationListView(
conversations: [],
isLoading: false,
error: nil,
folders: [],
onSelect: { _ in },
onRefresh: {},
onMoveToFolder: { _, _ in },
appState: AppState()
)
.frame(width: 400, height: 600)
.background(Ink.surface)
}
#endif