Files
zendegi/packages/z-flutter/lib/main.dart
2026-03-07 14:47:42 +01:00

380 lines
11 KiB
Dart
Raw Permalink 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 '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,
),
),
),
],
),
),
),
),
);
}
}