252 lines
6.6 KiB
Dart
252 lines
6.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'bridge.dart';
|
|
import 'state.dart';
|
|
import 'timeline.dart';
|
|
|
|
void main() {
|
|
runApp(const MainApp());
|
|
}
|
|
|
|
class MainApp extends StatefulWidget {
|
|
const MainApp({super.key});
|
|
|
|
@override
|
|
State<MainApp> createState() => _MainAppState();
|
|
}
|
|
|
|
class _MainAppState extends State<MainApp> {
|
|
TimelineState? _state;
|
|
List<TimelineGroup> _groups = const [];
|
|
List<TimelineEntry> _entries = const [];
|
|
TimelineViewportNotifier? _viewport;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initBridge();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_viewport?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _initBridge() {
|
|
final initial = readInitialState();
|
|
if (initial != null) {
|
|
_applyState(TimelineState.fromJson(initial));
|
|
}
|
|
|
|
onStateUpdated((json) {
|
|
_applyState(TimelineState.fromJson(json));
|
|
});
|
|
}
|
|
|
|
void _applyState(TimelineState state) {
|
|
final groups = _convertGroups(state.timeline.groups);
|
|
final entries = _convertEntries(state.timeline.groups);
|
|
final domain = _computeDomain(entries);
|
|
|
|
setState(() {
|
|
_state = state;
|
|
_groups = groups;
|
|
_entries = entries;
|
|
|
|
_viewport ??= TimelineViewportNotifier(
|
|
start: domain.start,
|
|
end: domain.end,
|
|
);
|
|
});
|
|
|
|
_emitContentHeight();
|
|
}
|
|
|
|
List<TimelineGroup> _convertGroups(List<TimelineGroupData> groups) {
|
|
return [
|
|
for (final g in groups) TimelineGroup(id: g.id, title: g.title),
|
|
];
|
|
}
|
|
|
|
List<TimelineEntry> _convertEntries(List<TimelineGroupData> groups) {
|
|
final entries = <TimelineEntry>[];
|
|
for (final group in groups) {
|
|
// Collect all items for this group to compute lanes
|
|
final groupItems = group.items;
|
|
final sorted = [...groupItems]..sort(
|
|
(a, b) => a.start.compareTo(b.start),
|
|
);
|
|
|
|
for (final item in sorted) {
|
|
final start = DateTime.parse(item.start);
|
|
final end = item.end != null
|
|
? DateTime.parse(item.end!)
|
|
: start.add(const Duration(days: 1));
|
|
|
|
final lane = _assignLane(entries, group.id, start, end);
|
|
|
|
entries.add(
|
|
TimelineEntry(
|
|
id: item.id,
|
|
groupId: group.id,
|
|
start: start,
|
|
end: end,
|
|
lane: lane,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
int _assignLane(
|
|
List<TimelineEntry> existing,
|
|
String groupId,
|
|
DateTime start,
|
|
DateTime end,
|
|
) {
|
|
final groupEntries = existing.where((e) => e.groupId == groupId);
|
|
for (var lane = 1; lane <= 100; lane++) {
|
|
final hasConflict = groupEntries.any(
|
|
(e) => e.lane == lane && e.overlaps(start, end),
|
|
);
|
|
if (!hasConflict) return lane;
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
({DateTime start, DateTime end}) _computeDomain(List<TimelineEntry> entries) {
|
|
if (entries.isEmpty) {
|
|
final now = DateTime.now();
|
|
return (start: now.subtract(const Duration(days: 30)), end: now.add(const Duration(days: 30)));
|
|
}
|
|
|
|
var earliest = entries.first.start;
|
|
var latest = entries.first.end;
|
|
for (final e in entries) {
|
|
if (e.start.isBefore(earliest)) earliest = e.start;
|
|
if (e.end.isAfter(latest)) latest = e.end;
|
|
}
|
|
|
|
// Add 10% padding on each side
|
|
final span = latest.difference(earliest);
|
|
final padding = Duration(milliseconds: (span.inMilliseconds * 0.1).round());
|
|
return (
|
|
start: earliest.subtract(padding),
|
|
end: latest.add(padding),
|
|
);
|
|
}
|
|
|
|
void _onEntryMoved(
|
|
TimelineEntry entry,
|
|
DateTime newStart,
|
|
String newGroupId,
|
|
int newLane,
|
|
) {
|
|
// Emit event to React via bridge
|
|
emitEvent('entry_moved', {
|
|
'entryId': entry.id,
|
|
'newStart': newStart.toIso8601String(),
|
|
'newGroupId': newGroupId,
|
|
'newLane': newLane,
|
|
});
|
|
|
|
// Update local state so Flutter UI reflects the move immediately
|
|
setState(() {
|
|
final duration = entry.end.difference(entry.start);
|
|
final newEnd = newStart.add(duration);
|
|
|
|
_entries = [
|
|
for (final e in _entries)
|
|
if (e.id == entry.id)
|
|
e.copyWith(
|
|
groupId: newGroupId,
|
|
start: newStart,
|
|
end: newEnd,
|
|
lane: newLane,
|
|
)
|
|
else
|
|
e,
|
|
];
|
|
});
|
|
|
|
_emitContentHeight();
|
|
}
|
|
|
|
void _emitContentHeight() {
|
|
var totalHeight = 0.0;
|
|
for (final group in _groups) {
|
|
totalHeight += ZTimelineConstants.groupHeaderHeight;
|
|
final groupEntries = _entries.where((e) => e.groupId == group.id);
|
|
var maxLane = 0;
|
|
for (final e in groupEntries) {
|
|
if (e.lane > maxLane) maxLane = e.lane;
|
|
}
|
|
final lanesCount = maxLane.clamp(0, 1000);
|
|
totalHeight += lanesCount * ZTimelineConstants.laneHeight
|
|
+ (lanesCount > 0 ? (lanesCount - 1) * ZTimelineConstants.laneVerticalSpacing : 0)
|
|
+ ZTimelineConstants.verticalOuterPadding * 2;
|
|
}
|
|
emitEvent('content_height', {'height': totalHeight});
|
|
}
|
|
|
|
String _labelForEntry(TimelineEntry entry) {
|
|
final state = _state;
|
|
if (state == null) return entry.id;
|
|
|
|
for (final group in state.timeline.groups) {
|
|
for (final item in group.items) {
|
|
if (item.id == entry.id) return item.title;
|
|
}
|
|
}
|
|
return entry.id;
|
|
}
|
|
|
|
static const _groupColors = [
|
|
Color(0xFF4285F4), // blue
|
|
Color(0xFF34A853), // green
|
|
Color(0xFFFBBC04), // yellow
|
|
Color(0xFFEA4335), // red
|
|
Color(0xFF9C27B0), // purple
|
|
Color(0xFF00BCD4), // cyan
|
|
Color(0xFFFF9800), // orange
|
|
Color(0xFF795548), // brown
|
|
];
|
|
|
|
Color _colorForEntry(TimelineEntry entry) {
|
|
final groupIndex = _groups.indexWhere((g) => g.id == entry.groupId);
|
|
if (groupIndex < 0) return _groupColors[0];
|
|
return _groupColors[groupIndex % _groupColors.length];
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final viewport = _viewport;
|
|
|
|
return MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
theme: ThemeData.dark(useMaterial3: true),
|
|
home: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: _state == null || viewport == null
|
|
? const Center(child: Text('Waiting for state...'))
|
|
: ZTimelineScope(
|
|
viewport: viewport,
|
|
child: ZTimelineInteractor(
|
|
child: ZTimelineView(
|
|
groups: _groups,
|
|
entries: _entries,
|
|
viewport: viewport,
|
|
labelBuilder: _labelForEntry,
|
|
colorBuilder: _colorForEntry,
|
|
enableDrag: true,
|
|
onEntryMoved: _onEntryMoved,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|