380 lines
11 KiB
Dart
380 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import 'package:z_timeline/z_timeline.dart';
|
||
|
||
import 'bridge.dart';
|
||
import 'state.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;
|
||
bool _darkMode = true;
|
||
|
||
/// Height of the tiered header: 2 rows x 28px + 1px border.
|
||
static const double _tieredHeaderHeight = 28.0 * 2 + 1;
|
||
|
||
/// Height of the breadcrumb bar.
|
||
static const double _breadcrumbHeight = 40.0;
|
||
|
||
@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;
|
||
_darkMode = state.darkMode;
|
||
|
||
_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 _onEntrySelected(TimelineEntry entry) {
|
||
emitEvent('item_selected', {'itemId': entry.id});
|
||
}
|
||
|
||
void _onBackgroundTap() {
|
||
emitEvent('item_deselected');
|
||
}
|
||
|
||
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,
|
||
darkMode: state.darkMode,
|
||
);
|
||
_applyState(updatedState);
|
||
}
|
||
}
|
||
|
||
emitEvent('entry_moved', <String, Object?>{
|
||
'entryId': entry.id,
|
||
'newStart': newStart.toIso8601String(),
|
||
'newGroupId': newGroupId,
|
||
'newLane': newLane,
|
||
'newEnd': newEnd?.toIso8601String(),
|
||
});
|
||
}
|
||
|
||
void _onEntryResized(
|
||
TimelineEntry entry,
|
||
DateTime newStart,
|
||
DateTime newEnd,
|
||
int newLane,
|
||
) {
|
||
// 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: oldItem.groupId,
|
||
title: oldItem.title,
|
||
description: oldItem.description,
|
||
start: newStart.toIso8601String(),
|
||
end: entry.hasEnd ? newEnd.toIso8601String() : null,
|
||
lane: newLane,
|
||
);
|
||
final updatedState = TimelineState(
|
||
timeline: state.timeline,
|
||
groups: state.groups,
|
||
items: updatedItems,
|
||
groupOrder: state.groupOrder,
|
||
selectedItemId: state.selectedItemId,
|
||
darkMode: state.darkMode,
|
||
);
|
||
_applyState(updatedState);
|
||
}
|
||
}
|
||
|
||
emitEvent('entry_resized', <String, Object?>{
|
||
'entryId': entry.id,
|
||
'newStart': newStart.toIso8601String(),
|
||
'newEnd': entry.hasEnd ? newEnd.toIso8601String() : null,
|
||
'groupId': entry.groupId,
|
||
'lane': newLane,
|
||
});
|
||
}
|
||
|
||
void _emitContentHeight() {
|
||
// Start with the fixed chrome heights.
|
||
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
|
||
|
||
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];
|
||
}
|
||
|
||
static const _months = [
|
||
'Jan',
|
||
'Feb',
|
||
'Mar',
|
||
'Apr',
|
||
'May',
|
||
'Jun',
|
||
'Jul',
|
||
'Aug',
|
||
'Sep',
|
||
'Oct',
|
||
'Nov',
|
||
'Dec',
|
||
];
|
||
|
||
String _formatDate(DateTime d) {
|
||
return '${_months[d.month - 1]} ${d.day}, ${d.year}';
|
||
}
|
||
|
||
String _formatDateRange(DateTime start, DateTime? end) {
|
||
final s = _formatDate(start);
|
||
if (end == null) return s;
|
||
final e = _formatDate(end);
|
||
return s == e ? s : '$s – $e';
|
||
}
|
||
|
||
Widget? _buildPopoverContent(String entryId) {
|
||
final item = _state?.items[entryId];
|
||
if (item == null) return null;
|
||
|
||
final start = DateTime.parse(item.start);
|
||
final end = item.end != null ? DateTime.parse(item.end!) : null;
|
||
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.title,
|
||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (item.description != null && item.description!.isNotEmpty) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
item.description!,
|
||
style: const TextStyle(fontSize: 12),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
_formatDateRange(start, end),
|
||
style: TextStyle(fontSize: 11, color: Colors.grey[400]),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildCompactDate(DateTime start, DateTime end) {
|
||
return Text(
|
||
_formatDateRange(start, end),
|
||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final viewport = _viewport;
|
||
|
||
return MaterialApp(
|
||
debugShowCheckedModeBanner: false,
|
||
theme: _darkMode
|
||
? ThemeData.dark(useMaterial3: true)
|
||
: ThemeData.light(useMaterial3: true),
|
||
home: Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: _state == null || viewport == null
|
||
? const Center(child: Text('Waiting for state...'))
|
||
: ZTimelineScope(
|
||
viewport: viewport,
|
||
child: EntryPopoverOverlay(
|
||
popoverContentBuilder: _buildPopoverContent,
|
||
compactDateBuilder: _buildCompactDate,
|
||
child: Column(
|
||
children: [
|
||
const ZTimelineBreadcrumb(),
|
||
const ZTimelineTieredHeader(),
|
||
Expanded(
|
||
child: ZTimelineInteractor(
|
||
onBackgroundTap: _onBackgroundTap,
|
||
child: ZTimelineView(
|
||
groups: _groups,
|
||
entries: _entries,
|
||
viewport: viewport,
|
||
labelBuilder: _labelForEntry,
|
||
colorBuilder: _colorForEntry,
|
||
enableDrag: true,
|
||
onEntryMoved: _onEntryMoved,
|
||
onEntryResized: _onEntryResized,
|
||
onEntrySelected: _onEntrySelected,
|
||
selectedEntryId: _state?.selectedItemId,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|