neoagent 3.0.1-beta.9 → 3.1.0
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/.env.example +2 -10
- package/README.md +12 -3
- package/docs/automation.md +37 -0
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +34 -8
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +10 -8
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +3 -0
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +38 -13
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1612 -214
- package/flutter_app/lib/main_controller.dart +398 -26
- package/flutter_app/lib/main_devices.dart +293 -207
- package/flutter_app/lib/main_models.dart +142 -0
- package/flutter_app/lib/main_navigation.dart +19 -1
- package/flutter_app/lib/main_operations.dart +288 -9
- package/flutter_app/lib/main_settings.dart +510 -300
- package/flutter_app/lib/main_shared.dart +2 -0
- package/flutter_app/lib/main_timeline.dart +1378 -0
- package/flutter_app/lib/src/backend_client.dart +149 -19
- package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
- package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
- package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
- package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
- package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
- package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
- package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
- package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
- package/flutter_app/lib/src/recording_payloads.dart +9 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
- package/flutter_app/windows/runner/flutter_window.cpp +75 -0
- package/landing/index.html +3 -3
- package/lib/manager.js +184 -66
- package/package.json +4 -1
- package/server/admin/access.js +12 -7
- package/server/admin/admin.js +436 -10
- package/server/admin/billing.js +158 -35
- package/server/admin/index.html +72 -2
- package/server/admin/users.js +15 -15
- package/server/db/database.js +125 -20
- package/server/http/routes.js +1 -0
- package/server/http/static.js +4 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.js.symbols +8475 -8467
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +91077 -87037
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +345 -64
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +5 -0
- package/server/routes/browser.js +8 -1
- package/server/routes/recordings.js +96 -6
- package/server/routes/screenHistory.js +140 -2
- package/server/routes/timeline.js +43 -0
- package/server/services/account/erasure.js +263 -0
- package/server/services/ai/hooks.js +4 -1
- package/server/services/ai/loop/agent_engine_core.js +8 -1
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/conversation_loop.js +166 -33
- package/server/services/ai/messagingFallback.js +22 -0
- package/server/services/ai/rate_limits.js +28 -5
- package/server/services/ai/systemPrompt.js +6 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/toolEvidence.js +15 -0
- package/server/services/ai/toolResult.js +40 -0
- package/server/services/ai/tools.js +163 -4
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/plans.js +2 -1
- package/server/services/browser/anti_detection.js +192 -0
- package/server/services/browser/controller.js +180 -54
- package/server/services/desktop/auth.js +3 -0
- package/server/services/desktop/registry.js +50 -2
- package/server/services/integrations/google/calendar.js +22 -14
- package/server/services/manager.js +12 -42
- package/server/services/memory/ingestion_chunking.js +268 -0
- package/server/services/messaging/telnyx.js +9 -8
- package/server/services/recordings/manager.js +60 -27
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +188 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/social_video/service.js +60 -10
- package/server/services/tasks/runtime.js +234 -9
- package/server/services/tasks/task_repository.js +13 -0
- package/server/services/timeline/service.js +558 -0
- package/server/services/wearable/gateway.js +1 -1
- package/server/services/websocket.js +21 -3
- package/server/services/desktop/screenRecorder.js +0 -292
- package/server/services/desktop/screen_recorder_support.js +0 -46
|
@@ -0,0 +1,1378 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
class TimelinePanel extends StatefulWidget {
|
|
4
|
+
const TimelinePanel({super.key, required this.controller});
|
|
5
|
+
|
|
6
|
+
final NeoAgentController controller;
|
|
7
|
+
|
|
8
|
+
@override
|
|
9
|
+
State<TimelinePanel> createState() => _TimelinePanelState();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class _TimelinePanelState extends State<TimelinePanel> {
|
|
13
|
+
int? _selectedEventId;
|
|
14
|
+
|
|
15
|
+
void _selectEvent(TimelineEventItem item) {
|
|
16
|
+
if (_selectedEventId == item.id) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
setState(() {
|
|
20
|
+
_selectedEventId = item.id;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
void _moveSelection(List<TimelineEventItem> items, int offset) {
|
|
25
|
+
if (items.isEmpty) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
final currentIndex = items.indexWhere(
|
|
29
|
+
(item) => item.id == _selectedEventId,
|
|
30
|
+
);
|
|
31
|
+
final baseIndex = currentIndex == -1
|
|
32
|
+
? (offset > 0 ? 0 : items.length - 1)
|
|
33
|
+
: currentIndex;
|
|
34
|
+
final nextIndex = (baseIndex + offset).clamp(0, items.length - 1);
|
|
35
|
+
setState(() {
|
|
36
|
+
_selectedEventId = items[nextIndex].id;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
TimelineEventItem? _resolveSelectedEvent(List<TimelineEventItem> items) {
|
|
41
|
+
if (items.isEmpty) {
|
|
42
|
+
_selectedEventId = null;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
for (final item in items) {
|
|
46
|
+
if (item.id == _selectedEventId) {
|
|
47
|
+
return item;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
_selectedEventId = items.first.id;
|
|
51
|
+
return items.first;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@override
|
|
55
|
+
Widget build(BuildContext context) {
|
|
56
|
+
final items = _sortedTimelineEvents(widget.controller.timelineItems);
|
|
57
|
+
final groups = _groupTimelineEvents(items);
|
|
58
|
+
final selectedEvent = _resolveSelectedEvent(items);
|
|
59
|
+
final selectedIndex = selectedEvent == null
|
|
60
|
+
? -1
|
|
61
|
+
: items.indexWhere((item) => item.id == selectedEvent.id);
|
|
62
|
+
|
|
63
|
+
return Padding(
|
|
64
|
+
padding: _pagePadding(context),
|
|
65
|
+
child: Column(
|
|
66
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
67
|
+
children: <Widget>[
|
|
68
|
+
_TimelineHeroHeader(
|
|
69
|
+
items: items,
|
|
70
|
+
selectedEvent: selectedEvent,
|
|
71
|
+
controller: widget.controller,
|
|
72
|
+
),
|
|
73
|
+
const SizedBox(height: 18),
|
|
74
|
+
Expanded(
|
|
75
|
+
child: items.isEmpty
|
|
76
|
+
? Card(
|
|
77
|
+
child: Center(
|
|
78
|
+
child: Padding(
|
|
79
|
+
padding: const EdgeInsets.all(28),
|
|
80
|
+
child: Text(
|
|
81
|
+
'No timeline activity yet for the selected filters.',
|
|
82
|
+
style: TextStyle(color: _textSecondary),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
: LayoutBuilder(
|
|
88
|
+
builder: (context, constraints) {
|
|
89
|
+
final isWide = constraints.maxWidth >= 1180;
|
|
90
|
+
final feedPane = _TimelineFeedPane(
|
|
91
|
+
groups: groups,
|
|
92
|
+
selectedEventId: selectedEvent?.id,
|
|
93
|
+
onSelectEvent: _selectEvent,
|
|
94
|
+
);
|
|
95
|
+
final detailPane = _TimelineDetailPane(
|
|
96
|
+
items: items,
|
|
97
|
+
selectedEvent: selectedEvent,
|
|
98
|
+
selectedIndex: selectedIndex,
|
|
99
|
+
onSelectPrevious: selectedIndex > 0
|
|
100
|
+
? () => _moveSelection(items, -1)
|
|
101
|
+
: null,
|
|
102
|
+
onSelectNext:
|
|
103
|
+
selectedIndex >= 0 &&
|
|
104
|
+
selectedIndex < items.length - 1
|
|
105
|
+
? () => _moveSelection(items, 1)
|
|
106
|
+
: null,
|
|
107
|
+
onOpenRun:
|
|
108
|
+
selectedEvent != null &&
|
|
109
|
+
selectedEvent.runId.isNotEmpty
|
|
110
|
+
? () => unawaited(
|
|
111
|
+
widget.controller.openRunDetails(
|
|
112
|
+
selectedEvent.runId,
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
: null,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (isWide) {
|
|
119
|
+
return Row(
|
|
120
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
121
|
+
children: <Widget>[
|
|
122
|
+
Expanded(flex: 10, child: feedPane),
|
|
123
|
+
const SizedBox(width: 16),
|
|
124
|
+
SizedBox(width: 420, child: detailPane),
|
|
125
|
+
],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Column(
|
|
130
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
131
|
+
children: <Widget>[
|
|
132
|
+
Expanded(flex: 11, child: feedPane),
|
|
133
|
+
const SizedBox(height: 16),
|
|
134
|
+
Expanded(flex: 8, child: detailPane),
|
|
135
|
+
],
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
],
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
class _TimelineHeroHeader extends StatelessWidget {
|
|
147
|
+
const _TimelineHeroHeader({
|
|
148
|
+
required this.items,
|
|
149
|
+
required this.selectedEvent,
|
|
150
|
+
required this.controller,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
final List<TimelineEventItem> items;
|
|
154
|
+
final TimelineEventItem? selectedEvent;
|
|
155
|
+
final NeoAgentController controller;
|
|
156
|
+
|
|
157
|
+
@override
|
|
158
|
+
Widget build(BuildContext context) {
|
|
159
|
+
final focusedDay =
|
|
160
|
+
selectedEvent?.occurredAt ??
|
|
161
|
+
(items.isEmpty ? null : items.first.occurredAt);
|
|
162
|
+
|
|
163
|
+
return Container(
|
|
164
|
+
width: double.infinity,
|
|
165
|
+
padding: const EdgeInsets.all(22),
|
|
166
|
+
decoration: BoxDecoration(
|
|
167
|
+
gradient: LinearGradient(
|
|
168
|
+
colors: <Color>[
|
|
169
|
+
_bgSecondary.withValues(alpha: 0.96),
|
|
170
|
+
_bgPrimary.withValues(alpha: 0.9),
|
|
171
|
+
],
|
|
172
|
+
begin: Alignment.topLeft,
|
|
173
|
+
end: Alignment.bottomRight,
|
|
174
|
+
),
|
|
175
|
+
borderRadius: BorderRadius.circular(28),
|
|
176
|
+
border: Border.all(color: _borderLight),
|
|
177
|
+
boxShadow: <BoxShadow>[
|
|
178
|
+
BoxShadow(
|
|
179
|
+
color: Colors.black.withValues(alpha: 0.12),
|
|
180
|
+
blurRadius: 26,
|
|
181
|
+
offset: const Offset(0, 16),
|
|
182
|
+
),
|
|
183
|
+
],
|
|
184
|
+
),
|
|
185
|
+
child: Column(
|
|
186
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
187
|
+
children: <Widget>[
|
|
188
|
+
LayoutBuilder(
|
|
189
|
+
builder: (context, constraints) {
|
|
190
|
+
final isWide = constraints.maxWidth >= 940;
|
|
191
|
+
final heading = Column(
|
|
192
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
193
|
+
children: <Widget>[
|
|
194
|
+
Text(
|
|
195
|
+
'ACTIVITY FEED',
|
|
196
|
+
style: TextStyle(
|
|
197
|
+
color: _accentHover,
|
|
198
|
+
fontSize: 13,
|
|
199
|
+
fontWeight: FontWeight.w700,
|
|
200
|
+
letterSpacing: 4.2,
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
const SizedBox(height: 14),
|
|
204
|
+
const Text(
|
|
205
|
+
'Timeline',
|
|
206
|
+
style: TextStyle(
|
|
207
|
+
fontSize: 40,
|
|
208
|
+
fontWeight: FontWeight.w800,
|
|
209
|
+
height: 1,
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
const SizedBox(height: 14),
|
|
213
|
+
Text(
|
|
214
|
+
'Emails, AI actions, recordings, tasks and run activity in one chronological feed.',
|
|
215
|
+
style: TextStyle(
|
|
216
|
+
color: _textSecondary,
|
|
217
|
+
fontSize: 16.5,
|
|
218
|
+
height: 1.35,
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
],
|
|
222
|
+
);
|
|
223
|
+
final summary = Wrap(
|
|
224
|
+
spacing: 10,
|
|
225
|
+
runSpacing: 10,
|
|
226
|
+
alignment: isWide ? WrapAlignment.end : WrapAlignment.start,
|
|
227
|
+
children: <Widget>[
|
|
228
|
+
if (items.isNotEmpty)
|
|
229
|
+
_TimelineStatPill(
|
|
230
|
+
icon: Icons.event_note_rounded,
|
|
231
|
+
label: '${items.length} events',
|
|
232
|
+
),
|
|
233
|
+
if (items.length > 1)
|
|
234
|
+
_TimelineStatPill(
|
|
235
|
+
icon: Icons.schedule_outlined,
|
|
236
|
+
label: _formatTimelineRange(items.first, items.last),
|
|
237
|
+
),
|
|
238
|
+
if (focusedDay != null)
|
|
239
|
+
_TimelineStatPill(
|
|
240
|
+
icon: Icons.calendar_today_outlined,
|
|
241
|
+
label: _formatTimelineDate(focusedDay.toLocal()),
|
|
242
|
+
),
|
|
243
|
+
],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (isWide) {
|
|
247
|
+
return Row(
|
|
248
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
249
|
+
children: <Widget>[
|
|
250
|
+
Expanded(child: heading),
|
|
251
|
+
const SizedBox(width: 20),
|
|
252
|
+
ConstrainedBox(
|
|
253
|
+
constraints: const BoxConstraints(maxWidth: 440),
|
|
254
|
+
child: Align(
|
|
255
|
+
alignment: Alignment.topRight,
|
|
256
|
+
child: summary,
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
],
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return Column(
|
|
264
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
265
|
+
children: <Widget>[
|
|
266
|
+
heading,
|
|
267
|
+
const SizedBox(height: 18),
|
|
268
|
+
summary,
|
|
269
|
+
],
|
|
270
|
+
);
|
|
271
|
+
},
|
|
272
|
+
),
|
|
273
|
+
const SizedBox(height: 18),
|
|
274
|
+
Wrap(
|
|
275
|
+
spacing: 10,
|
|
276
|
+
runSpacing: 10,
|
|
277
|
+
children: <Widget>[
|
|
278
|
+
for (final filter in const <({String id, String label})>[
|
|
279
|
+
(id: 'screen', label: 'Screen'),
|
|
280
|
+
(id: 'tasks', label: 'Tasks'),
|
|
281
|
+
(id: 'runs', label: 'Runs'),
|
|
282
|
+
])
|
|
283
|
+
FilterChip(
|
|
284
|
+
selected: controller.selectedTimelineSources.contains(
|
|
285
|
+
filter.id,
|
|
286
|
+
),
|
|
287
|
+
label: Text(filter.label),
|
|
288
|
+
onSelected: (_) => controller.toggleTimelineSource(filter.id),
|
|
289
|
+
avatar: Icon(
|
|
290
|
+
_timelineLaneIcon(filter.id),
|
|
291
|
+
size: 16,
|
|
292
|
+
color:
|
|
293
|
+
controller.selectedTimelineSources.contains(filter.id)
|
|
294
|
+
? _bgSecondary
|
|
295
|
+
: _sourceColorForKind(filter.id),
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
OutlinedButton.icon(
|
|
299
|
+
onPressed: controller.isRefreshingTimeline
|
|
300
|
+
? null
|
|
301
|
+
: controller.refreshTimeline,
|
|
302
|
+
icon: controller.isRefreshingTimeline
|
|
303
|
+
? const SizedBox.square(
|
|
304
|
+
dimension: 14,
|
|
305
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
306
|
+
)
|
|
307
|
+
: const Icon(Icons.sync_outlined),
|
|
308
|
+
label: const Text('Refresh'),
|
|
309
|
+
),
|
|
310
|
+
],
|
|
311
|
+
),
|
|
312
|
+
],
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
class _TimelineFeedPane extends StatelessWidget {
|
|
319
|
+
const _TimelineFeedPane({
|
|
320
|
+
required this.groups,
|
|
321
|
+
required this.selectedEventId,
|
|
322
|
+
required this.onSelectEvent,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
final List<_TimelineDayGroup> groups;
|
|
326
|
+
final int? selectedEventId;
|
|
327
|
+
final ValueChanged<TimelineEventItem> onSelectEvent;
|
|
328
|
+
|
|
329
|
+
@override
|
|
330
|
+
Widget build(BuildContext context) {
|
|
331
|
+
return Card(
|
|
332
|
+
clipBehavior: Clip.antiAlias,
|
|
333
|
+
child: Column(
|
|
334
|
+
children: <Widget>[
|
|
335
|
+
Container(
|
|
336
|
+
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
|
337
|
+
decoration: BoxDecoration(
|
|
338
|
+
color: _bgSecondary.withValues(alpha: 0.78),
|
|
339
|
+
border: Border(bottom: BorderSide(color: _border)),
|
|
340
|
+
),
|
|
341
|
+
child: Row(
|
|
342
|
+
children: <Widget>[
|
|
343
|
+
Expanded(
|
|
344
|
+
child: Text(
|
|
345
|
+
'Timeline feed',
|
|
346
|
+
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
|
347
|
+
),
|
|
348
|
+
),
|
|
349
|
+
Text(
|
|
350
|
+
'${groups.fold<int>(0, (sum, group) => sum + group.items.length)} entries',
|
|
351
|
+
style: TextStyle(color: _textMuted, fontSize: 12.5),
|
|
352
|
+
),
|
|
353
|
+
],
|
|
354
|
+
),
|
|
355
|
+
),
|
|
356
|
+
Expanded(
|
|
357
|
+
child: ListView.builder(
|
|
358
|
+
padding: const EdgeInsets.all(0),
|
|
359
|
+
itemCount: groups.length,
|
|
360
|
+
itemBuilder: (context, index) {
|
|
361
|
+
final group = groups[index];
|
|
362
|
+
return _TimelineDaySection(
|
|
363
|
+
group: group,
|
|
364
|
+
selectedEventId: selectedEventId,
|
|
365
|
+
onSelectEvent: onSelectEvent,
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
),
|
|
369
|
+
),
|
|
370
|
+
],
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
class _TimelineDaySection extends StatelessWidget {
|
|
377
|
+
const _TimelineDaySection({
|
|
378
|
+
required this.group,
|
|
379
|
+
required this.selectedEventId,
|
|
380
|
+
required this.onSelectEvent,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
final _TimelineDayGroup group;
|
|
384
|
+
final int? selectedEventId;
|
|
385
|
+
final ValueChanged<TimelineEventItem> onSelectEvent;
|
|
386
|
+
|
|
387
|
+
@override
|
|
388
|
+
Widget build(BuildContext context) {
|
|
389
|
+
return Container(
|
|
390
|
+
decoration: BoxDecoration(
|
|
391
|
+
border: Border(bottom: BorderSide(color: _border)),
|
|
392
|
+
),
|
|
393
|
+
child: Column(
|
|
394
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
395
|
+
children: <Widget>[
|
|
396
|
+
Padding(
|
|
397
|
+
padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
|
|
398
|
+
child: Row(
|
|
399
|
+
children: <Widget>[
|
|
400
|
+
Container(
|
|
401
|
+
padding: const EdgeInsets.symmetric(
|
|
402
|
+
horizontal: 14,
|
|
403
|
+
vertical: 8,
|
|
404
|
+
),
|
|
405
|
+
decoration: BoxDecoration(
|
|
406
|
+
color: group.isToday ? _accent : _bgTertiary,
|
|
407
|
+
borderRadius: BorderRadius.circular(999),
|
|
408
|
+
),
|
|
409
|
+
child: Text(
|
|
410
|
+
group.label,
|
|
411
|
+
style: TextStyle(
|
|
412
|
+
color: group.isToday ? _bgSecondary : _textPrimary,
|
|
413
|
+
fontWeight: FontWeight.w800,
|
|
414
|
+
),
|
|
415
|
+
),
|
|
416
|
+
),
|
|
417
|
+
const SizedBox(width: 14),
|
|
418
|
+
Expanded(
|
|
419
|
+
child: Text(
|
|
420
|
+
group.dateLabel.toUpperCase(),
|
|
421
|
+
style: TextStyle(
|
|
422
|
+
color: _textMuted,
|
|
423
|
+
fontSize: 12.5,
|
|
424
|
+
fontWeight: FontWeight.w700,
|
|
425
|
+
letterSpacing: 3,
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
),
|
|
429
|
+
Text(
|
|
430
|
+
'${group.items.length} events',
|
|
431
|
+
style: TextStyle(color: _textMuted, fontSize: 12.5),
|
|
432
|
+
),
|
|
433
|
+
],
|
|
434
|
+
),
|
|
435
|
+
),
|
|
436
|
+
for (var index = 0; index < group.items.length; index++)
|
|
437
|
+
_TimelineFeedRow(
|
|
438
|
+
item: group.items[index],
|
|
439
|
+
isSelected: group.items[index].id == selectedEventId,
|
|
440
|
+
isFirst: index == 0,
|
|
441
|
+
isLast: index == group.items.length - 1,
|
|
442
|
+
onTap: () => onSelectEvent(group.items[index]),
|
|
443
|
+
),
|
|
444
|
+
],
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
class _TimelineFeedRow extends StatelessWidget {
|
|
451
|
+
const _TimelineFeedRow({
|
|
452
|
+
required this.item,
|
|
453
|
+
required this.isSelected,
|
|
454
|
+
required this.isFirst,
|
|
455
|
+
required this.isLast,
|
|
456
|
+
required this.onTap,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
final TimelineEventItem item;
|
|
460
|
+
final bool isSelected;
|
|
461
|
+
final bool isFirst;
|
|
462
|
+
final bool isLast;
|
|
463
|
+
final VoidCallback onTap;
|
|
464
|
+
|
|
465
|
+
@override
|
|
466
|
+
Widget build(BuildContext context) {
|
|
467
|
+
final sourceColor = item.sourceColor;
|
|
468
|
+
final chips = _timelineEventChips(item);
|
|
469
|
+
|
|
470
|
+
return Material(
|
|
471
|
+
color: isSelected
|
|
472
|
+
? sourceColor.withValues(alpha: 0.08)
|
|
473
|
+
: Colors.transparent,
|
|
474
|
+
child: InkWell(
|
|
475
|
+
onTap: onTap,
|
|
476
|
+
child: Padding(
|
|
477
|
+
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
|
478
|
+
child: Row(
|
|
479
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
480
|
+
children: <Widget>[
|
|
481
|
+
SizedBox(
|
|
482
|
+
width: 82,
|
|
483
|
+
child: Padding(
|
|
484
|
+
padding: const EdgeInsets.only(top: 28),
|
|
485
|
+
child: Text(
|
|
486
|
+
_formatTimelineTime(item.occurredAt),
|
|
487
|
+
style: TextStyle(
|
|
488
|
+
color: isSelected ? _textPrimary : _textMuted,
|
|
489
|
+
fontSize: 14,
|
|
490
|
+
fontWeight: FontWeight.w600,
|
|
491
|
+
),
|
|
492
|
+
),
|
|
493
|
+
),
|
|
494
|
+
),
|
|
495
|
+
SizedBox(
|
|
496
|
+
width: 30,
|
|
497
|
+
child: Column(
|
|
498
|
+
children: <Widget>[
|
|
499
|
+
Container(
|
|
500
|
+
width: 2,
|
|
501
|
+
height: isFirst ? 24 : 18,
|
|
502
|
+
color: isFirst ? Colors.transparent : _borderLight,
|
|
503
|
+
),
|
|
504
|
+
Container(
|
|
505
|
+
width: 16,
|
|
506
|
+
height: 16,
|
|
507
|
+
decoration: BoxDecoration(
|
|
508
|
+
shape: BoxShape.circle,
|
|
509
|
+
color: _bgCard,
|
|
510
|
+
border: Border.all(
|
|
511
|
+
color: sourceColor.withValues(alpha: 0.95),
|
|
512
|
+
width: 3,
|
|
513
|
+
),
|
|
514
|
+
boxShadow: <BoxShadow>[
|
|
515
|
+
BoxShadow(
|
|
516
|
+
color: sourceColor.withValues(alpha: 0.22),
|
|
517
|
+
blurRadius: 12,
|
|
518
|
+
),
|
|
519
|
+
],
|
|
520
|
+
),
|
|
521
|
+
),
|
|
522
|
+
Container(
|
|
523
|
+
width: 2,
|
|
524
|
+
height: isLast ? 26 : 148,
|
|
525
|
+
color: isLast ? Colors.transparent : _borderLight,
|
|
526
|
+
),
|
|
527
|
+
],
|
|
528
|
+
),
|
|
529
|
+
),
|
|
530
|
+
const SizedBox(width: 18),
|
|
531
|
+
Expanded(
|
|
532
|
+
child: Container(
|
|
533
|
+
margin: const EdgeInsets.only(top: 14, bottom: 14),
|
|
534
|
+
padding: const EdgeInsets.all(18),
|
|
535
|
+
decoration: BoxDecoration(
|
|
536
|
+
color: isSelected
|
|
537
|
+
? _bgCard.withValues(alpha: 0.98)
|
|
538
|
+
: _bgSecondary.withValues(alpha: 0.52),
|
|
539
|
+
borderRadius: BorderRadius.circular(20),
|
|
540
|
+
border: Border.all(
|
|
541
|
+
color: isSelected
|
|
542
|
+
? sourceColor.withValues(alpha: 0.45)
|
|
543
|
+
: _border,
|
|
544
|
+
),
|
|
545
|
+
boxShadow: isSelected
|
|
546
|
+
? <BoxShadow>[
|
|
547
|
+
BoxShadow(
|
|
548
|
+
color: sourceColor.withValues(alpha: 0.08),
|
|
549
|
+
blurRadius: 18,
|
|
550
|
+
offset: const Offset(0, 10),
|
|
551
|
+
),
|
|
552
|
+
]
|
|
553
|
+
: const <BoxShadow>[],
|
|
554
|
+
),
|
|
555
|
+
child: Column(
|
|
556
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
557
|
+
children: <Widget>[
|
|
558
|
+
Row(
|
|
559
|
+
children: <Widget>[
|
|
560
|
+
_TimelineTypeChip(item: item),
|
|
561
|
+
const Spacer(),
|
|
562
|
+
if (isSelected)
|
|
563
|
+
Icon(
|
|
564
|
+
Icons.chevron_right_rounded,
|
|
565
|
+
color: sourceColor,
|
|
566
|
+
),
|
|
567
|
+
],
|
|
568
|
+
),
|
|
569
|
+
const SizedBox(height: 14),
|
|
570
|
+
Text(
|
|
571
|
+
item.title.ifEmpty(item.taskName),
|
|
572
|
+
style: const TextStyle(
|
|
573
|
+
fontSize: 17,
|
|
574
|
+
fontWeight: FontWeight.w800,
|
|
575
|
+
height: 1.2,
|
|
576
|
+
),
|
|
577
|
+
),
|
|
578
|
+
const SizedBox(height: 10),
|
|
579
|
+
Text(
|
|
580
|
+
_timelineCardDescription(item),
|
|
581
|
+
maxLines: 2,
|
|
582
|
+
overflow: TextOverflow.ellipsis,
|
|
583
|
+
style: TextStyle(
|
|
584
|
+
color: _textSecondary,
|
|
585
|
+
fontSize: 14.5,
|
|
586
|
+
height: 1.4,
|
|
587
|
+
),
|
|
588
|
+
),
|
|
589
|
+
if (chips.isNotEmpty) ...<Widget>[
|
|
590
|
+
const SizedBox(height: 14),
|
|
591
|
+
Wrap(
|
|
592
|
+
spacing: 8,
|
|
593
|
+
runSpacing: 8,
|
|
594
|
+
children: <Widget>[
|
|
595
|
+
for (final chip in chips) _TimelineInlineChip(chip),
|
|
596
|
+
],
|
|
597
|
+
),
|
|
598
|
+
],
|
|
599
|
+
],
|
|
600
|
+
),
|
|
601
|
+
),
|
|
602
|
+
),
|
|
603
|
+
],
|
|
604
|
+
),
|
|
605
|
+
),
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
class _TimelineTypeChip extends StatelessWidget {
|
|
612
|
+
const _TimelineTypeChip({required this.item});
|
|
613
|
+
|
|
614
|
+
final TimelineEventItem item;
|
|
615
|
+
|
|
616
|
+
@override
|
|
617
|
+
Widget build(BuildContext context) {
|
|
618
|
+
return Container(
|
|
619
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
620
|
+
decoration: BoxDecoration(
|
|
621
|
+
color: item.sourceColor.withValues(alpha: 0.12),
|
|
622
|
+
borderRadius: BorderRadius.circular(999),
|
|
623
|
+
border: Border.all(color: item.sourceColor.withValues(alpha: 0.24)),
|
|
624
|
+
),
|
|
625
|
+
child: Row(
|
|
626
|
+
mainAxisSize: MainAxisSize.min,
|
|
627
|
+
children: <Widget>[
|
|
628
|
+
Icon(
|
|
629
|
+
_timelineLaneIcon(item.sourceKind),
|
|
630
|
+
size: 15,
|
|
631
|
+
color: item.sourceColor,
|
|
632
|
+
),
|
|
633
|
+
const SizedBox(width: 8),
|
|
634
|
+
Text(
|
|
635
|
+
item.sourceLabel.toLowerCase(),
|
|
636
|
+
style: TextStyle(
|
|
637
|
+
color: item.sourceColor,
|
|
638
|
+
fontSize: 13,
|
|
639
|
+
fontWeight: FontWeight.w700,
|
|
640
|
+
letterSpacing: 0.4,
|
|
641
|
+
),
|
|
642
|
+
),
|
|
643
|
+
],
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
class _TimelineInlineChip extends StatelessWidget {
|
|
650
|
+
const _TimelineInlineChip(this.chip);
|
|
651
|
+
|
|
652
|
+
final _TimelineChip chip;
|
|
653
|
+
|
|
654
|
+
@override
|
|
655
|
+
Widget build(BuildContext context) {
|
|
656
|
+
return Container(
|
|
657
|
+
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
|
658
|
+
decoration: BoxDecoration(
|
|
659
|
+
color: chip.color.withValues(alpha: 0.1),
|
|
660
|
+
borderRadius: BorderRadius.circular(12),
|
|
661
|
+
border: Border.all(color: chip.color.withValues(alpha: 0.2)),
|
|
662
|
+
),
|
|
663
|
+
child: Text(
|
|
664
|
+
chip.label,
|
|
665
|
+
style: TextStyle(
|
|
666
|
+
color: chip.color,
|
|
667
|
+
fontSize: 12.5,
|
|
668
|
+
fontWeight: FontWeight.w700,
|
|
669
|
+
),
|
|
670
|
+
),
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
class _TimelineDetailPane extends StatelessWidget {
|
|
676
|
+
const _TimelineDetailPane({
|
|
677
|
+
required this.items,
|
|
678
|
+
required this.selectedEvent,
|
|
679
|
+
required this.selectedIndex,
|
|
680
|
+
this.onSelectPrevious,
|
|
681
|
+
this.onSelectNext,
|
|
682
|
+
this.onOpenRun,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
final List<TimelineEventItem> items;
|
|
686
|
+
final TimelineEventItem? selectedEvent;
|
|
687
|
+
final int selectedIndex;
|
|
688
|
+
final VoidCallback? onSelectPrevious;
|
|
689
|
+
final VoidCallback? onSelectNext;
|
|
690
|
+
final VoidCallback? onOpenRun;
|
|
691
|
+
|
|
692
|
+
@override
|
|
693
|
+
Widget build(BuildContext context) {
|
|
694
|
+
final item = selectedEvent;
|
|
695
|
+
return Card(
|
|
696
|
+
clipBehavior: Clip.antiAlias,
|
|
697
|
+
child: Column(
|
|
698
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
699
|
+
children: <Widget>[
|
|
700
|
+
Container(
|
|
701
|
+
padding: const EdgeInsets.fromLTRB(20, 18, 20, 16),
|
|
702
|
+
decoration: BoxDecoration(
|
|
703
|
+
color: _bgSecondary.withValues(alpha: 0.8),
|
|
704
|
+
border: Border(bottom: BorderSide(color: _border)),
|
|
705
|
+
),
|
|
706
|
+
child: Row(
|
|
707
|
+
children: <Widget>[
|
|
708
|
+
Expanded(
|
|
709
|
+
child: Column(
|
|
710
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
711
|
+
children: <Widget>[
|
|
712
|
+
Text(
|
|
713
|
+
'DETAIL',
|
|
714
|
+
style: TextStyle(
|
|
715
|
+
color: _textMuted,
|
|
716
|
+
fontSize: 12,
|
|
717
|
+
fontWeight: FontWeight.w700,
|
|
718
|
+
letterSpacing: 4,
|
|
719
|
+
),
|
|
720
|
+
),
|
|
721
|
+
const SizedBox(height: 6),
|
|
722
|
+
const Text(
|
|
723
|
+
'Event detail',
|
|
724
|
+
style: TextStyle(
|
|
725
|
+
fontSize: 18,
|
|
726
|
+
fontWeight: FontWeight.w800,
|
|
727
|
+
),
|
|
728
|
+
),
|
|
729
|
+
],
|
|
730
|
+
),
|
|
731
|
+
),
|
|
732
|
+
IconButton(
|
|
733
|
+
tooltip: 'Previous event',
|
|
734
|
+
onPressed: onSelectPrevious,
|
|
735
|
+
icon: const Icon(Icons.chevron_left_rounded),
|
|
736
|
+
),
|
|
737
|
+
Text(
|
|
738
|
+
item == null ? '0/0' : '${selectedIndex + 1}/${items.length}',
|
|
739
|
+
style: TextStyle(color: _textMuted, fontSize: 12.5),
|
|
740
|
+
),
|
|
741
|
+
IconButton(
|
|
742
|
+
tooltip: 'Next event',
|
|
743
|
+
onPressed: onSelectNext,
|
|
744
|
+
icon: const Icon(Icons.chevron_right_rounded),
|
|
745
|
+
),
|
|
746
|
+
],
|
|
747
|
+
),
|
|
748
|
+
),
|
|
749
|
+
Expanded(
|
|
750
|
+
child: item == null
|
|
751
|
+
? Center(
|
|
752
|
+
child: Text(
|
|
753
|
+
'Select an event from the feed.',
|
|
754
|
+
style: TextStyle(color: _textSecondary),
|
|
755
|
+
),
|
|
756
|
+
)
|
|
757
|
+
: SingleChildScrollView(
|
|
758
|
+
padding: const EdgeInsets.all(20),
|
|
759
|
+
child: Column(
|
|
760
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
761
|
+
children: <Widget>[
|
|
762
|
+
Wrap(
|
|
763
|
+
spacing: 10,
|
|
764
|
+
runSpacing: 10,
|
|
765
|
+
children: <Widget>[
|
|
766
|
+
_TimelineStatPill(
|
|
767
|
+
icon: _timelineLaneIcon(item.sourceKind),
|
|
768
|
+
label: item.sourceLabel,
|
|
769
|
+
color: item.sourceColor,
|
|
770
|
+
),
|
|
771
|
+
_TimelineStatPill(
|
|
772
|
+
icon: Icons.schedule_outlined,
|
|
773
|
+
label:
|
|
774
|
+
'${_formatTimelineTime(item.occurredAt)} · ${_formatTimelineDate(item.occurredAt.toLocal())}',
|
|
775
|
+
),
|
|
776
|
+
],
|
|
777
|
+
),
|
|
778
|
+
const SizedBox(height: 18),
|
|
779
|
+
Text(
|
|
780
|
+
item.title.ifEmpty(item.taskName),
|
|
781
|
+
style: const TextStyle(
|
|
782
|
+
fontSize: 28,
|
|
783
|
+
fontWeight: FontWeight.w800,
|
|
784
|
+
height: 1.15,
|
|
785
|
+
),
|
|
786
|
+
),
|
|
787
|
+
const SizedBox(height: 12),
|
|
788
|
+
Text(
|
|
789
|
+
_timelineDetailDescription(item),
|
|
790
|
+
style: TextStyle(
|
|
791
|
+
color: _textSecondary,
|
|
792
|
+
fontSize: 15,
|
|
793
|
+
height: 1.5,
|
|
794
|
+
),
|
|
795
|
+
),
|
|
796
|
+
const SizedBox(height: 18),
|
|
797
|
+
Wrap(
|
|
798
|
+
spacing: 8,
|
|
799
|
+
runSpacing: 8,
|
|
800
|
+
children: <Widget>[
|
|
801
|
+
_TimelineInlineChip(
|
|
802
|
+
_TimelineChip(
|
|
803
|
+
label: _titleCase(
|
|
804
|
+
item.eventKind.replaceAll('_', ' '),
|
|
805
|
+
),
|
|
806
|
+
color: item.sourceColor,
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
for (final chip in _timelineEventChips(
|
|
810
|
+
item,
|
|
811
|
+
).take(4))
|
|
812
|
+
_TimelineInlineChip(chip),
|
|
813
|
+
],
|
|
814
|
+
),
|
|
815
|
+
const SizedBox(height: 20),
|
|
816
|
+
_TimelineDetailGrid(item: item),
|
|
817
|
+
if (onOpenRun != null) ...<Widget>[
|
|
818
|
+
const SizedBox(height: 20),
|
|
819
|
+
SizedBox(
|
|
820
|
+
width: double.infinity,
|
|
821
|
+
child: FilledButton.icon(
|
|
822
|
+
onPressed: onOpenRun,
|
|
823
|
+
icon: const Icon(Icons.open_in_new_rounded),
|
|
824
|
+
label: const Text('Open linked run'),
|
|
825
|
+
),
|
|
826
|
+
),
|
|
827
|
+
],
|
|
828
|
+
const SizedBox(height: 20),
|
|
829
|
+
..._timelineDetailBody(item, onOpenRun),
|
|
830
|
+
],
|
|
831
|
+
),
|
|
832
|
+
),
|
|
833
|
+
),
|
|
834
|
+
],
|
|
835
|
+
),
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
class _TimelineDetailGrid extends StatelessWidget {
|
|
841
|
+
const _TimelineDetailGrid({required this.item});
|
|
842
|
+
|
|
843
|
+
final TimelineEventItem item;
|
|
844
|
+
|
|
845
|
+
@override
|
|
846
|
+
Widget build(BuildContext context) {
|
|
847
|
+
final cells = _timelineDetailCells(item);
|
|
848
|
+
return GridView.builder(
|
|
849
|
+
shrinkWrap: true,
|
|
850
|
+
physics: const NeverScrollableScrollPhysics(),
|
|
851
|
+
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
852
|
+
crossAxisCount: 2,
|
|
853
|
+
mainAxisSpacing: 1,
|
|
854
|
+
crossAxisSpacing: 1,
|
|
855
|
+
childAspectRatio: 1.48,
|
|
856
|
+
),
|
|
857
|
+
itemCount: cells.length,
|
|
858
|
+
itemBuilder: (context, index) {
|
|
859
|
+
final cell = cells[index];
|
|
860
|
+
return Container(
|
|
861
|
+
padding: const EdgeInsets.all(16),
|
|
862
|
+
decoration: BoxDecoration(
|
|
863
|
+
color: _bgSecondary.withValues(alpha: 0.68),
|
|
864
|
+
border: Border.all(color: _border),
|
|
865
|
+
),
|
|
866
|
+
child: Column(
|
|
867
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
868
|
+
children: <Widget>[
|
|
869
|
+
Text(
|
|
870
|
+
cell.label,
|
|
871
|
+
style: TextStyle(
|
|
872
|
+
color: _textMuted,
|
|
873
|
+
fontSize: 11.5,
|
|
874
|
+
fontWeight: FontWeight.w700,
|
|
875
|
+
letterSpacing: 2.6,
|
|
876
|
+
),
|
|
877
|
+
),
|
|
878
|
+
const Spacer(),
|
|
879
|
+
Text(
|
|
880
|
+
cell.value,
|
|
881
|
+
maxLines: 3,
|
|
882
|
+
overflow: TextOverflow.ellipsis,
|
|
883
|
+
style: TextStyle(
|
|
884
|
+
color: cell.emphasized
|
|
885
|
+
? cell.color ?? _textPrimary
|
|
886
|
+
: _textPrimary,
|
|
887
|
+
fontSize: cell.emphasized ? 18 : 16,
|
|
888
|
+
fontWeight: FontWeight.w700,
|
|
889
|
+
height: 1.2,
|
|
890
|
+
),
|
|
891
|
+
),
|
|
892
|
+
],
|
|
893
|
+
),
|
|
894
|
+
);
|
|
895
|
+
},
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
class _TimelineStatPill extends StatelessWidget {
|
|
901
|
+
const _TimelineStatPill({
|
|
902
|
+
required this.icon,
|
|
903
|
+
required this.label,
|
|
904
|
+
this.color,
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
final IconData icon;
|
|
908
|
+
final String label;
|
|
909
|
+
final Color? color;
|
|
910
|
+
|
|
911
|
+
@override
|
|
912
|
+
Widget build(BuildContext context) {
|
|
913
|
+
final tone = color ?? _textSecondary;
|
|
914
|
+
return Container(
|
|
915
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
|
916
|
+
decoration: BoxDecoration(
|
|
917
|
+
color: _bgPrimary.withValues(alpha: 0.55),
|
|
918
|
+
borderRadius: BorderRadius.circular(16),
|
|
919
|
+
border: Border.all(color: _borderLight),
|
|
920
|
+
),
|
|
921
|
+
child: Row(
|
|
922
|
+
mainAxisSize: MainAxisSize.min,
|
|
923
|
+
children: <Widget>[
|
|
924
|
+
Icon(icon, size: 16, color: tone),
|
|
925
|
+
const SizedBox(width: 8),
|
|
926
|
+
Text(
|
|
927
|
+
label,
|
|
928
|
+
style: TextStyle(
|
|
929
|
+
color: tone,
|
|
930
|
+
fontSize: 13,
|
|
931
|
+
fontWeight: FontWeight.w700,
|
|
932
|
+
),
|
|
933
|
+
),
|
|
934
|
+
],
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
class _TimelineDayGroup {
|
|
941
|
+
const _TimelineDayGroup({
|
|
942
|
+
required this.date,
|
|
943
|
+
required this.label,
|
|
944
|
+
required this.dateLabel,
|
|
945
|
+
required this.isToday,
|
|
946
|
+
required this.items,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
final DateTime date;
|
|
950
|
+
final String label;
|
|
951
|
+
final String dateLabel;
|
|
952
|
+
final bool isToday;
|
|
953
|
+
final List<TimelineEventItem> items;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
class _TimelineChip {
|
|
957
|
+
const _TimelineChip({required this.label, required this.color});
|
|
958
|
+
|
|
959
|
+
final String label;
|
|
960
|
+
final Color color;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
class _TimelineDetailCell {
|
|
964
|
+
const _TimelineDetailCell({
|
|
965
|
+
required this.label,
|
|
966
|
+
required this.value,
|
|
967
|
+
this.emphasized = false,
|
|
968
|
+
this.color,
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
final String label;
|
|
972
|
+
final String value;
|
|
973
|
+
final bool emphasized;
|
|
974
|
+
final Color? color;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
List<TimelineEventItem> _sortedTimelineEvents(List<TimelineEventItem> items) {
|
|
978
|
+
final sorted = List<TimelineEventItem>.of(items);
|
|
979
|
+
sorted.sort((a, b) {
|
|
980
|
+
final timestampCompare = b.occurredAt.compareTo(a.occurredAt);
|
|
981
|
+
if (timestampCompare != 0) {
|
|
982
|
+
return timestampCompare;
|
|
983
|
+
}
|
|
984
|
+
return b.id.compareTo(a.id);
|
|
985
|
+
});
|
|
986
|
+
return sorted;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
List<_TimelineDayGroup> _groupTimelineEvents(List<TimelineEventItem> items) {
|
|
990
|
+
final groups = <_TimelineDayGroup>[];
|
|
991
|
+
final now = DateTime.now();
|
|
992
|
+
DateTime? activeDate;
|
|
993
|
+
final buffer = <TimelineEventItem>[];
|
|
994
|
+
|
|
995
|
+
void flush() {
|
|
996
|
+
if (activeDate == null || buffer.isEmpty) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
final day = activeDate;
|
|
1000
|
+
groups.add(
|
|
1001
|
+
_TimelineDayGroup(
|
|
1002
|
+
date: day,
|
|
1003
|
+
label: _timelineDayLabel(day, now),
|
|
1004
|
+
dateLabel: _formatTimelineDate(day),
|
|
1005
|
+
isToday: _isSameDay(day, now),
|
|
1006
|
+
items: List<TimelineEventItem>.of(buffer),
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
buffer.clear();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
for (final item in items) {
|
|
1013
|
+
final local = item.occurredAt.toLocal();
|
|
1014
|
+
final day = DateTime(local.year, local.month, local.day);
|
|
1015
|
+
if (activeDate == null || !_isSameDay(activeDate, day)) {
|
|
1016
|
+
flush();
|
|
1017
|
+
activeDate = day;
|
|
1018
|
+
}
|
|
1019
|
+
buffer.add(item);
|
|
1020
|
+
}
|
|
1021
|
+
flush();
|
|
1022
|
+
return groups;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
List<_TimelineChip> _timelineEventChips(TimelineEventItem item) {
|
|
1026
|
+
final chips = <_TimelineChip>[];
|
|
1027
|
+
if (item.sourceKind == 'screen') {
|
|
1028
|
+
final duration = _timelineSpanDuration(item);
|
|
1029
|
+
if (duration != null) {
|
|
1030
|
+
chips.add(_TimelineChip(label: duration, color: _accent));
|
|
1031
|
+
}
|
|
1032
|
+
if (item.appName.trim().isNotEmpty) {
|
|
1033
|
+
chips.add(_TimelineChip(label: item.appName.trim(), color: _info));
|
|
1034
|
+
}
|
|
1035
|
+
if (item.windowTitle.trim().isNotEmpty) {
|
|
1036
|
+
chips.add(
|
|
1037
|
+
_TimelineChip(label: item.windowTitle.trim(), color: _textSecondary),
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
} else if (item.sourceKind == 'tasks') {
|
|
1041
|
+
chips.add(
|
|
1042
|
+
_TimelineChip(
|
|
1043
|
+
label: _titleCase(item.eventKind.replaceAll('_', ' ')),
|
|
1044
|
+
color: _warning,
|
|
1045
|
+
),
|
|
1046
|
+
);
|
|
1047
|
+
if (item.taskName.trim().isNotEmpty &&
|
|
1048
|
+
item.taskName.trim() != item.title.trim()) {
|
|
1049
|
+
chips.add(_TimelineChip(label: item.taskName.trim(), color: _success));
|
|
1050
|
+
}
|
|
1051
|
+
} else if (item.sourceKind == 'runs') {
|
|
1052
|
+
chips.add(
|
|
1053
|
+
_TimelineChip(
|
|
1054
|
+
label: _titleCase(item.eventKind.replaceAll('_', ' ')),
|
|
1055
|
+
color: _success,
|
|
1056
|
+
),
|
|
1057
|
+
);
|
|
1058
|
+
if (item.runId.isNotEmpty) {
|
|
1059
|
+
chips.add(_TimelineChip(label: 'Run linked', color: _accentAlt));
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (item.deviceLabel.trim().isNotEmpty && item.sourceKind != 'screen') {
|
|
1063
|
+
chips.add(_TimelineChip(label: item.deviceLabel.trim(), color: _info));
|
|
1064
|
+
}
|
|
1065
|
+
return chips.take(4).toList(growable: false);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
List<_TimelineDetailCell> _timelineDetailCells(TimelineEventItem item) {
|
|
1069
|
+
final cells = <_TimelineDetailCell>[
|
|
1070
|
+
_TimelineDetailCell(
|
|
1071
|
+
label: 'SOURCE',
|
|
1072
|
+
value: item.sourceLabel,
|
|
1073
|
+
emphasized: true,
|
|
1074
|
+
color: item.sourceColor,
|
|
1075
|
+
),
|
|
1076
|
+
_TimelineDetailCell(
|
|
1077
|
+
label: 'KIND',
|
|
1078
|
+
value: _titleCase(item.eventKind.replaceAll('_', ' ')),
|
|
1079
|
+
),
|
|
1080
|
+
_TimelineDetailCell(
|
|
1081
|
+
label: 'TIME',
|
|
1082
|
+
value:
|
|
1083
|
+
'${_formatTimelineTime(item.occurredAt)}\n${_formatTimelineDate(item.occurredAt.toLocal())}',
|
|
1084
|
+
),
|
|
1085
|
+
];
|
|
1086
|
+
|
|
1087
|
+
if (item.sourceKind == 'screen') {
|
|
1088
|
+
cells.add(
|
|
1089
|
+
_TimelineDetailCell(
|
|
1090
|
+
label: 'DURATION',
|
|
1091
|
+
value: _timelineSpanDuration(item) ?? item.screenSpanLabel,
|
|
1092
|
+
),
|
|
1093
|
+
);
|
|
1094
|
+
cells.add(
|
|
1095
|
+
_TimelineDetailCell(
|
|
1096
|
+
label: 'APP',
|
|
1097
|
+
value: item.appName.ifEmpty('Unknown app'),
|
|
1098
|
+
),
|
|
1099
|
+
);
|
|
1100
|
+
cells.add(
|
|
1101
|
+
_TimelineDetailCell(
|
|
1102
|
+
label: 'DEVICE',
|
|
1103
|
+
value: item.deviceLabel.ifEmpty('Desktop'),
|
|
1104
|
+
),
|
|
1105
|
+
);
|
|
1106
|
+
} else if (item.sourceKind == 'tasks') {
|
|
1107
|
+
cells.add(
|
|
1108
|
+
_TimelineDetailCell(
|
|
1109
|
+
label: 'TASK',
|
|
1110
|
+
value: item.taskName.ifEmpty(item.title),
|
|
1111
|
+
),
|
|
1112
|
+
);
|
|
1113
|
+
cells.add(
|
|
1114
|
+
_TimelineDetailCell(
|
|
1115
|
+
label: 'RUN LINK',
|
|
1116
|
+
value: item.runId.isNotEmpty ? 'Available' : 'None',
|
|
1117
|
+
),
|
|
1118
|
+
);
|
|
1119
|
+
cells.add(
|
|
1120
|
+
_TimelineDetailCell(
|
|
1121
|
+
label: 'SUMMARY',
|
|
1122
|
+
value: item.summary.trim().ifEmpty('No summary'),
|
|
1123
|
+
),
|
|
1124
|
+
);
|
|
1125
|
+
} else if (item.sourceKind == 'runs') {
|
|
1126
|
+
cells.add(
|
|
1127
|
+
_TimelineDetailCell(
|
|
1128
|
+
label: 'RUN',
|
|
1129
|
+
value: item.runId.isNotEmpty ? item.runId : 'Unavailable',
|
|
1130
|
+
),
|
|
1131
|
+
);
|
|
1132
|
+
cells.add(
|
|
1133
|
+
_TimelineDetailCell(
|
|
1134
|
+
label: 'SUMMARY',
|
|
1135
|
+
value: item.summary.trim().ifEmpty('No summary'),
|
|
1136
|
+
),
|
|
1137
|
+
);
|
|
1138
|
+
cells.add(
|
|
1139
|
+
_TimelineDetailCell(
|
|
1140
|
+
label: 'TITLE',
|
|
1141
|
+
value: item.title.ifEmpty('Untitled run event'),
|
|
1142
|
+
),
|
|
1143
|
+
);
|
|
1144
|
+
} else {
|
|
1145
|
+
cells.add(
|
|
1146
|
+
_TimelineDetailCell(
|
|
1147
|
+
label: 'SUMMARY',
|
|
1148
|
+
value: item.summary.trim().ifEmpty('No summary'),
|
|
1149
|
+
),
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (cells.length.isOdd) {
|
|
1154
|
+
cells.add(const _TimelineDetailCell(label: 'STATUS', value: 'Captured'));
|
|
1155
|
+
}
|
|
1156
|
+
return cells.take(6).toList(growable: false);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
String _timelineCardDescription(TimelineEventItem item) {
|
|
1160
|
+
switch (item.sourceKind) {
|
|
1161
|
+
case 'screen':
|
|
1162
|
+
final preview = item.previewText.trim();
|
|
1163
|
+
if (preview.isNotEmpty) {
|
|
1164
|
+
return preview;
|
|
1165
|
+
}
|
|
1166
|
+
return '${item.deviceLabel.ifEmpty('Desktop')} · ${item.appName.ifEmpty('Unknown app')}';
|
|
1167
|
+
case 'tasks':
|
|
1168
|
+
case 'runs':
|
|
1169
|
+
return item.summary.trim().ifEmpty(
|
|
1170
|
+
_titleCase(item.eventKind.replaceAll('_', ' ')),
|
|
1171
|
+
);
|
|
1172
|
+
default:
|
|
1173
|
+
return item.summary.trim().ifEmpty(item.sourceLabel);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
String _timelineDetailDescription(TimelineEventItem item) {
|
|
1178
|
+
final body = item.sourceKind == 'screen'
|
|
1179
|
+
? item.previewText.trim()
|
|
1180
|
+
: item.summary.trim();
|
|
1181
|
+
if (body.isNotEmpty) {
|
|
1182
|
+
return body;
|
|
1183
|
+
}
|
|
1184
|
+
if (item.sourceKind == 'screen') {
|
|
1185
|
+
return '${item.appName.ifEmpty('Unknown app')} on ${item.deviceLabel.ifEmpty('Desktop')}';
|
|
1186
|
+
}
|
|
1187
|
+
return _titleCase(item.eventKind.replaceAll('_', ' '));
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
String? _timelineSpanDuration(TimelineEventItem item) {
|
|
1191
|
+
final start = item.startedAt;
|
|
1192
|
+
final end = item.endedAt;
|
|
1193
|
+
if (start == null || end == null) {
|
|
1194
|
+
return null;
|
|
1195
|
+
}
|
|
1196
|
+
final span = end.difference(start);
|
|
1197
|
+
if (span.inSeconds < 60) {
|
|
1198
|
+
return '${span.inSeconds}s';
|
|
1199
|
+
}
|
|
1200
|
+
if (span.inMinutes < 60) {
|
|
1201
|
+
final seconds = span.inSeconds % 60;
|
|
1202
|
+
if (seconds == 0) {
|
|
1203
|
+
return '${span.inMinutes}m';
|
|
1204
|
+
}
|
|
1205
|
+
return '${span.inMinutes}m ${seconds}s';
|
|
1206
|
+
}
|
|
1207
|
+
final minutes = span.inMinutes % 60;
|
|
1208
|
+
if (minutes == 0) {
|
|
1209
|
+
return '${span.inHours}h';
|
|
1210
|
+
}
|
|
1211
|
+
return '${span.inHours}h ${minutes}m';
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
Color _sourceColorForKind(String kind) {
|
|
1215
|
+
switch (kind) {
|
|
1216
|
+
case 'screen':
|
|
1217
|
+
return _accent;
|
|
1218
|
+
case 'tasks':
|
|
1219
|
+
return _warning;
|
|
1220
|
+
case 'runs':
|
|
1221
|
+
return _success;
|
|
1222
|
+
default:
|
|
1223
|
+
return _textSecondary;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
String _timelineDayLabel(DateTime day, DateTime now) {
|
|
1228
|
+
if (_isSameDay(day, now)) {
|
|
1229
|
+
return 'Today';
|
|
1230
|
+
}
|
|
1231
|
+
final yesterday = now.subtract(const Duration(days: 1));
|
|
1232
|
+
if (_isSameDay(day, yesterday)) {
|
|
1233
|
+
return 'Yesterday';
|
|
1234
|
+
}
|
|
1235
|
+
return _weekdayShort(day.weekday);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
bool _isSameDay(DateTime a, DateTime b) {
|
|
1239
|
+
return a.year == b.year && a.month == b.month && a.day == b.day;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
String _formatTimelineRange(TimelineEventItem first, TimelineEventItem last) {
|
|
1243
|
+
final start = last.occurredAt.toLocal();
|
|
1244
|
+
final end = first.occurredAt.toLocal();
|
|
1245
|
+
final startDate = _formatTimelineDate(start);
|
|
1246
|
+
final endDate = _formatTimelineDate(end);
|
|
1247
|
+
final startTime = _formatTimelineTime(start);
|
|
1248
|
+
final endTime = _formatTimelineTime(end);
|
|
1249
|
+
if (_isSameDay(start, end)) {
|
|
1250
|
+
return '$startDate · $startTime - $endTime';
|
|
1251
|
+
}
|
|
1252
|
+
return '$startDate -> $endDate';
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
String _formatTimelineDate(DateTime value) {
|
|
1256
|
+
return '${_monthShort(value.month)} ${value.day}, ${value.year}';
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
String _formatTimelineTime(DateTime value) {
|
|
1260
|
+
final local = value.toLocal();
|
|
1261
|
+
final hour = local.hour.toString().padLeft(2, '0');
|
|
1262
|
+
final minute = local.minute.toString().padLeft(2, '0');
|
|
1263
|
+
return '$hour:$minute';
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
String _monthShort(int month) {
|
|
1267
|
+
const labels = <String>[
|
|
1268
|
+
'Jan',
|
|
1269
|
+
'Feb',
|
|
1270
|
+
'Mar',
|
|
1271
|
+
'Apr',
|
|
1272
|
+
'May',
|
|
1273
|
+
'Jun',
|
|
1274
|
+
'Jul',
|
|
1275
|
+
'Aug',
|
|
1276
|
+
'Sep',
|
|
1277
|
+
'Oct',
|
|
1278
|
+
'Nov',
|
|
1279
|
+
'Dec',
|
|
1280
|
+
];
|
|
1281
|
+
return labels[(month - 1).clamp(0, labels.length - 1)];
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
String _weekdayShort(int weekday) {
|
|
1285
|
+
const labels = <String>['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
1286
|
+
return labels[(weekday - 1).clamp(0, labels.length - 1)];
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
IconData _timelineLaneIcon(String sourceKind) {
|
|
1290
|
+
switch (sourceKind) {
|
|
1291
|
+
case 'screen':
|
|
1292
|
+
return Icons.desktop_windows_outlined;
|
|
1293
|
+
case 'tasks':
|
|
1294
|
+
return Icons.task_alt_outlined;
|
|
1295
|
+
case 'runs':
|
|
1296
|
+
return Icons.monitor_heart_outlined;
|
|
1297
|
+
default:
|
|
1298
|
+
return Icons.timeline_outlined;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
List<Widget> _timelineDetailBody(
|
|
1303
|
+
TimelineEventItem item,
|
|
1304
|
+
VoidCallback? onOpenRun,
|
|
1305
|
+
) {
|
|
1306
|
+
final content = <Widget>[];
|
|
1307
|
+
|
|
1308
|
+
if (item.sourceKind == 'screen') {
|
|
1309
|
+
content.addAll(<Widget>[
|
|
1310
|
+
_TimelineMetaLine(
|
|
1311
|
+
icon: Icons.computer_outlined,
|
|
1312
|
+
text:
|
|
1313
|
+
'${item.deviceLabel.ifEmpty('Desktop')} · ${item.appName.ifEmpty('Unknown app')}',
|
|
1314
|
+
),
|
|
1315
|
+
if (item.windowTitle.isNotEmpty)
|
|
1316
|
+
_TimelineMetaLine(
|
|
1317
|
+
icon: Icons.web_asset_outlined,
|
|
1318
|
+
text: item.windowTitle,
|
|
1319
|
+
),
|
|
1320
|
+
_TimelineMetaLine(
|
|
1321
|
+
icon: Icons.schedule_outlined,
|
|
1322
|
+
text: item.screenSpanLabel,
|
|
1323
|
+
),
|
|
1324
|
+
]);
|
|
1325
|
+
} else {
|
|
1326
|
+
content.add(
|
|
1327
|
+
_TimelineMetaLine(
|
|
1328
|
+
icon: item.sourceKind == 'tasks'
|
|
1329
|
+
? Icons.task_alt_outlined
|
|
1330
|
+
: Icons.monitor_heart_outlined,
|
|
1331
|
+
text: _titleCase(item.eventKind.replaceAll('_', ' ')),
|
|
1332
|
+
),
|
|
1333
|
+
);
|
|
1334
|
+
if (item.runId.isNotEmpty && onOpenRun != null) {
|
|
1335
|
+
content.add(
|
|
1336
|
+
Padding(
|
|
1337
|
+
padding: const EdgeInsets.only(top: 8),
|
|
1338
|
+
child: Align(
|
|
1339
|
+
alignment: Alignment.centerLeft,
|
|
1340
|
+
child: TextButton.icon(
|
|
1341
|
+
onPressed: onOpenRun,
|
|
1342
|
+
icon: const Icon(Icons.open_in_new_rounded, size: 16),
|
|
1343
|
+
label: const Text('Open run'),
|
|
1344
|
+
),
|
|
1345
|
+
),
|
|
1346
|
+
),
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return content;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
class _TimelineMetaLine extends StatelessWidget {
|
|
1354
|
+
const _TimelineMetaLine({required this.icon, required this.text});
|
|
1355
|
+
|
|
1356
|
+
final IconData icon;
|
|
1357
|
+
final String text;
|
|
1358
|
+
|
|
1359
|
+
@override
|
|
1360
|
+
Widget build(BuildContext context) {
|
|
1361
|
+
return Padding(
|
|
1362
|
+
padding: const EdgeInsets.only(bottom: 10),
|
|
1363
|
+
child: Row(
|
|
1364
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1365
|
+
children: <Widget>[
|
|
1366
|
+
Icon(icon, size: 16, color: _textMuted),
|
|
1367
|
+
const SizedBox(width: 8),
|
|
1368
|
+
Expanded(
|
|
1369
|
+
child: Text(
|
|
1370
|
+
text,
|
|
1371
|
+
style: TextStyle(color: _textSecondary, height: 1.35),
|
|
1372
|
+
),
|
|
1373
|
+
),
|
|
1374
|
+
],
|
|
1375
|
+
),
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
}
|