use latest version of timline poc
This commit is contained in:
37
packages/z-flutter/lib/bridge.dart
Normal file
37
packages/z-flutter/lib/bridge.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('window.__zendegi__')
|
||||
external _ZendegiBridge? get _bridge;
|
||||
|
||||
extension type _ZendegiBridge._(JSObject _) implements JSObject {
|
||||
external JSString getState();
|
||||
external void onEvent(JSString json);
|
||||
external set updateState(JSFunction callback);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? readInitialState() {
|
||||
final bridge = _bridge;
|
||||
if (bridge == null) return null;
|
||||
final json = bridge.getState().toDart;
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void onStateUpdated(void Function(Map<String, dynamic> state) callback) {
|
||||
final bridge = _bridge;
|
||||
if (bridge == null) return;
|
||||
bridge.updateState = ((JSString json) {
|
||||
final decoded = jsonDecode(json.toDart) as Map<String, dynamic>;
|
||||
callback(decoded);
|
||||
}).toJS;
|
||||
}
|
||||
|
||||
void emitEvent(String type, [Map<String, dynamic>? payload]) {
|
||||
final bridge = _bridge;
|
||||
if (bridge == null) return;
|
||||
final event = <String, dynamic>{'type': type};
|
||||
if (payload != null) {
|
||||
event['payload'] = payload;
|
||||
}
|
||||
bridge.onEvent(jsonEncode(event).toJS);
|
||||
}
|
||||
254
packages/z-flutter/lib/main.dart
Normal file
254
packages/z-flutter/lib/main.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
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 _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 _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];
|
||||
}
|
||||
|
||||
@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: Column(
|
||||
children: [
|
||||
const ZTimelineBreadcrumb(),
|
||||
const ZTimelineTieredHeader(),
|
||||
Expanded(
|
||||
child: ZTimelineInteractor(
|
||||
child: ZTimelineView(
|
||||
groups: _groups,
|
||||
entries: _entries,
|
||||
viewport: viewport,
|
||||
labelBuilder: _labelForEntry,
|
||||
colorBuilder: _colorForEntry,
|
||||
enableDrag: true,
|
||||
onEntryMoved: _onEntryMoved,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
packages/z-flutter/lib/state.dart
Normal file
110
packages/z-flutter/lib/state.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
// Bridge data-transfer types deserialized from JSON pushed by the React host.
|
||||
//
|
||||
// The shape is normalized: groups and items are stored as maps keyed by
|
||||
// ID, with groupOrder preserving display ordering.
|
||||
//
|
||||
// Keep in sync with `apps/web/src/lib/flutter-bridge.ts`.
|
||||
|
||||
class TimelineState {
|
||||
final TimelineData timeline;
|
||||
final Map<String, TimelineGroupData> groups;
|
||||
final Map<String, TimelineItemData> items;
|
||||
final List<String> groupOrder;
|
||||
final String? selectedItemId;
|
||||
final bool darkMode;
|
||||
|
||||
TimelineState({
|
||||
required this.timeline,
|
||||
required this.groups,
|
||||
required this.items,
|
||||
required this.groupOrder,
|
||||
this.selectedItemId,
|
||||
this.darkMode = true,
|
||||
});
|
||||
|
||||
factory TimelineState.fromJson(Map<String, dynamic> json) {
|
||||
final rawGroups = json['groups'] as Map<String, dynamic>;
|
||||
final rawItems = json['items'] as Map<String, dynamic>;
|
||||
|
||||
return TimelineState(
|
||||
timeline: TimelineData.fromJson(json['timeline'] as Map<String, dynamic>),
|
||||
groups: rawGroups.map(
|
||||
(k, v) =>
|
||||
MapEntry(k, TimelineGroupData.fromJson(v as Map<String, dynamic>)),
|
||||
),
|
||||
items: rawItems.map(
|
||||
(k, v) =>
|
||||
MapEntry(k, TimelineItemData.fromJson(v as Map<String, dynamic>)),
|
||||
),
|
||||
groupOrder: (json['groupOrder'] as List<dynamic>).cast<String>(),
|
||||
selectedItemId: json['selectedItemId'] as String?,
|
||||
darkMode: json['darkMode'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineData {
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
TimelineData({required this.id, required this.title});
|
||||
|
||||
factory TimelineData.fromJson(Map<String, dynamic> json) {
|
||||
return TimelineData(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineGroupData {
|
||||
final String id;
|
||||
final String title;
|
||||
final int sortOrder;
|
||||
|
||||
TimelineGroupData({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
factory TimelineGroupData.fromJson(Map<String, dynamic> json) {
|
||||
return TimelineGroupData(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
sortOrder: json['sortOrder'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TimelineItemData {
|
||||
final String id;
|
||||
final String groupId;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String start;
|
||||
final String? end;
|
||||
final int lane;
|
||||
|
||||
TimelineItemData({
|
||||
required this.id,
|
||||
required this.groupId,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.start,
|
||||
this.end,
|
||||
required this.lane,
|
||||
});
|
||||
|
||||
factory TimelineItemData.fromJson(Map<String, dynamic> json) {
|
||||
return TimelineItemData(
|
||||
id: json['id'] as String,
|
||||
groupId: json['groupId'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
start: json['start'] as String,
|
||||
end: json['end'] as String?,
|
||||
lane: json['lane'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user