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.
Files changed (97) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_models.dart +142 -0
  17. package/flutter_app/lib/main_navigation.dart +19 -1
  18. package/flutter_app/lib/main_operations.dart +288 -9
  19. package/flutter_app/lib/main_settings.dart +510 -300
  20. package/flutter_app/lib/main_shared.dart +2 -0
  21. package/flutter_app/lib/main_timeline.dart +1378 -0
  22. package/flutter_app/lib/src/backend_client.dart +149 -19
  23. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  24. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  25. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  26. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  29. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  30. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  31. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  32. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  33. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  34. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  35. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  36. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  37. package/landing/index.html +3 -3
  38. package/lib/manager.js +184 -66
  39. package/package.json +4 -1
  40. package/server/admin/access.js +12 -7
  41. package/server/admin/admin.js +436 -10
  42. package/server/admin/billing.js +158 -35
  43. package/server/admin/index.html +72 -2
  44. package/server/admin/users.js +15 -15
  45. package/server/db/database.js +125 -20
  46. package/server/http/routes.js +1 -0
  47. package/server/http/static.js +4 -2
  48. package/server/public/.last_build_id +1 -1
  49. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  50. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  51. package/server/public/canvaskit/wimp.wasm +0 -0
  52. package/server/public/flutter_bootstrap.js +2 -2
  53. package/server/public/main.dart.js +91077 -87037
  54. package/server/routes/account.js +53 -0
  55. package/server/routes/admin.js +345 -64
  56. package/server/routes/agents.js +203 -21
  57. package/server/routes/billing.js +5 -0
  58. package/server/routes/browser.js +8 -1
  59. package/server/routes/recordings.js +96 -6
  60. package/server/routes/screenHistory.js +140 -2
  61. package/server/routes/timeline.js +43 -0
  62. package/server/services/account/erasure.js +263 -0
  63. package/server/services/ai/hooks.js +4 -1
  64. package/server/services/ai/loop/agent_engine_core.js +8 -1
  65. package/server/services/ai/loop/blank_recovery.js +36 -0
  66. package/server/services/ai/loop/conversation_loop.js +166 -33
  67. package/server/services/ai/messagingFallback.js +22 -0
  68. package/server/services/ai/rate_limits.js +28 -5
  69. package/server/services/ai/systemPrompt.js +6 -5
  70. package/server/services/ai/taskAnalysis.js +2 -0
  71. package/server/services/ai/toolEvidence.js +15 -0
  72. package/server/services/ai/toolResult.js +40 -0
  73. package/server/services/ai/tools.js +163 -4
  74. package/server/services/android/controller.js +6 -2
  75. package/server/services/billing/plans.js +2 -1
  76. package/server/services/browser/anti_detection.js +192 -0
  77. package/server/services/browser/controller.js +180 -54
  78. package/server/services/desktop/auth.js +3 -0
  79. package/server/services/desktop/registry.js +50 -2
  80. package/server/services/integrations/google/calendar.js +22 -14
  81. package/server/services/manager.js +12 -42
  82. package/server/services/memory/ingestion_chunking.js +268 -0
  83. package/server/services/messaging/telnyx.js +9 -8
  84. package/server/services/recordings/manager.js +60 -27
  85. package/server/services/runtime/docker-vm-manager.js +157 -266
  86. package/server/services/runtime/guest_bootstrap.js +17 -5
  87. package/server/services/runtime/guest_image.js +188 -0
  88. package/server/services/runtime/manager.js +0 -1
  89. package/server/services/runtime/validation.js +3 -8
  90. package/server/services/social_video/service.js +60 -10
  91. package/server/services/tasks/runtime.js +234 -9
  92. package/server/services/tasks/task_repository.js +13 -0
  93. package/server/services/timeline/service.js +558 -0
  94. package/server/services/wearable/gateway.js +1 -1
  95. package/server/services/websocket.js +21 -3
  96. package/server/services/desktop/screenRecorder.js +0 -292
  97. 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
+ }