neoagent 3.0.1-beta.25 → 3.0.1-beta.27

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,12 +1,98 @@
1
1
  part of 'main.dart';
2
2
 
3
- class TimelinePanel extends StatelessWidget {
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
+ class TimelinePanel extends StatefulWidget {
4
15
  const TimelinePanel({super.key, required this.controller});
5
16
 
6
17
  final NeoAgentController controller;
7
18
 
19
+ @override
20
+ State<TimelinePanel> createState() => _TimelinePanelState();
21
+ }
22
+
23
+ class _TimelinePanelState extends State<TimelinePanel> {
24
+ final TransformationController _viewportController =
25
+ TransformationController();
26
+
27
+ 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
+
40
+ void _selectEvent(TimelineEventItem item) {
41
+ if (_selectedEventId == item.id) {
42
+ return;
43
+ }
44
+ setState(() {
45
+ _selectedEventId = item.id;
46
+ });
47
+ }
48
+
49
+ void _setHoveredEvent(int? eventId) {
50
+ if (_hoveredEventId == eventId) {
51
+ return;
52
+ }
53
+ setState(() {
54
+ _hoveredEventId = eventId;
55
+ });
56
+ }
57
+
58
+ void _moveSelection(List<TimelineEventItem> items, int offset) {
59
+ if (items.isEmpty) {
60
+ return;
61
+ }
62
+ final currentIndex = items.indexWhere(
63
+ (item) => item.id == _selectedEventId,
64
+ );
65
+ final baseIndex = currentIndex == -1
66
+ ? (offset > 0 ? 0 : items.length - 1)
67
+ : currentIndex;
68
+ final nextIndex = (baseIndex + offset).clamp(0, items.length - 1);
69
+ setState(() {
70
+ _selectedEventId = items[nextIndex].id;
71
+ });
72
+ }
73
+
74
+ TimelineEventItem? _resolveSelectedEvent(List<TimelineEventItem> items) {
75
+ if (items.isEmpty) {
76
+ _selectedEventId = null;
77
+ return null;
78
+ }
79
+ for (final item in items) {
80
+ if (item.id == _selectedEventId) {
81
+ return item;
82
+ }
83
+ }
84
+ _selectedEventId = items.last.id;
85
+ return items.last;
86
+ }
87
+
8
88
  @override
9
89
  Widget build(BuildContext context) {
90
+ final items = _sortedTimelineEvents(widget.controller.timelineItems);
91
+ final selectedEvent = _resolveSelectedEvent(items);
92
+ final selectedIndex = selectedEvent == null
93
+ ? -1
94
+ : items.indexWhere((item) => item.id == selectedEvent.id);
95
+
10
96
  return Padding(
11
97
  padding: _pagePadding(context),
12
98
  child: Column(
@@ -15,7 +101,7 @@ class TimelinePanel extends StatelessWidget {
15
101
  const _PageTitle(
16
102
  title: 'Timeline',
17
103
  subtitle:
18
- 'User-wide activity feed for passive screen sessions, task lifecycle, and run lifecycle.',
104
+ 'Interactive chronology across passive screen sessions, tasks, and runs.',
19
105
  ),
20
106
  const SizedBox(height: 12),
21
107
  Wrap(
@@ -28,17 +114,18 @@ class TimelinePanel extends StatelessWidget {
28
114
  (id: 'runs', label: 'Runs'),
29
115
  ])
30
116
  FilterChip(
31
- selected: controller.selectedTimelineSources.contains(
117
+ selected: widget.controller.selectedTimelineSources.contains(
32
118
  filter.id,
33
119
  ),
34
120
  label: Text(filter.label),
35
- onSelected: (_) => controller.toggleTimelineSource(filter.id),
121
+ onSelected: (_) =>
122
+ widget.controller.toggleTimelineSource(filter.id),
36
123
  ),
37
124
  OutlinedButton.icon(
38
- onPressed: controller.isRefreshingTimeline
125
+ onPressed: widget.controller.isRefreshingTimeline
39
126
  ? null
40
- : controller.refreshTimeline,
41
- icon: controller.isRefreshingTimeline
127
+ : widget.controller.refreshTimeline,
128
+ icon: widget.controller.isRefreshingTimeline
42
129
  ? const SizedBox.square(
43
130
  dimension: 14,
44
131
  child: CircularProgressIndicator(strokeWidth: 2),
@@ -46,11 +133,38 @@ class TimelinePanel extends StatelessWidget {
46
133
  : const Icon(Icons.sync_outlined),
47
134
  label: const Text('Refresh'),
48
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
+ ),
49
141
  ],
50
142
  ),
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
+ ],
51
165
  const SizedBox(height: 16),
52
166
  Expanded(
53
- child: controller.timelineItems.isEmpty
167
+ child: items.isEmpty
54
168
  ? Card(
55
169
  child: Center(
56
170
  child: Padding(
@@ -62,18 +176,57 @@ class TimelinePanel extends StatelessWidget {
62
176
  ),
63
177
  ),
64
178
  )
65
- : ListView.separated(
66
- itemCount: controller.timelineItems.length,
67
- separatorBuilder: (_, _) => const SizedBox(height: 12),
68
- itemBuilder: (context, index) {
69
- final item = controller.timelineItems[index];
70
- return _TimelineEventCard(
71
- item: item,
72
- onOpenRun: item.runId.isEmpty
73
- ? null
74
- : () => unawaited(
75
- controller.openRunDetails(item.runId),
76
- ),
179
+ : LayoutBuilder(
180
+ builder: (context, constraints) {
181
+ final isWide = constraints.maxWidth >= 1120;
182
+ final timelinePane = _InteractiveTimelineCanvas(
183
+ items: items,
184
+ selectedEventId: selectedEvent?.id,
185
+ hoveredEventId: _hoveredEventId,
186
+ viewportController: _viewportController,
187
+ onSelectEvent: _selectEvent,
188
+ onHoverEvent: _setHoveredEvent,
189
+ );
190
+ final detailPane = _TimelineSelectionPanel(
191
+ items: items,
192
+ selectedEvent: selectedEvent,
193
+ selectedIndex: selectedIndex,
194
+ onSelectPrevious: selectedIndex > 0
195
+ ? () => _moveSelection(items, -1)
196
+ : null,
197
+ onSelectNext:
198
+ selectedIndex >= 0 &&
199
+ selectedIndex < items.length - 1
200
+ ? () => _moveSelection(items, 1)
201
+ : null,
202
+ onOpenRun:
203
+ selectedEvent != null &&
204
+ selectedEvent.runId.isNotEmpty
205
+ ? () => unawaited(
206
+ widget.controller.openRunDetails(
207
+ selectedEvent.runId,
208
+ ),
209
+ )
210
+ : null,
211
+ );
212
+
213
+ if (isWide) {
214
+ return Row(
215
+ crossAxisAlignment: CrossAxisAlignment.stretch,
216
+ children: <Widget>[
217
+ Expanded(flex: 7, child: timelinePane),
218
+ const SizedBox(width: 16),
219
+ SizedBox(width: 360, child: detailPane),
220
+ ],
221
+ );
222
+ }
223
+ return Column(
224
+ crossAxisAlignment: CrossAxisAlignment.stretch,
225
+ children: <Widget>[
226
+ Expanded(flex: 3, child: timelinePane),
227
+ const SizedBox(height: 16),
228
+ Expanded(flex: 2, child: detailPane),
229
+ ],
77
230
  );
78
231
  },
79
232
  ),
@@ -84,79 +237,27 @@ class TimelinePanel extends StatelessWidget {
84
237
  }
85
238
  }
86
239
 
87
- class _TimelineEventCard extends StatelessWidget {
88
- const _TimelineEventCard({required this.item, this.onOpenRun});
240
+ class _InteractiveTimelineCanvas extends StatelessWidget {
241
+ const _InteractiveTimelineCanvas({
242
+ required this.items,
243
+ required this.selectedEventId,
244
+ required this.hoveredEventId,
245
+ required this.viewportController,
246
+ required this.onSelectEvent,
247
+ required this.onHoverEvent,
248
+ });
89
249
 
90
- final TimelineEventItem item;
91
- final VoidCallback? onOpenRun;
250
+ final List<TimelineEventItem> items;
251
+ final int? selectedEventId;
252
+ final int? hoveredEventId;
253
+ final TransformationController viewportController;
254
+ final ValueChanged<TimelineEventItem> onSelectEvent;
255
+ final ValueChanged<int?> onHoverEvent;
92
256
 
93
257
  @override
94
258
  Widget build(BuildContext context) {
95
- final details = switch (item.sourceKind) {
96
- 'screen' => <Widget>[
97
- _TimelineMetaLine(
98
- icon: Icons.computer_outlined,
99
- text:
100
- '${item.deviceLabel.ifEmpty('Desktop')} · ${item.appName.ifEmpty('Unknown app')}',
101
- ),
102
- if (item.windowTitle.isNotEmpty)
103
- _TimelineMetaLine(
104
- icon: Icons.crop_square_rounded,
105
- text: item.windowTitle,
106
- ),
107
- _TimelineMetaLine(
108
- icon: Icons.schedule_outlined,
109
- text: item.screenSpanLabel,
110
- ),
111
- if (item.previewText.trim().isNotEmpty)
112
- Text(
113
- item.previewText.trim(),
114
- style: TextStyle(color: _textSecondary, height: 1.45),
115
- ),
116
- ],
117
- 'tasks' => <Widget>[
118
- _TimelineMetaLine(
119
- icon: Icons.task_alt_outlined,
120
- text: item.eventKind.replaceAll('_', ' '),
121
- ),
122
- if (item.summary.trim().isNotEmpty)
123
- Text(
124
- item.summary.trim(),
125
- style: TextStyle(color: _textSecondary, height: 1.45),
126
- ),
127
- if (item.runId.isNotEmpty && onOpenRun != null)
128
- Align(
129
- alignment: Alignment.centerLeft,
130
- child: TextButton.icon(
131
- onPressed: onOpenRun,
132
- icon: const Icon(Icons.open_in_new_rounded, size: 16),
133
- label: const Text('Open run'),
134
- ),
135
- ),
136
- ],
137
- _ => <Widget>[
138
- _TimelineMetaLine(
139
- icon: Icons.monitor_heart_outlined,
140
- text: item.eventKind.replaceAll('_', ' '),
141
- ),
142
- if (item.summary.trim().isNotEmpty)
143
- Text(
144
- item.summary.trim(),
145
- style: TextStyle(color: _textSecondary, height: 1.45),
146
- ),
147
- if (item.runId.isNotEmpty && onOpenRun != null)
148
- Align(
149
- alignment: Alignment.centerLeft,
150
- child: TextButton.icon(
151
- onPressed: onOpenRun,
152
- icon: const Icon(Icons.open_in_new_rounded, size: 16),
153
- label: const Text('Open run'),
154
- ),
155
- ),
156
- ],
157
- };
158
-
159
259
  return Card(
260
+ clipBehavior: Clip.antiAlias,
160
261
  child: Padding(
161
262
  padding: const EdgeInsets.all(18),
162
263
  child: Column(
@@ -164,37 +265,265 @@ class _TimelineEventCard extends StatelessWidget {
164
265
  children: <Widget>[
165
266
  Row(
166
267
  children: <Widget>[
268
+ 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
+ ],
285
+ ),
286
+ ),
167
287
  Container(
168
288
  padding: const EdgeInsets.symmetric(
169
- horizontal: 10,
170
- vertical: 6,
289
+ horizontal: 12,
290
+ vertical: 8,
171
291
  ),
172
292
  decoration: BoxDecoration(
173
- color: item.sourceColor.withValues(alpha: 0.14),
293
+ color: _bgTertiary.withValues(alpha: 0.7),
174
294
  borderRadius: BorderRadius.circular(999),
295
+ border: Border.all(color: _borderLight),
175
296
  ),
176
- child: Text(
177
- item.sourceLabel,
178
- style: TextStyle(
179
- color: item.sourceColor,
180
- fontWeight: FontWeight.w700,
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
+ ],
311
+ ),
312
+ ),
313
+ ],
314
+ ),
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
+ ),
181
390
  ),
391
+ );
392
+ },
393
+ ),
394
+ ),
395
+ ],
396
+ ),
397
+ ),
398
+ );
399
+ }
400
+ }
401
+
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,
410
+ });
411
+
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;
418
+
419
+ @override
420
+ 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(
431
+ child: Text(
432
+ 'Event detail',
433
+ style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
182
434
  ),
183
435
  ),
184
- const Spacer(),
436
+ IconButton(
437
+ tooltip: 'Previous event',
438
+ onPressed: onSelectPrevious,
439
+ icon: const Icon(Icons.chevron_left_rounded),
440
+ ),
185
441
  Text(
186
- item.occurredAtLabel,
442
+ item == null ? '0/0' : '${selectedIndex + 1}/${items.length}',
187
443
  style: TextStyle(color: _textMuted, fontSize: 12),
188
444
  ),
445
+ IconButton(
446
+ tooltip: 'Next event',
447
+ onPressed: onSelectNext,
448
+ icon: const Icon(Icons.chevron_right_rounded),
449
+ ),
189
450
  ],
190
451
  ),
191
452
  const SizedBox(height: 12),
192
- Text(
193
- item.title.ifEmpty(item.taskName),
194
- style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
195
- ),
196
- const SizedBox(height: 10),
197
- ...details,
453
+ if (item == null)
454
+ Expanded(
455
+ child: Center(
456
+ child: Text(
457
+ 'Select an event on the timeline.',
458
+ style: TextStyle(color: _textSecondary),
459
+ ),
460
+ ),
461
+ )
462
+ else
463
+ Expanded(
464
+ child: SingleChildScrollView(
465
+ child: Column(
466
+ crossAxisAlignment: CrossAxisAlignment.start,
467
+ children: <Widget>[
468
+ Row(
469
+ children: <Widget>[
470
+ Container(
471
+ padding: const EdgeInsets.symmetric(
472
+ horizontal: 10,
473
+ vertical: 6,
474
+ ),
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
+ ],
493
+ ),
494
+ const SizedBox(height: 14),
495
+ Text(
496
+ item.title.ifEmpty(item.taskName),
497
+ style: const TextStyle(
498
+ fontSize: 20,
499
+ fontWeight: FontWeight.w800,
500
+ height: 1.15,
501
+ ),
502
+ ),
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
+ ],
520
+ ),
521
+ const SizedBox(height: 16),
522
+ ..._timelineDetailBody(item, onOpenRun),
523
+ ],
524
+ ),
525
+ ),
526
+ ),
198
527
  ],
199
528
  ),
200
529
  ),
@@ -202,6 +531,704 @@ class _TimelineEventCard extends StatelessWidget {
202
531
  }
203
532
  }
204
533
 
534
+ class _TimelineLaneBadge extends StatelessWidget {
535
+ const _TimelineLaneBadge({
536
+ required this.label,
537
+ required this.color,
538
+ required this.icon,
539
+ });
540
+
541
+ final String label;
542
+ final Color color;
543
+ final IconData icon;
544
+
545
+ @override
546
+ Widget build(BuildContext context) {
547
+ return Container(
548
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
549
+ 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
+ ],
560
+ ),
561
+ child: Row(
562
+ mainAxisSize: MainAxisSize.min,
563
+ children: <Widget>[
564
+ Icon(icon, size: 15, color: color),
565
+ const SizedBox(width: 8),
566
+ Text(label, style: const TextStyle(fontWeight: FontWeight.w700)),
567
+ ],
568
+ ),
569
+ );
570
+ }
571
+ }
572
+
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,
580
+ });
581
+
582
+ final TimelineEventItem item;
583
+ final bool isSelected;
584
+ final bool isHovered;
585
+ final VoidCallback onTap;
586
+ final ValueChanged<bool> onHoverChanged;
587
+
588
+ @override
589
+ 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),
624
+ ],
625
+ begin: Alignment.topLeft,
626
+ end: Alignment.bottomRight,
627
+ ),
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
+ ),
637
+ child: Column(
638
+ crossAxisAlignment: CrossAxisAlignment.start,
639
+ children: <Widget>[
640
+ Row(
641
+ 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
+ ],
654
+ ),
655
+ ),
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
+ ),
665
+ ),
666
+ ),
667
+ if (isSelected)
668
+ Icon(
669
+ Icons.ads_click_rounded,
670
+ size: 15,
671
+ color: accentColor,
672
+ ),
673
+ ],
674
+ ),
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
+ ),
699
+ ),
700
+ ),
701
+ ),
702
+ );
703
+ }
704
+ }
705
+
706
+ class _TimelineScenePainter extends CustomPainter {
707
+ const _TimelineScenePainter({
708
+ required this.scene,
709
+ required this.selectedEventId,
710
+ });
711
+
712
+ final _TimelineSceneLayout scene;
713
+ final int? selectedEventId;
714
+
715
+ @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,
787
+ ),
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;
799
+ }
800
+ }
801
+
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,
809
+ });
810
+
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
+ }
856
+
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,
881
+ ),
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,
897
+ ),
898
+ );
899
+ }
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
+ }
907
+
908
+ class _TimelineLaneDefinition {
909
+ const _TimelineLaneDefinition({
910
+ required this.sourceKind,
911
+ required this.label,
912
+ required this.color,
913
+ });
914
+
915
+ final String sourceKind;
916
+ final String label;
917
+ final Color color;
918
+ }
919
+
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
+ });
928
+
929
+ final String sourceKind;
930
+ final String label;
931
+ final Color color;
932
+ final double top;
933
+ final double height;
934
+ }
935
+
936
+ class _TimelineSceneEntry {
937
+ const _TimelineSceneEntry({
938
+ required this.item,
939
+ required this.left,
940
+ required this.top,
941
+ });
942
+
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
+ final String label;
955
+ }
956
+
957
+ List<TimelineEventItem> _sortedTimelineEvents(List<TimelineEventItem> items) {
958
+ final sorted = List<TimelineEventItem>.of(items);
959
+ sorted.sort((a, b) {
960
+ final timestampCompare = a.occurredAt.compareTo(b.occurredAt);
961
+ if (timestampCompare != 0) {
962
+ return timestampCompare;
963
+ }
964
+ return a.id.compareTo(b.id);
965
+ });
966
+ return sorted;
967
+ }
968
+
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
+ }
977
+
978
+ final lanes = <_TimelineLaneDefinition>[];
979
+ for (final kind in preferredOrder) {
980
+ final sample = firstBySource[kind];
981
+ if (sample == null) {
982
+ continue;
983
+ }
984
+ lanes.add(
985
+ _TimelineLaneDefinition(
986
+ sourceKind: kind,
987
+ label: sample.sourceLabel,
988
+ color: sample.sourceColor,
989
+ ),
990
+ );
991
+ }
992
+
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,
1005
+ ),
1006
+ );
1007
+ }
1008
+ return lanes;
1009
+ }
1010
+
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),
1034
+ ),
1035
+ );
1036
+ }
1037
+ if (ticks.length < 2) {
1038
+ ticks.add(
1039
+ _TimelineTick(
1040
+ x: plotStart + plotWidth,
1041
+ label: _formatTimelineTick(end, step),
1042
+ ),
1043
+ );
1044
+ }
1045
+ return ticks;
1046
+ }
1047
+
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;
1067
+ }
1068
+ }
1069
+ return const Duration(days: 180);
1070
+ }
1071
+
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);
1076
+ }
1077
+
1078
+ String _formatTimelineRange(TimelineEventItem first, TimelineEventItem last) {
1079
+ final start = first.occurredAt.toLocal();
1080
+ final end = last.occurredAt.toLocal();
1081
+ final startDate = _formatTimelineDate(start);
1082
+ final endDate = _formatTimelineDate(end);
1083
+ final startTime = _formatTimelineTime(start);
1084
+ final endTime = _formatTimelineTime(end);
1085
+ if (start.year == end.year &&
1086
+ start.month == end.month &&
1087
+ start.day == end.day) {
1088
+ return '$startDate · $startTime - $endTime';
1089
+ }
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}';
1102
+ }
1103
+
1104
+ String _formatTimelineDate(DateTime value) {
1105
+ return '${_monthShort(value.month)} ${value.day}, ${value.year}';
1106
+ }
1107
+
1108
+ String _formatTimelineTime(DateTime value) {
1109
+ final local = value.toLocal();
1110
+ final hour = local.hour.toString().padLeft(2, '0');
1111
+ final minute = local.minute.toString().padLeft(2, '0');
1112
+ return '$hour:$minute';
1113
+ }
1114
+
1115
+ String _monthShort(int month) {
1116
+ const labels = <String>[
1117
+ 'Jan',
1118
+ 'Feb',
1119
+ 'Mar',
1120
+ 'Apr',
1121
+ 'May',
1122
+ 'Jun',
1123
+ 'Jul',
1124
+ 'Aug',
1125
+ 'Sep',
1126
+ 'Oct',
1127
+ 'Nov',
1128
+ 'Dec',
1129
+ ];
1130
+ return labels[(month - 1).clamp(0, labels.length - 1)];
1131
+ }
1132
+
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
+ }
1148
+ }
1149
+
1150
+ IconData _timelineLaneIcon(String sourceKind) {
1151
+ switch (sourceKind) {
1152
+ case 'screen':
1153
+ return Icons.desktop_windows_outlined;
1154
+ case 'tasks':
1155
+ return Icons.task_alt_outlined;
1156
+ case 'runs':
1157
+ return Icons.monitor_heart_outlined;
1158
+ default:
1159
+ return Icons.timeline_outlined;
1160
+ }
1161
+ }
1162
+
1163
+ List<Widget> _timelineDetailBody(
1164
+ TimelineEventItem item,
1165
+ VoidCallback? onOpenRun,
1166
+ ) {
1167
+ 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
+
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>[
1200
+ _TimelineMetaLine(
1201
+ icon: item.sourceKind == 'tasks'
1202
+ ? Icons.task_alt_outlined
1203
+ : Icons.monitor_heart_outlined,
1204
+ text: _titleCase(item.eventKind.replaceAll('_', ' ')),
1205
+ ),
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
+ ),
1216
+ ),
1217
+ ),
1218
+ ]);
1219
+ break;
1220
+ default:
1221
+ content.add(
1222
+ _TimelineMetaLine(
1223
+ icon: Icons.info_outline_rounded,
1224
+ text: _titleCase(item.eventKind.replaceAll('_', ' ')),
1225
+ ),
1226
+ );
1227
+ break;
1228
+ }
1229
+ return content;
1230
+ }
1231
+
205
1232
  class _TimelineMetaLine extends StatelessWidget {
206
1233
  const _TimelineMetaLine({required this.icon, required this.text});
207
1234
 
@@ -211,7 +1238,7 @@ class _TimelineMetaLine extends StatelessWidget {
211
1238
  @override
212
1239
  Widget build(BuildContext context) {
213
1240
  return Padding(
214
- padding: const EdgeInsets.only(bottom: 8),
1241
+ padding: const EdgeInsets.only(bottom: 10),
215
1242
  child: Row(
216
1243
  crossAxisAlignment: CrossAxisAlignment.start,
217
1244
  children: <Widget>[