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.
- package/flutter_app/lib/main_timeline.dart +1132 -105
- package/package.json +1 -1
- package/server/db/database.js +32 -9
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +69968 -69538
- package/server/services/tasks/runtime.js +12 -3
|
@@ -1,12 +1,98 @@
|
|
|
1
1
|
part of 'main.dart';
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
'
|
|
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: (_) =>
|
|
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:
|
|
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
|
-
:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
88
|
-
const
|
|
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
|
|
91
|
-
final
|
|
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:
|
|
170
|
-
vertical:
|
|
289
|
+
horizontal: 12,
|
|
290
|
+
vertical: 8,
|
|
171
291
|
),
|
|
172
292
|
decoration: BoxDecoration(
|
|
173
|
-
color:
|
|
293
|
+
color: _bgTertiary.withValues(alpha: 0.7),
|
|
174
294
|
borderRadius: BorderRadius.circular(999),
|
|
295
|
+
border: Border.all(color: _borderLight),
|
|
175
296
|
),
|
|
176
|
-
child:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
436
|
+
IconButton(
|
|
437
|
+
tooltip: 'Previous event',
|
|
438
|
+
onPressed: onSelectPrevious,
|
|
439
|
+
icon: const Icon(Icons.chevron_left_rounded),
|
|
440
|
+
),
|
|
185
441
|
Text(
|
|
186
|
-
item.
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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:
|
|
1241
|
+
padding: const EdgeInsets.only(bottom: 10),
|
|
215
1242
|
child: Row(
|
|
216
1243
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
217
1244
|
children: <Widget>[
|