Files
zendegi/packages/z-timeline/lib/main.dart

233 lines
6.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
final entries = _convertEntries(state);
final domain = _computeDomain(entries);
setState(() {
_state = state;
_groups = groups;
_entries = entries;
_viewport ??= TimelineViewportNotifier(
start: domain.start,
end: domain.end,
);
});
_emitContentHeight();
}
/// Build an ordered list of [TimelineGroup] using [groupOrder].
List<TimelineGroup> _convertGroups(TimelineState state) {
return [
for (final id in state.groupOrder)
if (state.groups[id] case final g?)
TimelineGroup(id: g.id, title: g.title),
];
}
/// Build a flat list of [TimelineEntry] from the normalized items map.
List<TimelineEntry> _convertEntries(TimelineState state) {
return [
for (final item in state.items.values)
() {
final start = DateTime.parse(item.start);
final end = item.end != null
? DateTime.parse(item.end!)
: start.add(const Duration(days: 1));
return TimelineEntry(
id: item.id,
groupId: item.groupId,
start: start,
end: end,
lane: item.lane,
hasEnd: item.end != null,
);
}(),
];
}
({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,
) {
final duration = entry.end.difference(entry.start);
final newEnd = entry.hasEnd ? newStart.add(duration) : null;
// Optimistic update apply locally before the host round-trips.
if (_state case final state?) {
final oldItem = state.items[entry.id];
if (oldItem != null) {
final updatedItems = Map<String, TimelineItemData>.of(state.items);
updatedItems[entry.id] = TimelineItemData(
id: oldItem.id,
groupId: newGroupId,
title: oldItem.title,
description: oldItem.description,
start: newStart.toIso8601String(),
end: newEnd?.toIso8601String(),
lane: newLane,
);
final updatedState = TimelineState(
timeline: state.timeline,
groups: state.groups,
items: updatedItems,
groupOrder: state.groupOrder,
selectedItemId: state.selectedItemId,
);
_applyState(updatedState);
}
}
emitEvent('entry_moved', <String, Object?>{
'entryId': entry.id,
'newStart': newStart.toIso8601String(),
'newGroupId': newGroupId,
'newLane': newLane,
'newEnd': newEnd?.toIso8601String(),
});
}
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});
}
/// O(1) label lookup from the normalized items map.
String _labelForEntry(TimelineEntry entry) {
return _state?.items[entry.id]?.title ?? 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,
),
),
),
),
);
}
}