neoagent 3.0.1-beta.31 → 3.0.1-beta.32

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.
@@ -1,16 +1,5 @@
1
1
  part of 'main.dart';
2
2
 
3
- const double _timelineAxisHeaderHeight = 56;
4
- const double _timelineLaneLabelWidth = 132;
5
- const double _timelinePlotRightPadding = 84;
6
- const double _timelineLanePadding = 18;
7
- const double _timelineLaneGap = 18;
8
- const double _timelineNodeWidth = 208;
9
- const double _timelineNodeHeight = 88;
10
- const double _timelineNodeGap = 18;
11
- const double _timelineCanvasBottomPadding = 28;
12
- const double _timelinePlotInset = _timelineNodeWidth / 2;
13
-
14
3
  class TimelinePanel extends StatefulWidget {
15
4
  const TimelinePanel({super.key, required this.controller});
16
5
 
@@ -21,21 +10,7 @@ class TimelinePanel extends StatefulWidget {
21
10
  }
22
11
 
23
12
  class _TimelinePanelState extends State<TimelinePanel> {
24
- final TransformationController _viewportController =
25
- TransformationController();
26
-
27
13
  int? _selectedEventId;
28
- int? _hoveredEventId;
29
-
30
- @override
31
- void dispose() {
32
- _viewportController.dispose();
33
- super.dispose();
34
- }
35
-
36
- void _resetViewport() {
37
- _viewportController.value = Matrix4.identity();
38
- }
39
14
 
40
15
  void _selectEvent(TimelineEventItem item) {
41
16
  if (_selectedEventId == item.id) {
@@ -46,15 +21,6 @@ class _TimelinePanelState extends State<TimelinePanel> {
46
21
  });
47
22
  }
48
23
 
49
- void _setHoveredEvent(int? eventId) {
50
- if (_hoveredEventId == eventId) {
51
- return;
52
- }
53
- setState(() {
54
- _hoveredEventId = eventId;
55
- });
56
- }
57
-
58
24
  void _moveSelection(List<TimelineEventItem> items, int offset) {
59
25
  if (items.isEmpty) {
60
26
  return;
@@ -81,13 +47,14 @@ class _TimelinePanelState extends State<TimelinePanel> {
81
47
  return item;
82
48
  }
83
49
  }
84
- _selectedEventId = items.last.id;
85
- return items.last;
50
+ _selectedEventId = items.first.id;
51
+ return items.first;
86
52
  }
87
53
 
88
54
  @override
89
55
  Widget build(BuildContext context) {
90
56
  final items = _sortedTimelineEvents(widget.controller.timelineItems);
57
+ final groups = _groupTimelineEvents(items);
91
58
  final selectedEvent = _resolveSelectedEvent(items);
92
59
  final selectedIndex = selectedEvent == null
93
60
  ? -1
@@ -98,71 +65,12 @@ class _TimelinePanelState extends State<TimelinePanel> {
98
65
  child: Column(
99
66
  crossAxisAlignment: CrossAxisAlignment.start,
100
67
  children: <Widget>[
101
- const _PageTitle(
102
- title: 'Timeline',
103
- subtitle:
104
- 'Interactive chronology across passive screen sessions, tasks, and runs.',
105
- ),
106
- const SizedBox(height: 12),
107
- Wrap(
108
- spacing: 10,
109
- runSpacing: 10,
110
- children: <Widget>[
111
- for (final filter in const <({String id, String label})>[
112
- (id: 'screen', label: 'Screen'),
113
- (id: 'tasks', label: 'Tasks'),
114
- (id: 'runs', label: 'Runs'),
115
- ])
116
- FilterChip(
117
- selected: widget.controller.selectedTimelineSources.contains(
118
- filter.id,
119
- ),
120
- label: Text(filter.label),
121
- onSelected: (_) =>
122
- widget.controller.toggleTimelineSource(filter.id),
123
- ),
124
- OutlinedButton.icon(
125
- onPressed: widget.controller.isRefreshingTimeline
126
- ? null
127
- : widget.controller.refreshTimeline,
128
- icon: widget.controller.isRefreshingTimeline
129
- ? const SizedBox.square(
130
- dimension: 14,
131
- child: CircularProgressIndicator(strokeWidth: 2),
132
- )
133
- : const Icon(Icons.sync_outlined),
134
- label: const Text('Refresh'),
135
- ),
136
- OutlinedButton.icon(
137
- onPressed: items.isEmpty ? null : _resetViewport,
138
- icon: const Icon(Icons.center_focus_strong_rounded),
139
- label: const Text('Reset view'),
140
- ),
141
- ],
68
+ _TimelineHeroHeader(
69
+ items: items,
70
+ selectedEvent: selectedEvent,
71
+ controller: widget.controller,
142
72
  ),
143
- if (items.isNotEmpty) ...<Widget>[
144
- const SizedBox(height: 12),
145
- Wrap(
146
- spacing: 10,
147
- runSpacing: 10,
148
- children: <Widget>[
149
- _MetaPill(
150
- label: '${items.length} events',
151
- icon: Icons.bubble_chart_outlined,
152
- ),
153
- _MetaPill(
154
- label: _formatTimelineRange(items.first, items.last),
155
- icon: Icons.schedule_outlined,
156
- ),
157
- _MetaPill(
158
- label: 'Drag to pan, scroll or pinch to zoom',
159
- icon: Icons.pan_tool_alt_outlined,
160
- color: _info,
161
- ),
162
- ],
163
- ),
164
- ],
165
- const SizedBox(height: 16),
73
+ const SizedBox(height: 18),
166
74
  Expanded(
167
75
  child: items.isEmpty
168
76
  ? Card(
@@ -178,16 +86,13 @@ class _TimelinePanelState extends State<TimelinePanel> {
178
86
  )
179
87
  : LayoutBuilder(
180
88
  builder: (context, constraints) {
181
- final isWide = constraints.maxWidth >= 1120;
182
- final timelinePane = _InteractiveTimelineCanvas(
183
- items: items,
89
+ final isWide = constraints.maxWidth >= 1180;
90
+ final feedPane = _TimelineFeedPane(
91
+ groups: groups,
184
92
  selectedEventId: selectedEvent?.id,
185
- hoveredEventId: _hoveredEventId,
186
- viewportController: _viewportController,
187
93
  onSelectEvent: _selectEvent,
188
- onHoverEvent: _setHoveredEvent,
189
94
  );
190
- final detailPane = _TimelineSelectionPanel(
95
+ final detailPane = _TimelineDetailPane(
191
96
  items: items,
192
97
  selectedEvent: selectedEvent,
193
98
  selectedIndex: selectedIndex,
@@ -214,18 +119,19 @@ class _TimelinePanelState extends State<TimelinePanel> {
214
119
  return Row(
215
120
  crossAxisAlignment: CrossAxisAlignment.stretch,
216
121
  children: <Widget>[
217
- Expanded(flex: 7, child: timelinePane),
122
+ Expanded(flex: 10, child: feedPane),
218
123
  const SizedBox(width: 16),
219
- SizedBox(width: 360, child: detailPane),
124
+ SizedBox(width: 420, child: detailPane),
220
125
  ],
221
126
  );
222
127
  }
128
+
223
129
  return Column(
224
130
  crossAxisAlignment: CrossAxisAlignment.stretch,
225
131
  children: <Widget>[
226
- Expanded(flex: 3, child: timelinePane),
132
+ Expanded(flex: 11, child: feedPane),
227
133
  const SizedBox(height: 16),
228
- Expanded(flex: 2, child: detailPane),
134
+ Expanded(flex: 8, child: detailPane),
229
135
  ],
230
136
  );
231
137
  },
@@ -237,868 +143,1113 @@ class _TimelinePanelState extends State<TimelinePanel> {
237
143
  }
238
144
  }
239
145
 
240
- class _InteractiveTimelineCanvas extends StatelessWidget {
241
- const _InteractiveTimelineCanvas({
146
+ class _TimelineHeroHeader extends StatelessWidget {
147
+ const _TimelineHeroHeader({
242
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,
243
321
  required this.selectedEventId,
244
- required this.hoveredEventId,
245
- required this.viewportController,
246
322
  required this.onSelectEvent,
247
- required this.onHoverEvent,
248
323
  });
249
324
 
250
- final List<TimelineEventItem> items;
325
+ final List<_TimelineDayGroup> groups;
251
326
  final int? selectedEventId;
252
- final int? hoveredEventId;
253
- final TransformationController viewportController;
254
327
  final ValueChanged<TimelineEventItem> onSelectEvent;
255
- final ValueChanged<int?> onHoverEvent;
256
328
 
257
329
  @override
258
330
  Widget build(BuildContext context) {
259
331
  return Card(
260
332
  clipBehavior: Clip.antiAlias,
261
- child: Padding(
262
- padding: const EdgeInsets.all(18),
263
- child: Column(
264
- crossAxisAlignment: CrossAxisAlignment.start,
265
- children: <Widget>[
266
- Row(
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(
267
342
  children: <Widget>[
268
343
  Expanded(
269
- child: Column(
270
- crossAxisAlignment: CrossAxisAlignment.start,
271
- children: <Widget>[
272
- const Text(
273
- 'Timeline map',
274
- style: TextStyle(
275
- fontSize: 16,
276
- fontWeight: FontWeight.w800,
277
- ),
278
- ),
279
- const SizedBox(height: 4),
280
- Text(
281
- 'Events are placed by real timestamp and grouped into source lanes.',
282
- style: TextStyle(color: _textSecondary, height: 1.35),
283
- ),
284
- ],
344
+ child: Text(
345
+ 'Timeline feed',
346
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
285
347
  ),
286
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>[
287
400
  Container(
288
401
  padding: const EdgeInsets.symmetric(
289
- horizontal: 12,
402
+ horizontal: 14,
290
403
  vertical: 8,
291
404
  ),
292
405
  decoration: BoxDecoration(
293
- color: _bgTertiary.withValues(alpha: 0.7),
406
+ color: group.isToday ? _accent : _bgTertiary,
294
407
  borderRadius: BorderRadius.circular(999),
295
- border: Border.all(color: _borderLight),
296
408
  ),
297
- child: Row(
298
- mainAxisSize: MainAxisSize.min,
299
- children: <Widget>[
300
- Icon(
301
- Icons.touch_app_outlined,
302
- size: 15,
303
- color: _textMuted,
304
- ),
305
- const SizedBox(width: 8),
306
- Text(
307
- 'Tap cards for details',
308
- style: TextStyle(color: _textSecondary, fontSize: 12.5),
309
- ),
310
- ],
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
+ ),
311
427
  ),
312
428
  ),
429
+ Text(
430
+ '${group.items.length} events',
431
+ style: TextStyle(color: _textMuted, fontSize: 12.5),
432
+ ),
313
433
  ],
314
434
  ),
315
- const SizedBox(height: 16),
316
- Expanded(
317
- child: LayoutBuilder(
318
- builder: (context, constraints) {
319
- final scene = _TimelineSceneLayout.build(
320
- items: items,
321
- viewportWidth: constraints.maxWidth,
322
- );
323
- return ClipRRect(
324
- borderRadius: BorderRadius.circular(18),
325
- child: DecoratedBox(
326
- decoration: BoxDecoration(
327
- gradient: LinearGradient(
328
- colors: <Color>[
329
- _bgSecondary.withValues(alpha: 0.92),
330
- _bgCard.withValues(alpha: 0.78),
331
- ],
332
- begin: Alignment.topLeft,
333
- end: Alignment.bottomRight,
334
- ),
335
- border: Border.all(color: _borderLight),
336
- ),
337
- child: InteractiveViewer(
338
- transformationController: viewportController,
339
- boundaryMargin: const EdgeInsets.all(160),
340
- minScale: 0.65,
341
- maxScale: 2.8,
342
- panEnabled: true,
343
- scaleEnabled: true,
344
- constrained: false,
345
- trackpadScrollCausesScale: true,
346
- child: SizedBox(
347
- width: scene.canvasWidth,
348
- height: scene.canvasHeight,
349
- child: Stack(
350
- children: <Widget>[
351
- Positioned.fill(
352
- child: CustomPaint(
353
- painter: _TimelineScenePainter(
354
- scene: scene,
355
- selectedEventId: selectedEventId,
356
- ),
357
- ),
358
- ),
359
- for (final lane in scene.lanes)
360
- Positioned(
361
- left: 16,
362
- top: lane.top + 14,
363
- child: _TimelineLaneBadge(
364
- label: lane.label,
365
- color: lane.color,
366
- icon: _timelineLaneIcon(lane.sourceKind),
367
- ),
368
- ),
369
- for (final entry in scene.entries)
370
- Positioned(
371
- left: entry.left,
372
- top: entry.top,
373
- width: _timelineNodeWidth,
374
- height: _timelineNodeHeight,
375
- child: _TimelineEventNode(
376
- item: entry.item,
377
- isSelected:
378
- entry.item.id == selectedEventId,
379
- isHovered: entry.item.id == hoveredEventId,
380
- onTap: () => onSelectEvent(entry.item),
381
- onHoverChanged: (hovering) => onHoverEvent(
382
- hovering ? entry.item.id : null,
383
- ),
384
- ),
385
- ),
386
- ],
387
- ),
388
- ),
389
- ),
390
- ),
391
- );
392
- },
393
- ),
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]),
394
443
  ),
395
- ],
396
- ),
444
+ ],
397
445
  ),
398
446
  );
399
447
  }
400
448
  }
401
449
 
402
- class _TimelineSelectionPanel extends StatelessWidget {
403
- const _TimelineSelectionPanel({
404
- required this.items,
405
- required this.selectedEvent,
406
- required this.selectedIndex,
407
- this.onSelectPrevious,
408
- this.onSelectNext,
409
- this.onOpenRun,
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,
410
457
  });
411
458
 
412
- final List<TimelineEventItem> items;
413
- final TimelineEventItem? selectedEvent;
414
- final int selectedIndex;
415
- final VoidCallback? onSelectPrevious;
416
- final VoidCallback? onSelectNext;
417
- final VoidCallback? onOpenRun;
459
+ final TimelineEventItem item;
460
+ final bool isSelected;
461
+ final bool isFirst;
462
+ final bool isLast;
463
+ final VoidCallback onTap;
418
464
 
419
465
  @override
420
466
  Widget build(BuildContext context) {
421
- final item = selectedEvent;
422
- return Card(
423
- child: Padding(
424
- padding: const EdgeInsets.all(18),
425
- child: Column(
426
- crossAxisAlignment: CrossAxisAlignment.start,
427
- children: <Widget>[
428
- Row(
429
- children: <Widget>[
430
- const Expanded(
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),
431
485
  child: Text(
432
- 'Event detail',
433
- style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
486
+ _formatTimelineTime(item.occurredAt),
487
+ style: TextStyle(
488
+ color: isSelected ? _textPrimary : _textMuted,
489
+ fontSize: 14,
490
+ fontWeight: FontWeight.w600,
491
+ ),
434
492
  ),
435
493
  ),
436
- IconButton(
437
- tooltip: 'Previous event',
438
- onPressed: onSelectPrevious,
439
- icon: const Icon(Icons.chevron_left_rounded),
440
- ),
441
- Text(
442
- item == null ? '0/0' : '${selectedIndex + 1}/${items.length}',
443
- style: TextStyle(color: _textMuted, fontSize: 12),
444
- ),
445
- IconButton(
446
- tooltip: 'Next event',
447
- onPressed: onSelectNext,
448
- icon: const Icon(Icons.chevron_right_rounded),
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
+ ],
449
528
  ),
450
- ],
451
- ),
452
- const SizedBox(height: 12),
453
- if (item == null)
529
+ ),
530
+ const SizedBox(width: 18),
454
531
  Expanded(
455
- child: Center(
456
- child: Text(
457
- 'Select an event on the timeline.',
458
- style: TextStyle(color: _textSecondary),
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>[],
459
554
  ),
460
- ),
461
- )
462
- else
463
- Expanded(
464
- child: SingleChildScrollView(
465
555
  child: Column(
466
556
  crossAxisAlignment: CrossAxisAlignment.start,
467
557
  children: <Widget>[
468
558
  Row(
469
559
  children: <Widget>[
470
- Container(
471
- padding: const EdgeInsets.symmetric(
472
- horizontal: 10,
473
- vertical: 6,
560
+ _TimelineTypeChip(item: item),
561
+ const Spacer(),
562
+ if (isSelected)
563
+ Icon(
564
+ Icons.chevron_right_rounded,
565
+ color: sourceColor,
474
566
  ),
475
- decoration: BoxDecoration(
476
- color: item.sourceColor.withValues(alpha: 0.14),
477
- borderRadius: BorderRadius.circular(999),
478
- ),
479
- child: Text(
480
- item.sourceLabel,
481
- style: TextStyle(
482
- color: item.sourceColor,
483
- fontWeight: FontWeight.w700,
484
- ),
485
- ),
486
- ),
487
- const Spacer(),
488
- Text(
489
- item.occurredAtLabel,
490
- style: TextStyle(color: _textMuted, fontSize: 12),
491
- ),
492
567
  ],
493
568
  ),
494
569
  const SizedBox(height: 14),
495
570
  Text(
496
571
  item.title.ifEmpty(item.taskName),
497
572
  style: const TextStyle(
498
- fontSize: 20,
573
+ fontSize: 17,
499
574
  fontWeight: FontWeight.w800,
500
- height: 1.15,
575
+ height: 1.2,
501
576
  ),
502
577
  ),
503
- const SizedBox(height: 12),
504
- Wrap(
505
- spacing: 8,
506
- runSpacing: 8,
507
- children: <Widget>[
508
- _MetaPill(
509
- label: _titleCase(
510
- item.eventKind.replaceAll('_', ' '),
511
- ),
512
- icon: Icons.label_outline_rounded,
513
- color: item.sourceColor,
514
- ),
515
- _MetaPill(
516
- label: _timelineNodeSubtitle(item),
517
- icon: _timelineLaneIcon(item.sourceKind),
518
- ),
519
- ],
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
+ ),
520
588
  ),
521
- const SizedBox(height: 16),
522
- ..._timelineDetailBody(item, onOpenRun),
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
+ ],
523
599
  ],
524
600
  ),
525
601
  ),
526
602
  ),
527
- ],
603
+ ],
604
+ ),
528
605
  ),
529
606
  ),
530
607
  );
531
608
  }
532
609
  }
533
610
 
534
- class _TimelineLaneBadge extends StatelessWidget {
535
- const _TimelineLaneBadge({
536
- required this.label,
537
- required this.color,
538
- required this.icon,
539
- });
611
+ class _TimelineTypeChip extends StatelessWidget {
612
+ const _TimelineTypeChip({required this.item});
540
613
 
541
- final String label;
542
- final Color color;
543
- final IconData icon;
614
+ final TimelineEventItem item;
544
615
 
545
616
  @override
546
617
  Widget build(BuildContext context) {
547
618
  return Container(
548
619
  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
549
620
  decoration: BoxDecoration(
550
- color: _bgCard.withValues(alpha: 0.9),
551
- borderRadius: BorderRadius.circular(16),
552
- border: Border.all(color: color.withValues(alpha: 0.22)),
553
- boxShadow: <BoxShadow>[
554
- BoxShadow(
555
- color: Colors.black.withValues(alpha: 0.08),
556
- blurRadius: 16,
557
- offset: const Offset(0, 8),
558
- ),
559
- ],
621
+ color: item.sourceColor.withValues(alpha: 0.12),
622
+ borderRadius: BorderRadius.circular(999),
623
+ border: Border.all(color: item.sourceColor.withValues(alpha: 0.24)),
560
624
  ),
561
625
  child: Row(
562
626
  mainAxisSize: MainAxisSize.min,
563
627
  children: <Widget>[
564
- Icon(icon, size: 15, color: color),
628
+ Icon(
629
+ _timelineLaneIcon(item.sourceKind),
630
+ size: 15,
631
+ color: item.sourceColor,
632
+ ),
565
633
  const SizedBox(width: 8),
566
- Text(label, style: const TextStyle(fontWeight: FontWeight.w700)),
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
+ ),
567
643
  ],
568
644
  ),
569
645
  );
570
646
  }
571
647
  }
572
648
 
573
- class _TimelineEventNode extends StatelessWidget {
574
- const _TimelineEventNode({
575
- required this.item,
576
- required this.isSelected,
577
- required this.isHovered,
578
- required this.onTap,
579
- required this.onHoverChanged,
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,
580
683
  });
581
684
 
582
- final TimelineEventItem item;
583
- final bool isSelected;
584
- final bool isHovered;
585
- final VoidCallback onTap;
586
- final ValueChanged<bool> onHoverChanged;
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;
587
691
 
588
692
  @override
589
693
  Widget build(BuildContext context) {
590
- final accentColor = item.sourceColor;
591
- final active = isSelected || isHovered;
592
- final borderColor = isSelected
593
- ? accentColor.withValues(alpha: 0.8)
594
- : accentColor.withValues(alpha: active ? 0.4 : 0.18);
595
- final glowColor = accentColor.withValues(alpha: isSelected ? 0.24 : 0.12);
596
- final subtitle = _timelineNodeSubtitle(item);
597
-
598
- return MouseRegion(
599
- cursor: SystemMouseCursors.click,
600
- onEnter: (_) => onHoverChanged(true),
601
- onExit: (_) => onHoverChanged(false),
602
- child: Tooltip(
603
- message:
604
- '${item.occurredAtLabel}\n${item.title.ifEmpty(item.taskName)}',
605
- child: AnimatedScale(
606
- scale: active ? 1.02 : 1,
607
- duration: const Duration(milliseconds: 160),
608
- curve: Curves.easeOutCubic,
609
- child: Material(
610
- color: Colors.transparent,
611
- child: InkWell(
612
- borderRadius: BorderRadius.circular(18),
613
- onTap: onTap,
614
- child: AnimatedContainer(
615
- duration: const Duration(milliseconds: 180),
616
- curve: Curves.easeOutCubic,
617
- padding: const EdgeInsets.all(14),
618
- decoration: BoxDecoration(
619
- borderRadius: BorderRadius.circular(18),
620
- gradient: LinearGradient(
621
- colors: <Color>[
622
- _bgCard.withValues(alpha: 0.98),
623
- _bgTertiary.withValues(alpha: 0.9),
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
+ ),
624
729
  ],
625
- begin: Alignment.topLeft,
626
- end: Alignment.bottomRight,
627
730
  ),
628
- border: Border.all(color: borderColor),
629
- boxShadow: <BoxShadow>[
630
- BoxShadow(
631
- color: glowColor,
632
- blurRadius: active ? 24 : 18,
633
- offset: const Offset(0, 10),
634
- ),
635
- ],
636
731
  ),
637
- child: Column(
638
- crossAxisAlignment: CrossAxisAlignment.start,
639
- children: <Widget>[
640
- Row(
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,
641
761
  children: <Widget>[
642
- Container(
643
- width: 11,
644
- height: 11,
645
- decoration: BoxDecoration(
646
- color: accentColor,
647
- shape: BoxShape.circle,
648
- boxShadow: <BoxShadow>[
649
- BoxShadow(
650
- color: accentColor.withValues(alpha: 0.32),
651
- blurRadius: 12,
652
- ),
653
- ],
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,
654
785
  ),
655
786
  ),
656
- const SizedBox(width: 8),
657
- Expanded(
658
- child: Text(
659
- _formatTimelineTime(item.occurredAt),
660
- style: TextStyle(
661
- color: _textMuted,
662
- fontSize: 11.5,
663
- fontWeight: FontWeight.w700,
664
- ),
787
+ const SizedBox(height: 12),
788
+ Text(
789
+ _timelineDetailDescription(item),
790
+ style: TextStyle(
791
+ color: _textSecondary,
792
+ fontSize: 15,
793
+ height: 1.5,
665
794
  ),
666
795
  ),
667
- if (isSelected)
668
- Icon(
669
- Icons.ads_click_rounded,
670
- size: 15,
671
- color: accentColor,
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
+ ),
672
826
  ),
827
+ ],
828
+ const SizedBox(height: 20),
829
+ ..._timelineDetailBody(item, onOpenRun),
673
830
  ],
674
831
  ),
675
- const SizedBox(height: 10),
676
- Expanded(
677
- child: Text(
678
- item.title.ifEmpty(item.taskName),
679
- maxLines: 2,
680
- overflow: TextOverflow.ellipsis,
681
- style: const TextStyle(
682
- fontSize: 13.5,
683
- fontWeight: FontWeight.w700,
684
- height: 1.22,
685
- ),
686
- ),
687
- ),
688
- const SizedBox(height: 8),
689
- Text(
690
- subtitle,
691
- maxLines: 1,
692
- overflow: TextOverflow.ellipsis,
693
- style: TextStyle(color: _textSecondary, fontSize: 11.5),
694
- ),
695
- ],
696
- ),
697
- ),
698
- ),
832
+ ),
699
833
  ),
700
- ),
834
+ ],
701
835
  ),
702
836
  );
703
837
  }
704
838
  }
705
839
 
706
- class _TimelineScenePainter extends CustomPainter {
707
- const _TimelineScenePainter({
708
- required this.scene,
709
- required this.selectedEventId,
710
- });
840
+ class _TimelineDetailGrid extends StatelessWidget {
841
+ const _TimelineDetailGrid({required this.item});
711
842
 
712
- final _TimelineSceneLayout scene;
713
- final int? selectedEventId;
843
+ final TimelineEventItem item;
714
844
 
715
845
  @override
716
- void paint(Canvas canvas, Size size) {
717
- final backgroundPaint = Paint()
718
- ..shader = LinearGradient(
719
- colors: <Color>[
720
- Colors.white.withValues(alpha: 0.06),
721
- Colors.white.withValues(alpha: 0.02),
722
- ],
723
- begin: Alignment.topLeft,
724
- end: Alignment.bottomRight,
725
- ).createShader(Offset.zero & size);
726
- canvas.drawRect(Offset.zero & size, backgroundPaint);
727
-
728
- final laneFillPaint = Paint()..style = PaintingStyle.fill;
729
- final laneStrokePaint = Paint()
730
- ..style = PaintingStyle.stroke
731
- ..strokeWidth = 1
732
- ..color = _borderLight;
733
- for (var index = 0; index < scene.lanes.length; index++) {
734
- final lane = scene.lanes[index];
735
- final laneRect = Rect.fromLTWH(
736
- 8,
737
- lane.top,
738
- scene.canvasWidth - 16,
739
- lane.height,
740
- );
741
- laneFillPaint.color = lane.color.withValues(
742
- alpha: index.isEven ? 0.05 : 0.025,
743
- );
744
- canvas.drawRRect(
745
- RRect.fromRectAndRadius(laneRect, const Radius.circular(22)),
746
- laneFillPaint,
747
- );
748
- canvas.drawRRect(
749
- RRect.fromRectAndRadius(laneRect, const Radius.circular(22)),
750
- laneStrokePaint,
751
- );
752
- }
753
-
754
- final selectedEntry = selectedEventId == null
755
- ? null
756
- : scene.entries.cast<_TimelineSceneEntry?>().firstWhere(
757
- (entry) => entry?.item.id == selectedEventId,
758
- orElse: () => null,
759
- );
760
- if (selectedEntry != null) {
761
- final selectedLinePaint = Paint()
762
- ..color = selectedEntry.item.sourceColor.withValues(alpha: 0.26)
763
- ..strokeWidth = 2;
764
- canvas.drawLine(
765
- Offset(selectedEntry.centerX, _timelineAxisHeaderHeight - 4),
766
- Offset(selectedEntry.centerX, scene.canvasHeight),
767
- selectedLinePaint,
768
- );
769
- }
770
-
771
- final tickPaint = Paint()
772
- ..color = _borderLight
773
- ..strokeWidth = 1;
774
- for (final tick in scene.ticks) {
775
- canvas.drawLine(
776
- Offset(tick.x, _timelineAxisHeaderHeight - 8),
777
- Offset(tick.x, scene.canvasHeight - 10),
778
- tickPaint,
779
- );
780
- final textPainter = TextPainter(
781
- text: TextSpan(
782
- text: tick.label,
783
- style: TextStyle(
784
- color: _textMuted,
785
- fontSize: 11,
786
- fontWeight: FontWeight.w600,
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),
787
865
  ),
788
- ),
789
- textDirection: TextDirection.ltr,
790
- )..layout(maxWidth: 120);
791
- textPainter.paint(canvas, Offset(tick.x - textPainter.width / 2, 14));
792
- }
793
- }
794
-
795
- @override
796
- bool shouldRepaint(covariant _TimelineScenePainter oldDelegate) {
797
- return scene != oldDelegate.scene ||
798
- selectedEventId != oldDelegate.selectedEventId;
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
+ );
799
897
  }
800
898
  }
801
899
 
802
- class _TimelineSceneLayout {
803
- const _TimelineSceneLayout({
804
- required this.canvasWidth,
805
- required this.canvasHeight,
806
- required this.lanes,
807
- required this.entries,
808
- required this.ticks,
900
+ class _TimelineStatPill extends StatelessWidget {
901
+ const _TimelineStatPill({
902
+ required this.icon,
903
+ required this.label,
904
+ this.color,
809
905
  });
810
906
 
811
- factory _TimelineSceneLayout.build({
812
- required List<TimelineEventItem> items,
813
- required double viewportWidth,
814
- }) {
815
- final lanes = _buildTimelineLanes(items);
816
- final minimumCanvasWidth = math.max(
817
- viewportWidth - 36,
818
- items.length * 156 + _timelineLaneLabelWidth + _timelinePlotRightPadding,
819
- );
820
- final plotStart = _timelineLaneLabelWidth + _timelinePlotInset;
821
- final plotWidth =
822
- minimumCanvasWidth -
823
- plotStart -
824
- _timelinePlotRightPadding -
825
- _timelinePlotInset;
826
- final start = items.first.occurredAt;
827
- final end = items.last.occurredAt;
828
- final durationMs = math.max(end.difference(start).inMilliseconds, 1);
829
- final entries = <_TimelineSceneEntry>[];
830
- final laneGeometries = <_TimelineLaneGeometry>[];
831
- var laneTop = _timelineAxisHeaderHeight;
832
-
833
- for (final lane in lanes) {
834
- final laneItems = items
835
- .where((item) => item.sourceKind == lane.sourceKind)
836
- .toList(growable: false);
837
- final positioned = <({TimelineEventItem item, double x, int track})>[];
838
- final trackLastEdge = <double>[];
839
- for (final item in laneItems) {
840
- final elapsedMs = item.occurredAt.difference(start).inMilliseconds;
841
- final fraction = elapsedMs / durationMs;
842
- final x =
843
- plotStart + (fraction * plotWidth).clamp(0, plotWidth.toDouble());
844
- var track = 0;
845
- while (track < trackLastEdge.length &&
846
- x - trackLastEdge[track] < _timelineNodeWidth + _timelineNodeGap) {
847
- track += 1;
848
- }
849
- if (track == trackLastEdge.length) {
850
- trackLastEdge.add(x);
851
- } else {
852
- trackLastEdge[track] = x;
853
- }
854
- positioned.add((item: item, x: x, track: track));
855
- }
907
+ final IconData icon;
908
+ final String label;
909
+ final Color? color;
856
910
 
857
- final trackCount = math.max(trackLastEdge.length, 1);
858
- final laneHeight =
859
- trackCount * (_timelineNodeHeight + _timelineNodeGap) +
860
- (_timelineLanePadding * 2) -
861
- _timelineNodeGap;
862
- laneGeometries.add(
863
- _TimelineLaneGeometry(
864
- sourceKind: lane.sourceKind,
865
- label: lane.label,
866
- color: lane.color,
867
- top: laneTop,
868
- height: laneHeight,
869
- ),
870
- );
871
- for (final entry in positioned) {
872
- final top =
873
- laneTop +
874
- _timelineLanePadding +
875
- entry.track * (_timelineNodeHeight + _timelineNodeGap);
876
- entries.add(
877
- _TimelineSceneEntry(
878
- item: entry.item,
879
- left: entry.x - (_timelineNodeWidth / 2),
880
- top: top,
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
+ ),
881
933
  ),
882
- );
883
- }
884
- laneTop += laneHeight + _timelineLaneGap;
885
- }
886
-
887
- return _TimelineSceneLayout(
888
- canvasWidth: minimumCanvasWidth,
889
- canvasHeight: laneTop + _timelineCanvasBottomPadding,
890
- lanes: laneGeometries,
891
- entries: entries,
892
- ticks: _buildTimelineTicks(
893
- start: start,
894
- end: end,
895
- plotStart: plotStart,
896
- plotWidth: plotWidth,
934
+ ],
897
935
  ),
898
936
  );
899
937
  }
900
-
901
- final double canvasWidth;
902
- final double canvasHeight;
903
- final List<_TimelineLaneGeometry> lanes;
904
- final List<_TimelineSceneEntry> entries;
905
- final List<_TimelineTick> ticks;
906
938
  }
907
939
 
908
- class _TimelineLaneDefinition {
909
- const _TimelineLaneDefinition({
910
- required this.sourceKind,
940
+ class _TimelineDayGroup {
941
+ const _TimelineDayGroup({
942
+ required this.date,
911
943
  required this.label,
912
- required this.color,
944
+ required this.dateLabel,
945
+ required this.isToday,
946
+ required this.items,
913
947
  });
914
948
 
915
- final String sourceKind;
949
+ final DateTime date;
916
950
  final String label;
917
- final Color color;
951
+ final String dateLabel;
952
+ final bool isToday;
953
+ final List<TimelineEventItem> items;
918
954
  }
919
955
 
920
- class _TimelineLaneGeometry {
921
- const _TimelineLaneGeometry({
922
- required this.sourceKind,
923
- required this.label,
924
- required this.color,
925
- required this.top,
926
- required this.height,
927
- });
956
+ class _TimelineChip {
957
+ const _TimelineChip({required this.label, required this.color});
928
958
 
929
- final String sourceKind;
930
959
  final String label;
931
960
  final Color color;
932
- final double top;
933
- final double height;
934
961
  }
935
962
 
936
- class _TimelineSceneEntry {
937
- const _TimelineSceneEntry({
938
- required this.item,
939
- required this.left,
940
- required this.top,
963
+ class _TimelineDetailCell {
964
+ const _TimelineDetailCell({
965
+ required this.label,
966
+ required this.value,
967
+ this.emphasized = false,
968
+ this.color,
941
969
  });
942
970
 
943
- final TimelineEventItem item;
944
- final double left;
945
- final double top;
946
-
947
- double get centerX => left + (_timelineNodeWidth / 2);
948
- }
949
-
950
- class _TimelineTick {
951
- const _TimelineTick({required this.x, required this.label});
952
-
953
- final double x;
954
971
  final String label;
972
+ final String value;
973
+ final bool emphasized;
974
+ final Color? color;
955
975
  }
956
976
 
957
977
  List<TimelineEventItem> _sortedTimelineEvents(List<TimelineEventItem> items) {
958
978
  final sorted = List<TimelineEventItem>.of(items);
959
979
  sorted.sort((a, b) {
960
- final timestampCompare = a.occurredAt.compareTo(b.occurredAt);
980
+ final timestampCompare = b.occurredAt.compareTo(a.occurredAt);
961
981
  if (timestampCompare != 0) {
962
982
  return timestampCompare;
963
983
  }
964
- return a.id.compareTo(b.id);
984
+ return b.id.compareTo(a.id);
965
985
  });
966
986
  return sorted;
967
987
  }
968
988
 
969
- List<_TimelineLaneDefinition> _buildTimelineLanes(
970
- List<TimelineEventItem> items,
971
- ) {
972
- const preferredOrder = <String>['screen', 'tasks', 'runs'];
973
- final firstBySource = <String, TimelineEventItem>{};
974
- for (final item in items) {
975
- firstBySource.putIfAbsent(item.sourceKind, () => item);
976
- }
989
+ List<_TimelineDayGroup> _groupTimelineEvents(List<TimelineEventItem> items) {
990
+ final groups = <_TimelineDayGroup>[];
991
+ final now = DateTime.now();
992
+ DateTime? activeDate;
993
+ final buffer = <TimelineEventItem>[];
977
994
 
978
- final lanes = <_TimelineLaneDefinition>[];
979
- for (final kind in preferredOrder) {
980
- final sample = firstBySource[kind];
981
- if (sample == null) {
982
- continue;
995
+ void flush() {
996
+ if (activeDate == null || buffer.isEmpty) {
997
+ return;
983
998
  }
984
- lanes.add(
985
- _TimelineLaneDefinition(
986
- sourceKind: kind,
987
- label: sample.sourceLabel,
988
- color: sample.sourceColor,
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),
989
1007
  ),
990
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);
991
1020
  }
1021
+ flush();
1022
+ return groups;
1023
+ }
992
1024
 
993
- final remaining =
994
- firstBySource.keys
995
- .where((kind) => !preferredOrder.contains(kind))
996
- .toList()
997
- ..sort();
998
- for (final kind in remaining) {
999
- final sample = firstBySource[kind]!;
1000
- lanes.add(
1001
- _TimelineLaneDefinition(
1002
- sourceKind: kind,
1003
- label: sample.sourceLabel,
1004
- color: sample.sourceColor,
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,
1005
1045
  ),
1006
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));
1007
1064
  }
1008
- return lanes;
1065
+ return chips.take(4).toList(growable: false);
1009
1066
  }
1010
1067
 
1011
- List<_TimelineTick> _buildTimelineTicks({
1012
- required DateTime start,
1013
- required DateTime end,
1014
- required double plotStart,
1015
- required double plotWidth,
1016
- }) {
1017
- final totalDuration = end.difference(start);
1018
- final step = _pickTimelineTickStep(totalDuration);
1019
- final rangeMs = math.max(totalDuration.inMilliseconds, 1);
1020
- final alignedStart = _alignTimelineTick(start, step);
1021
- final ticks = <_TimelineTick>[];
1022
-
1023
- for (
1024
- var tick = alignedStart;
1025
- !tick.isAfter(end.add(step));
1026
- tick = tick.add(step)
1027
- ) {
1028
- final offsetMs = tick.difference(start).inMilliseconds;
1029
- final fraction = (offsetMs / rangeMs).clamp(0.0, 1.0);
1030
- ticks.add(
1031
- _TimelineTick(
1032
- x: plotStart + fraction * plotWidth,
1033
- label: _formatTimelineTick(tick, step),
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,
1034
1092
  ),
1035
1093
  );
1036
- }
1037
- if (ticks.length < 2) {
1038
- ticks.add(
1039
- _TimelineTick(
1040
- x: plotStart + plotWidth,
1041
- label: _formatTimelineTick(end, step),
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'),
1042
1136
  ),
1043
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'));
1044
1155
  }
1045
- return ticks;
1156
+ return cells.take(6).toList(growable: false);
1046
1157
  }
1047
1158
 
1048
- Duration _pickTimelineTickStep(Duration range) {
1049
- final candidates = <Duration>[
1050
- const Duration(minutes: 15),
1051
- const Duration(minutes: 30),
1052
- const Duration(hours: 1),
1053
- const Duration(hours: 2),
1054
- const Duration(hours: 6),
1055
- const Duration(hours: 12),
1056
- const Duration(days: 1),
1057
- const Duration(days: 2),
1058
- const Duration(days: 7),
1059
- const Duration(days: 14),
1060
- const Duration(days: 30),
1061
- const Duration(days: 90),
1062
- ];
1063
- final targetTicks = math.max((range.inHours / 6).round(), 4);
1064
- for (final step in candidates) {
1065
- if (range.inMilliseconds / step.inMilliseconds <= targetTicks) {
1066
- return step;
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';
1067
1204
  }
1205
+ return '${span.inMinutes}m ${seconds}s';
1206
+ }
1207
+ final minutes = span.inMinutes % 60;
1208
+ if (minutes == 0) {
1209
+ return '${span.inHours}h';
1068
1210
  }
1069
- return const Duration(days: 180);
1211
+ return '${span.inHours}h ${minutes}m';
1070
1212
  }
1071
1213
 
1072
- DateTime _alignTimelineTick(DateTime value, Duration step) {
1073
- final stepMs = math.max(step.inMilliseconds, 1);
1074
- final alignedMs = (value.millisecondsSinceEpoch ~/ stepMs) * stepMs;
1075
- return DateTime.fromMillisecondsSinceEpoch(alignedMs, isUtc: value.isUtc);
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;
1076
1240
  }
1077
1241
 
1078
1242
  String _formatTimelineRange(TimelineEventItem first, TimelineEventItem last) {
1079
- final start = first.occurredAt.toLocal();
1080
- final end = last.occurredAt.toLocal();
1243
+ final start = last.occurredAt.toLocal();
1244
+ final end = first.occurredAt.toLocal();
1081
1245
  final startDate = _formatTimelineDate(start);
1082
1246
  final endDate = _formatTimelineDate(end);
1083
1247
  final startTime = _formatTimelineTime(start);
1084
1248
  final endTime = _formatTimelineTime(end);
1085
- if (start.year == end.year &&
1086
- start.month == end.month &&
1087
- start.day == end.day) {
1249
+ if (_isSameDay(start, end)) {
1088
1250
  return '$startDate · $startTime - $endTime';
1089
1251
  }
1090
- return '$startDate, $startTime -> $endDate, $endTime';
1091
- }
1092
-
1093
- String _formatTimelineTick(DateTime value, Duration step) {
1094
- final local = value.toLocal();
1095
- if (step.inHours < 24) {
1096
- return _formatTimelineTime(local);
1097
- }
1098
- if (step.inDays < 30) {
1099
- return '${_monthShort(local.month)} ${local.day}';
1100
- }
1101
- return '${_monthShort(local.month)} ${local.year}';
1252
+ return '$startDate -> $endDate';
1102
1253
  }
1103
1254
 
1104
1255
  String _formatTimelineDate(DateTime value) {
@@ -1130,21 +1281,9 @@ String _monthShort(int month) {
1130
1281
  return labels[(month - 1).clamp(0, labels.length - 1)];
1131
1282
  }
1132
1283
 
1133
- String _timelineNodeSubtitle(TimelineEventItem item) {
1134
- switch (item.sourceKind) {
1135
- case 'screen':
1136
- return item.appName
1137
- .ifEmpty(item.windowTitle)
1138
- .ifEmpty(item.deviceLabel.ifEmpty('Passive capture'));
1139
- case 'tasks':
1140
- return _titleCase(item.eventKind.replaceAll('_', ' '));
1141
- case 'runs':
1142
- return item.summary.trim().ifEmpty(
1143
- _titleCase(item.eventKind.replaceAll('_', ' ')),
1144
- );
1145
- default:
1146
- return item.sourceLabel;
1147
- }
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)];
1148
1287
  }
1149
1288
 
1150
1289
  IconData _timelineLaneIcon(String sourceKind) {
@@ -1165,66 +1304,48 @@ List<Widget> _timelineDetailBody(
1165
1304
  VoidCallback? onOpenRun,
1166
1305
  ) {
1167
1306
  final content = <Widget>[];
1168
- final body = item.sourceKind == 'screen'
1169
- ? item.previewText.trim()
1170
- : item.summary.trim();
1171
- if (body.isNotEmpty) {
1172
- content.add(
1173
- Text(body, style: TextStyle(color: _textSecondary, height: 1.5)),
1174
- );
1175
- content.add(const SizedBox(height: 16));
1176
- }
1177
1307
 
1178
- switch (item.sourceKind) {
1179
- case 'screen':
1180
- content.addAll(<Widget>[
1181
- _TimelineMetaLine(
1182
- icon: Icons.computer_outlined,
1183
- text:
1184
- '${item.deviceLabel.ifEmpty('Desktop')} · ${item.appName.ifEmpty('Unknown app')}',
1185
- ),
1186
- if (item.windowTitle.isNotEmpty)
1187
- _TimelineMetaLine(
1188
- icon: Icons.web_asset_outlined,
1189
- text: item.windowTitle,
1190
- ),
1191
- _TimelineMetaLine(
1192
- icon: Icons.schedule_outlined,
1193
- text: item.screenSpanLabel,
1194
- ),
1195
- ]);
1196
- break;
1197
- case 'tasks':
1198
- case 'runs':
1199
- content.addAll(<Widget>[
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)
1200
1316
  _TimelineMetaLine(
1201
- icon: item.sourceKind == 'tasks'
1202
- ? Icons.task_alt_outlined
1203
- : Icons.monitor_heart_outlined,
1204
- text: _titleCase(item.eventKind.replaceAll('_', ' ')),
1317
+ icon: Icons.web_asset_outlined,
1318
+ text: item.windowTitle,
1205
1319
  ),
1206
- if (item.runId.isNotEmpty && onOpenRun != null)
1207
- Padding(
1208
- padding: const EdgeInsets.only(top: 6),
1209
- child: Align(
1210
- alignment: Alignment.centerLeft,
1211
- child: TextButton.icon(
1212
- onPressed: onOpenRun,
1213
- icon: const Icon(Icons.open_in_new_rounded, size: 16),
1214
- label: const Text('Open run'),
1215
- ),
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'),
1216
1344
  ),
1217
1345
  ),
1218
- ]);
1219
- break;
1220
- default:
1221
- content.add(
1222
- _TimelineMetaLine(
1223
- icon: Icons.info_outline_rounded,
1224
- text: _titleCase(item.eventKind.replaceAll('_', ' ')),
1225
1346
  ),
1226
1347
  );
1227
- break;
1348
+ }
1228
1349
  }
1229
1350
  return content;
1230
1351
  }