use latest version of timline poc
This commit is contained in:
45
packages/z-flutter/.gitignore
vendored
Normal file
45
packages/z-flutter/.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
30
packages/z-flutter/.metadata
Normal file
30
packages/z-flutter/.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: web
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
3
packages/z-flutter/README.md
Normal file
3
packages/z-flutter/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# z_flutter
|
||||
|
||||
A new Flutter project.
|
||||
1
packages/z-flutter/analysis_options.yaml
Normal file
1
packages/z-flutter/analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
8
packages/z-flutter/package.json
Normal file
8
packages/z-flutter/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@zendegi/z-flutter",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "flutter build web --release --wasm --base-href /flutter/ && node scripts/copy-build.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared layout constants for the z_timeline package UI.
|
||||
class ZTimelineConstants {
|
||||
const ZTimelineConstants._();
|
||||
|
||||
// Heights
|
||||
static const double laneHeight = 28.0;
|
||||
static const double groupHeaderHeight = 34.0;
|
||||
|
||||
// Spacing
|
||||
static const double laneVerticalSpacing = 8.0;
|
||||
static const double verticalOuterPadding = 16.0;
|
||||
|
||||
// Event pill appearance
|
||||
static const double pillBorderRadius = 4.0;
|
||||
static const EdgeInsets pillPadding = EdgeInsets.symmetric(
|
||||
horizontal: 8.0,
|
||||
vertical: 6.0,
|
||||
);
|
||||
|
||||
// Content width
|
||||
static const double minContentWidth = 1200.0;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Type of temporal segment in breadcrumb.
|
||||
///
|
||||
/// Segments are displayed in hierarchical order: Year > Quarter > Month > Week
|
||||
enum BreadcrumbSegmentType { year, quarter, month, week }
|
||||
|
||||
/// Navigation target for breadcrumb click.
|
||||
///
|
||||
/// Defines where the viewport should navigate when a breadcrumb segment
|
||||
/// is clicked. The viewport will be centered on [centerDate] with
|
||||
/// [daysVisible] days shown.
|
||||
@immutable
|
||||
class BreadcrumbNavigationTarget {
|
||||
const BreadcrumbNavigationTarget({
|
||||
required this.centerDate,
|
||||
required this.daysVisible,
|
||||
});
|
||||
|
||||
/// Center date for the new viewport.
|
||||
final DateTime centerDate;
|
||||
|
||||
/// Number of days to show in viewport.
|
||||
final int daysVisible;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BreadcrumbNavigationTarget &&
|
||||
centerDate == other.centerDate &&
|
||||
daysVisible == other.daysVisible;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(centerDate, daysVisible);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BreadcrumbNavigationTarget(centerDate: $centerDate, daysVisible: $daysVisible)';
|
||||
}
|
||||
|
||||
/// Immutable model for a breadcrumb segment.
|
||||
///
|
||||
/// Represents a single segment in the breadcrumb trail (e.g., "2024", "Q1",
|
||||
/// "March", "W12"). Each segment knows its type, display label, navigation
|
||||
/// target, and whether it should be visible at the current zoom level.
|
||||
@immutable
|
||||
class BreadcrumbSegment {
|
||||
const BreadcrumbSegment({
|
||||
required this.type,
|
||||
required this.label,
|
||||
required this.navigationTarget,
|
||||
required this.isVisible,
|
||||
});
|
||||
|
||||
/// Type of temporal unit (year, quarter, month, week).
|
||||
final BreadcrumbSegmentType type;
|
||||
|
||||
/// Display label (e.g., "2024", "Q1-Q2", "March", "Mar-Apr").
|
||||
final String label;
|
||||
|
||||
/// Navigation target when clicked (center date and days visible).
|
||||
final BreadcrumbNavigationTarget navigationTarget;
|
||||
|
||||
/// Whether this segment should be visible at current zoom level.
|
||||
final bool isVisible;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BreadcrumbSegment &&
|
||||
type == other.type &&
|
||||
label == other.label &&
|
||||
navigationTarget == other.navigationTarget &&
|
||||
isVisible == other.isVisible;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(type, label, navigationTarget, isVisible);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'BreadcrumbSegment(type: $type, label: $label, isVisible: $isVisible)';
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'timeline_entry.dart';
|
||||
|
||||
/// Immutable state tracking during drag operations.
|
||||
///
|
||||
/// Captures the entry being dragged and its target position.
|
||||
@immutable
|
||||
class EntryDragState {
|
||||
const EntryDragState({
|
||||
required this.entryId,
|
||||
required this.originalEntry,
|
||||
required this.targetGroupId,
|
||||
required this.targetLane,
|
||||
required this.targetStart,
|
||||
});
|
||||
|
||||
/// The ID of the entry being dragged.
|
||||
final String entryId;
|
||||
|
||||
/// The original entry (for duration calculation).
|
||||
final TimelineEntry originalEntry;
|
||||
|
||||
/// The target group ID.
|
||||
final String targetGroupId;
|
||||
|
||||
/// The target lane (resolved to avoid conflicts).
|
||||
final int targetLane;
|
||||
|
||||
/// The target start time.
|
||||
final DateTime targetStart;
|
||||
|
||||
/// Calculate target end preserving the original duration.
|
||||
DateTime get targetEnd =>
|
||||
targetStart.add(originalEntry.end.difference(originalEntry.start));
|
||||
|
||||
/// The duration of the entry.
|
||||
Duration get duration => originalEntry.end.difference(originalEntry.start);
|
||||
|
||||
EntryDragState copyWith({
|
||||
String? entryId,
|
||||
TimelineEntry? originalEntry,
|
||||
String? targetGroupId,
|
||||
int? targetLane,
|
||||
DateTime? targetStart,
|
||||
}) {
|
||||
return EntryDragState(
|
||||
entryId: entryId ?? this.entryId,
|
||||
originalEntry: originalEntry ?? this.originalEntry,
|
||||
targetGroupId: targetGroupId ?? this.targetGroupId,
|
||||
targetLane: targetLane ?? this.targetLane,
|
||||
targetStart: targetStart ?? this.targetStart,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is EntryDragState &&
|
||||
other.entryId == entryId &&
|
||||
other.originalEntry == originalEntry &&
|
||||
other.targetGroupId == targetGroupId &&
|
||||
other.targetLane == targetLane &&
|
||||
other.targetStart == targetStart;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
entryId,
|
||||
originalEntry,
|
||||
targetGroupId,
|
||||
targetLane,
|
||||
targetStart,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'EntryDragState('
|
||||
'entryId: $entryId, '
|
||||
'targetGroupId: $targetGroupId, '
|
||||
'targetLane: $targetLane, '
|
||||
'targetStart: $targetStart)';
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Configuration for timeline pan/zoom interactions.
|
||||
@immutable
|
||||
class ZTimelineInteractionConfig {
|
||||
const ZTimelineInteractionConfig({
|
||||
this.zoomFactorIn = 1.1,
|
||||
this.zoomFactorOut = 0.9,
|
||||
this.keyboardPanRatio = 0.1,
|
||||
this.enablePinchZoom = true,
|
||||
this.enableMouseWheelZoom = true,
|
||||
this.enablePan = true,
|
||||
this.enableKeyboardShortcuts = true,
|
||||
this.minZoomDuration = const Duration(hours: 1),
|
||||
this.maxZoomDuration = const Duration(days: 3650),
|
||||
});
|
||||
|
||||
/// Zoom factor applied when zooming in (> 1.0 zooms in, reduces duration).
|
||||
final double zoomFactorIn;
|
||||
|
||||
/// Zoom factor applied when zooming out (< 1.0 zooms out, increases duration).
|
||||
final double zoomFactorOut;
|
||||
|
||||
/// Pan ratio for keyboard arrow keys (fraction of visible domain).
|
||||
final double keyboardPanRatio;
|
||||
|
||||
/// Enable two-finger pinch-to-zoom gesture.
|
||||
final bool enablePinchZoom;
|
||||
|
||||
/// Enable Ctrl/Cmd + mouse wheel zoom.
|
||||
final bool enableMouseWheelZoom;
|
||||
|
||||
/// Enable single-finger/mouse drag panning.
|
||||
final bool enablePan;
|
||||
|
||||
/// Enable keyboard shortcuts (arrows for pan, +/- for zoom).
|
||||
final bool enableKeyboardShortcuts;
|
||||
|
||||
/// Minimum domain duration (prevents zooming in too far).
|
||||
final Duration minZoomDuration;
|
||||
|
||||
/// Maximum domain duration (prevents zooming out too far).
|
||||
final Duration maxZoomDuration;
|
||||
|
||||
/// Default configuration.
|
||||
static const defaults = ZTimelineInteractionConfig();
|
||||
|
||||
ZTimelineInteractionConfig copyWith({
|
||||
double? zoomFactorIn,
|
||||
double? zoomFactorOut,
|
||||
double? keyboardPanRatio,
|
||||
bool? enablePinchZoom,
|
||||
bool? enableMouseWheelZoom,
|
||||
bool? enablePan,
|
||||
bool? enableKeyboardShortcuts,
|
||||
Duration? minZoomDuration,
|
||||
Duration? maxZoomDuration,
|
||||
}) {
|
||||
return ZTimelineInteractionConfig(
|
||||
zoomFactorIn: zoomFactorIn ?? this.zoomFactorIn,
|
||||
zoomFactorOut: zoomFactorOut ?? this.zoomFactorOut,
|
||||
keyboardPanRatio: keyboardPanRatio ?? this.keyboardPanRatio,
|
||||
enablePinchZoom: enablePinchZoom ?? this.enablePinchZoom,
|
||||
enableMouseWheelZoom: enableMouseWheelZoom ?? this.enableMouseWheelZoom,
|
||||
enablePan: enablePan ?? this.enablePan,
|
||||
enableKeyboardShortcuts:
|
||||
enableKeyboardShortcuts ?? this.enableKeyboardShortcuts,
|
||||
minZoomDuration: minZoomDuration ?? this.minZoomDuration,
|
||||
maxZoomDuration: maxZoomDuration ?? this.maxZoomDuration,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineInteractionConfig &&
|
||||
other.zoomFactorIn == zoomFactorIn &&
|
||||
other.zoomFactorOut == zoomFactorOut &&
|
||||
other.keyboardPanRatio == keyboardPanRatio &&
|
||||
other.enablePinchZoom == enablePinchZoom &&
|
||||
other.enableMouseWheelZoom == enableMouseWheelZoom &&
|
||||
other.enablePan == enablePan &&
|
||||
other.enableKeyboardShortcuts == enableKeyboardShortcuts &&
|
||||
other.minZoomDuration == minZoomDuration &&
|
||||
other.maxZoomDuration == maxZoomDuration;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
zoomFactorIn,
|
||||
zoomFactorOut,
|
||||
keyboardPanRatio,
|
||||
enablePinchZoom,
|
||||
enableMouseWheelZoom,
|
||||
enablePan,
|
||||
enableKeyboardShortcuts,
|
||||
minZoomDuration,
|
||||
maxZoomDuration,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Transient state for timeline interactions.
|
||||
///
|
||||
/// This is separate from viewport state as it represents UI feedback state,
|
||||
/// not the actual domain/data state.
|
||||
@immutable
|
||||
class ZTimelineInteractionState {
|
||||
const ZTimelineInteractionState({
|
||||
this.isGrabbing = false,
|
||||
this.isDraggingEntry = false,
|
||||
});
|
||||
|
||||
/// Whether the user is actively panning (for cursor feedback).
|
||||
final bool isGrabbing;
|
||||
|
||||
/// Whether an entry is being dragged (disables pan gesture).
|
||||
/// This will be used by future drag-and-drop functionality.
|
||||
final bool isDraggingEntry;
|
||||
|
||||
ZTimelineInteractionState copyWith({
|
||||
bool? isGrabbing,
|
||||
bool? isDraggingEntry,
|
||||
}) {
|
||||
return ZTimelineInteractionState(
|
||||
isGrabbing: isGrabbing ?? this.isGrabbing,
|
||||
isDraggingEntry: isDraggingEntry ?? this.isDraggingEntry,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineInteractionState &&
|
||||
other.isGrabbing == isGrabbing &&
|
||||
other.isDraggingEntry == isDraggingEntry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isGrabbing, isDraggingEntry);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'timeline_entry.dart';
|
||||
|
||||
/// Represents a projected entry on the timeline.
|
||||
/// This is used to represent an entry on the timeline in a normalized space.
|
||||
/// The startX and endX are normalized in [0, 1] and represent the position of
|
||||
/// the entry on the timeline.
|
||||
/// The widthX is the width of the entry in the normalized space.
|
||||
@immutable
|
||||
class ProjectedEntry {
|
||||
const ProjectedEntry({
|
||||
required this.entry,
|
||||
required this.startX,
|
||||
required this.endX,
|
||||
}) : assert(startX <= endX, 'Projected startX must be <= endX');
|
||||
|
||||
final TimelineEntry entry;
|
||||
final double startX; // normalized in [0, 1]
|
||||
final double endX; // normalized in [0, 1]
|
||||
|
||||
double get widthX => (endX - startX).clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(entry, startX, endX);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProjectedEntry &&
|
||||
other.entry == entry &&
|
||||
other.startX == startX &&
|
||||
other.endX == endX;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ProjectedEntry(entry: ${entry.id}, startX: $startX, endX: $endX)';
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/// Time units for tiered tick generation.
|
||||
///
|
||||
/// These units represent different granularities of time, from hours to
|
||||
/// centuries. Each unit can be used for aligning timestamps and generating
|
||||
/// tier sections.
|
||||
enum TierUnit {
|
||||
/// 1-hour intervals
|
||||
hour,
|
||||
|
||||
/// 3-hour intervals
|
||||
hour3,
|
||||
|
||||
/// 6-hour intervals
|
||||
hour6,
|
||||
|
||||
/// 1-day intervals
|
||||
day,
|
||||
|
||||
/// 2-day intervals
|
||||
day2,
|
||||
|
||||
/// 7-day (week) intervals, aligned to Sunday
|
||||
week,
|
||||
|
||||
/// 1-month intervals
|
||||
month,
|
||||
|
||||
/// 3-month (quarter) intervals
|
||||
quarter,
|
||||
|
||||
/// 1-year intervals
|
||||
year,
|
||||
|
||||
/// 2-year intervals
|
||||
year2,
|
||||
|
||||
/// 5-year intervals
|
||||
year5,
|
||||
|
||||
/// 10-year (decade) intervals
|
||||
decade,
|
||||
|
||||
/// 20-year intervals
|
||||
year20,
|
||||
|
||||
/// 100-year (century) intervals
|
||||
century,
|
||||
}
|
||||
|
||||
/// Label format options for tier sections.
|
||||
///
|
||||
/// Determines how dates are formatted for display in tier headers.
|
||||
enum TierLabelFormat {
|
||||
/// Day name with month and day number (e.g., "Wed, Mar 15")
|
||||
dayWithWeekday,
|
||||
|
||||
/// Hour with AM/PM (e.g., "9 AM")
|
||||
hourAmPm,
|
||||
|
||||
/// Day number only (e.g., "15")
|
||||
dayNumber,
|
||||
|
||||
/// Month abbreviated (e.g., "Mar")
|
||||
monthShort,
|
||||
|
||||
/// Month and year (e.g., "March 2024")
|
||||
monthLongYear,
|
||||
|
||||
/// Year only (e.g., "2024")
|
||||
yearOnly,
|
||||
}
|
||||
|
||||
/// Defines a single tier within a tier configuration.
|
||||
///
|
||||
/// Each tier configuration has two tiers - a primary (coarser) tier and
|
||||
/// a secondary (finer) tier.
|
||||
class TierDefinition {
|
||||
const TierDefinition({required this.unit, required this.format});
|
||||
|
||||
/// The time unit for this tier.
|
||||
final TierUnit unit;
|
||||
|
||||
/// The label format for sections in this tier.
|
||||
final TierLabelFormat format;
|
||||
}
|
||||
|
||||
/// Configuration for a specific zoom level.
|
||||
///
|
||||
/// Each configuration maps a range of msPerPixel values to a pair of tiers
|
||||
/// that should be displayed at that zoom level.
|
||||
class TierConfig {
|
||||
/// Creates a tier configuration.
|
||||
///
|
||||
/// The [tiers] list must contain exactly 2 tiers: primary (index 0) and
|
||||
/// secondary (index 1).
|
||||
const TierConfig({
|
||||
required this.name,
|
||||
required this.minMsPerPixel,
|
||||
required this.maxMsPerPixel,
|
||||
required this.tiers,
|
||||
});
|
||||
|
||||
/// Display name for this configuration (e.g., "hours", "days", "months").
|
||||
final String name;
|
||||
|
||||
/// Minimum msPerPixel for this configuration (inclusive).
|
||||
final double minMsPerPixel;
|
||||
|
||||
/// Maximum msPerPixel for this configuration (exclusive).
|
||||
/// Use double.infinity for the last configuration.
|
||||
final double maxMsPerPixel;
|
||||
|
||||
/// The two tiers to display: [0] is primary (top), [1] is secondary (bottom).
|
||||
final List<TierDefinition> tiers;
|
||||
}
|
||||
|
||||
/// Predefined tier configurations for all zoom levels.
|
||||
///
|
||||
/// These configurations are based on the React TimeTickService implementation.
|
||||
/// The msPerPixel ranges determine which configuration is selected based on
|
||||
/// the current viewport zoom level.
|
||||
const List<TierConfig> defaultTierConfigs = [
|
||||
// Hours: < 1 min/px
|
||||
TierConfig(
|
||||
name: 'hours',
|
||||
minMsPerPixel: 0,
|
||||
maxMsPerPixel: 60000,
|
||||
tiers: [
|
||||
TierDefinition(
|
||||
unit: TierUnit.day,
|
||||
format: TierLabelFormat.dayWithWeekday,
|
||||
),
|
||||
TierDefinition(unit: TierUnit.hour, format: TierLabelFormat.hourAmPm),
|
||||
],
|
||||
),
|
||||
|
||||
// Hours sparse: 1-3 min/px
|
||||
TierConfig(
|
||||
name: 'hours-sparse',
|
||||
minMsPerPixel: 60000,
|
||||
maxMsPerPixel: 180000,
|
||||
tiers: [
|
||||
TierDefinition(
|
||||
unit: TierUnit.day,
|
||||
format: TierLabelFormat.dayWithWeekday,
|
||||
),
|
||||
TierDefinition(unit: TierUnit.hour3, format: TierLabelFormat.hourAmPm),
|
||||
],
|
||||
),
|
||||
|
||||
// Days: 3-30 min/px
|
||||
TierConfig(
|
||||
name: 'days',
|
||||
minMsPerPixel: 180000,
|
||||
maxMsPerPixel: 1800000,
|
||||
tiers: [
|
||||
TierDefinition(
|
||||
unit: TierUnit.month,
|
||||
format: TierLabelFormat.monthLongYear,
|
||||
),
|
||||
TierDefinition(unit: TierUnit.day, format: TierLabelFormat.dayNumber),
|
||||
],
|
||||
),
|
||||
|
||||
// Days sparse: 30-90 min/px
|
||||
TierConfig(
|
||||
name: 'days-sparse',
|
||||
minMsPerPixel: 1800000,
|
||||
maxMsPerPixel: 5400000,
|
||||
tiers: [
|
||||
TierDefinition(
|
||||
unit: TierUnit.month,
|
||||
format: TierLabelFormat.monthLongYear,
|
||||
),
|
||||
TierDefinition(unit: TierUnit.day2, format: TierLabelFormat.dayNumber),
|
||||
],
|
||||
),
|
||||
|
||||
// Weeks: 1.5-4 hours/px
|
||||
TierConfig(
|
||||
name: 'weeks',
|
||||
minMsPerPixel: 5400000,
|
||||
maxMsPerPixel: 14400000,
|
||||
tiers: [
|
||||
TierDefinition(
|
||||
unit: TierUnit.month,
|
||||
format: TierLabelFormat.monthLongYear,
|
||||
),
|
||||
TierDefinition(unit: TierUnit.week, format: TierLabelFormat.dayNumber),
|
||||
],
|
||||
),
|
||||
|
||||
// Months: 4-24 hours/px
|
||||
TierConfig(
|
||||
name: 'months',
|
||||
minMsPerPixel: 14400000,
|
||||
maxMsPerPixel: 86400000,
|
||||
tiers: [
|
||||
TierDefinition(unit: TierUnit.year, format: TierLabelFormat.yearOnly),
|
||||
TierDefinition(unit: TierUnit.month, format: TierLabelFormat.monthShort),
|
||||
],
|
||||
),
|
||||
|
||||
// Months sparse: 1-3 days/px
|
||||
TierConfig(
|
||||
name: 'months-sparse',
|
||||
minMsPerPixel: 86400000,
|
||||
maxMsPerPixel: 259200000,
|
||||
tiers: [
|
||||
TierDefinition(unit: TierUnit.year, format: TierLabelFormat.yearOnly),
|
||||
TierDefinition(
|
||||
unit: TierUnit.quarter,
|
||||
format: TierLabelFormat.monthShort,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Years: 3-10 days/px
|
||||
TierConfig(
|
||||
name: 'years',
|
||||
minMsPerPixel: 259200000,
|
||||
maxMsPerPixel: 864000000,
|
||||
tiers: [
|
||||
TierDefinition(unit: TierUnit.decade, format: TierLabelFormat.yearOnly),
|
||||
TierDefinition(unit: TierUnit.year, format: TierLabelFormat.yearOnly),
|
||||
],
|
||||
),
|
||||
|
||||
// Years sparse: 10-30 days/px
|
||||
TierConfig(
|
||||
name: 'years-sparse',
|
||||
minMsPerPixel: 864000000,
|
||||
maxMsPerPixel: 2592000000,
|
||||
tiers: [
|
||||
TierDefinition(unit: TierUnit.decade, format: TierLabelFormat.yearOnly),
|
||||
TierDefinition(unit: TierUnit.year2, format: TierLabelFormat.yearOnly),
|
||||
],
|
||||
),
|
||||
|
||||
// Decades: 30-100 days/px
|
||||
TierConfig(
|
||||
name: 'decades',
|
||||
minMsPerPixel: 2592000000,
|
||||
maxMsPerPixel: 8640000000,
|
||||
tiers: [
|
||||
TierDefinition(unit: TierUnit.century, format: TierLabelFormat.yearOnly),
|
||||
TierDefinition(unit: TierUnit.decade, format: TierLabelFormat.yearOnly),
|
||||
],
|
||||
),
|
||||
|
||||
// Decades sparse: >100 days/px
|
||||
TierConfig(
|
||||
name: 'decades-sparse',
|
||||
minMsPerPixel: 8640000000,
|
||||
maxMsPerPixel: double.infinity,
|
||||
tiers: [
|
||||
TierDefinition(unit: TierUnit.century, format: TierLabelFormat.yearOnly),
|
||||
TierDefinition(unit: TierUnit.year20, format: TierLabelFormat.yearOnly),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'tier_config.dart';
|
||||
|
||||
/// Represents a single section in a tier row.
|
||||
///
|
||||
/// Each section spans a time range and displays a label. Sections are
|
||||
/// generated based on the [TierUnit] and cover the visible time domain.
|
||||
class TierSection {
|
||||
const TierSection({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.label,
|
||||
required this.unit,
|
||||
});
|
||||
|
||||
/// The start time of this section (inclusive).
|
||||
final DateTime start;
|
||||
|
||||
/// The end time of this section (exclusive, start of next section).
|
||||
final DateTime end;
|
||||
|
||||
/// The formatted label to display for this section.
|
||||
final String label;
|
||||
|
||||
/// The time unit this section represents, used for identification.
|
||||
final TierUnit unit;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TierSection(start: $start, end: $end, label: $label, unit: $unit)';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'tier_config.dart';
|
||||
import 'tier_section.dart';
|
||||
|
||||
/// A single row of tier sections.
|
||||
///
|
||||
/// Each [TieredTickData] contains two [TierRow]s - one for the primary
|
||||
/// (coarser) tier and one for the secondary (finer) tier.
|
||||
class TierRow {
|
||||
const TierRow({required this.unit, required this.sections});
|
||||
|
||||
/// The time unit for this tier row.
|
||||
final TierUnit unit;
|
||||
|
||||
/// The sections in this tier row, ordered by time.
|
||||
final List<TierSection> sections;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TierRow(unit: $unit, sections: ${sections.length} sections)';
|
||||
}
|
||||
|
||||
/// Complete tier data for the visible time range.
|
||||
///
|
||||
/// Generated by [TieredTickService.generateTiers] based on the viewport
|
||||
/// domain and zoom level. Contains exactly two tier rows.
|
||||
class TieredTickData {
|
||||
const TieredTickData({required this.configName, required this.tiers})
|
||||
: assert(tiers.length == 2, 'TieredTickData must have exactly 2 tiers');
|
||||
|
||||
/// The name of the tier configuration used (e.g., "hours", "days", "months").
|
||||
final String configName;
|
||||
|
||||
/// The two tier rows: [0] is primary (top), [1] is secondary (bottom).
|
||||
final List<TierRow> tiers;
|
||||
|
||||
/// The primary (top) tier row with coarser time units.
|
||||
TierRow get primaryTier => tiers[0];
|
||||
|
||||
/// The secondary (bottom) tier row with finer time units.
|
||||
TierRow get secondaryTier => tiers[1];
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TieredTickData(configName: $configName, tiers: ${tiers.length})';
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineEntry {
|
||||
TimelineEntry({
|
||||
required this.id,
|
||||
required this.groupId,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.lane,
|
||||
this.hasEnd = true,
|
||||
}) : assert(!end.isBefore(start), 'Entry end must be on/after start');
|
||||
|
||||
final String id;
|
||||
final String groupId;
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
final int lane; // provided by consumer for stacking
|
||||
final bool hasEnd; // false for point-events (end is synthetic)
|
||||
|
||||
bool overlaps(DateTime a, DateTime b) {
|
||||
return !(end.isBefore(a) || start.isAfter(b));
|
||||
}
|
||||
|
||||
TimelineEntry copyWith({
|
||||
String? id,
|
||||
String? groupId,
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
int? lane,
|
||||
bool? hasEnd,
|
||||
}) {
|
||||
return TimelineEntry(
|
||||
id: id ?? this.id,
|
||||
groupId: groupId ?? this.groupId,
|
||||
start: start ?? this.start,
|
||||
end: end ?? this.end,
|
||||
lane: lane ?? this.lane,
|
||||
hasEnd: hasEnd ?? this.hasEnd,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, groupId, start, end, lane, hasEnd);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is TimelineEntry &&
|
||||
other.id == id &&
|
||||
other.groupId == groupId &&
|
||||
other.start == start &&
|
||||
other.end == end &&
|
||||
other.lane == lane &&
|
||||
other.hasEnd == hasEnd;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane, hasEnd: $hasEnd)';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineGroup {
|
||||
const TimelineGroup({required this.id, required this.title});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
TimelineGroup copyWith({String? id, String? title}) {
|
||||
return TimelineGroup(id: id ?? this.id, title: title ?? this.title);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, title);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is TimelineGroup && other.id == id && other.title == title;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'TimelineGroup(id: $id, title: $title)';
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/// Zoom level determined by visible days in viewport.
|
||||
///
|
||||
/// Each level corresponds to a different temporal granularity for the
|
||||
/// breadcrumb display. As users zoom in, more granular segments appear.
|
||||
enum ZoomLevel {
|
||||
/// ≤14 days visible - shows Year, Quarter, Month, Week
|
||||
days,
|
||||
|
||||
/// ≤60 days visible - shows Year, Quarter, Month
|
||||
weeks,
|
||||
|
||||
/// ≤180 days visible - shows Year, Quarter
|
||||
months,
|
||||
|
||||
/// ≤730 days visible - shows Year only
|
||||
quarters,
|
||||
|
||||
/// >730 days visible - shows Year only
|
||||
years;
|
||||
|
||||
/// Display label for the zoom level indicator.
|
||||
String get label => name;
|
||||
}
|
||||
|
||||
/// Threshold constants for zoom level determination.
|
||||
///
|
||||
/// These values define the boundaries between zoom levels based on
|
||||
/// the number of days visible in the viewport.
|
||||
class ZoomLevelThresholds {
|
||||
const ZoomLevelThresholds._();
|
||||
|
||||
/// Maximum days for "days" zoom level (shows week segment)
|
||||
static const int daysThreshold = 14;
|
||||
|
||||
/// Maximum days for "weeks" zoom level (shows month segment)
|
||||
static const int weeksThreshold = 60;
|
||||
|
||||
/// Maximum days for "months" zoom level (shows quarter segment)
|
||||
static const int monthsThreshold = 180;
|
||||
|
||||
/// Maximum days for "quarters" zoom level (shows year segment)
|
||||
static const int quartersThreshold = 730;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import '../models/breadcrumb_segment.dart';
|
||||
import '../models/zoom_level.dart';
|
||||
|
||||
/// Pure static functions for breadcrumb calculations.
|
||||
///
|
||||
/// This service handles all logic for determining zoom levels, generating
|
||||
/// segment labels, and calculating navigation targets. All methods are
|
||||
/// pure functions with no side effects, following the same pattern as
|
||||
/// [LayoutCoordinateService] and [TimeScaleService].
|
||||
class BreadcrumbService {
|
||||
const BreadcrumbService._();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Zoom Level Detection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Calculate visible days from viewport domain.
|
||||
///
|
||||
/// Returns the number of whole days between [start] and [end].
|
||||
static int calculateVisibleDays(DateTime start, DateTime end) {
|
||||
return end.difference(start).inDays;
|
||||
}
|
||||
|
||||
/// Determine zoom level from visible days.
|
||||
///
|
||||
/// Uses [ZoomLevelThresholds] to map days to the appropriate zoom level.
|
||||
static ZoomLevel determineZoomLevel(int visibleDays) {
|
||||
if (visibleDays <= ZoomLevelThresholds.daysThreshold) return ZoomLevel.days;
|
||||
if (visibleDays <= ZoomLevelThresholds.weeksThreshold) {
|
||||
return ZoomLevel.weeks;
|
||||
}
|
||||
if (visibleDays <= ZoomLevelThresholds.monthsThreshold) {
|
||||
return ZoomLevel.months;
|
||||
}
|
||||
if (visibleDays <= ZoomLevelThresholds.quartersThreshold) {
|
||||
return ZoomLevel.quarters;
|
||||
}
|
||||
return ZoomLevel.years;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Segment Visibility
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Determine if year segment is visible.
|
||||
///
|
||||
/// Year is always visible regardless of zoom level.
|
||||
static bool isYearVisible(ZoomLevel level) => true;
|
||||
|
||||
/// Determine if quarter segment is visible.
|
||||
///
|
||||
/// Visible at all levels except "years" (>730 days).
|
||||
static bool isQuarterVisible(ZoomLevel level) {
|
||||
return level != ZoomLevel.years;
|
||||
}
|
||||
|
||||
/// Determine if month segment is visible.
|
||||
///
|
||||
/// Visible at "days", "weeks", and "months" levels (≤180 days).
|
||||
static bool isMonthVisible(ZoomLevel level) {
|
||||
return level == ZoomLevel.days ||
|
||||
level == ZoomLevel.weeks ||
|
||||
level == ZoomLevel.months;
|
||||
}
|
||||
|
||||
/// Determine if week segment is visible.
|
||||
///
|
||||
/// Only visible at "days" level (≤14 days).
|
||||
static bool isWeekVisible(ZoomLevel level) {
|
||||
return level == ZoomLevel.days;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Label Generation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate year label.
|
||||
///
|
||||
/// Returns single year if both dates are in the same year (e.g., "2024"),
|
||||
/// or a range if they span years (e.g., "2024-2025").
|
||||
static String generateYearLabel(DateTime start, DateTime end) {
|
||||
if (start.year == end.year) {
|
||||
return '${start.year}';
|
||||
}
|
||||
return '${start.year}-${end.year}';
|
||||
}
|
||||
|
||||
/// Generate quarter label.
|
||||
///
|
||||
/// Returns single quarter if both dates are in the same quarter and year
|
||||
/// (e.g., "Q1"), or a range if they span quarters (e.g., "Q1-Q2").
|
||||
static String generateQuarterLabel(DateTime start, DateTime end) {
|
||||
final startQ = _getQuarter(start);
|
||||
final endQ = _getQuarter(end);
|
||||
if (startQ == endQ && start.year == end.year) {
|
||||
return 'Q$startQ';
|
||||
}
|
||||
return 'Q$startQ-Q$endQ';
|
||||
}
|
||||
|
||||
/// Generate month label.
|
||||
///
|
||||
/// Returns full month name if both dates are in the same month and year
|
||||
/// (e.g., "March"), or abbreviated range if they span months (e.g., "Mar-Apr").
|
||||
static String generateMonthLabel(DateTime start, DateTime end) {
|
||||
if (start.month == end.month && start.year == end.year) {
|
||||
return _getMonthName(start.month);
|
||||
}
|
||||
return '${_getMonthAbbrev(start.month)}-${_getMonthAbbrev(end.month)}';
|
||||
}
|
||||
|
||||
/// Generate week label.
|
||||
///
|
||||
/// Returns single week if both dates are in the same ISO week
|
||||
/// (e.g., "W12"), or a range if they span weeks (e.g., "W12-W14").
|
||||
static String generateWeekLabel(DateTime start, DateTime end) {
|
||||
final startWeek = _getWeekNumber(start);
|
||||
final endWeek = _getWeekNumber(end);
|
||||
if (startWeek == endWeek) {
|
||||
return 'W$startWeek';
|
||||
}
|
||||
return 'W$startWeek-W$endWeek';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Navigation Target Calculation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Calculate navigation target for year click.
|
||||
///
|
||||
/// Centers on July 1st of the relevant year with 365 days visible.
|
||||
static BreadcrumbNavigationTarget calculateYearTarget(
|
||||
DateTime viewportCenter,
|
||||
) {
|
||||
final centerDate = DateTime(viewportCenter.year, 7, 1);
|
||||
return BreadcrumbNavigationTarget(centerDate: centerDate, daysVisible: 365);
|
||||
}
|
||||
|
||||
/// Calculate navigation target for quarter click.
|
||||
///
|
||||
/// Centers on the 15th of the middle month of the quarter with 90 days visible.
|
||||
/// Q1 (Jan-Mar) → Feb 15, Q2 (Apr-Jun) → May 15,
|
||||
/// Q3 (Jul-Sep) → Aug 15, Q4 (Oct-Dec) → Nov 15.
|
||||
static BreadcrumbNavigationTarget calculateQuarterTarget(
|
||||
DateTime viewportCenter,
|
||||
) {
|
||||
final quarter = _getQuarter(viewportCenter);
|
||||
// Middle month: Q1→Feb(2), Q2→May(5), Q3→Aug(8), Q4→Nov(11)
|
||||
final middleMonth = (quarter - 1) * 3 + 2;
|
||||
final centerDate = DateTime(viewportCenter.year, middleMonth, 15);
|
||||
return BreadcrumbNavigationTarget(centerDate: centerDate, daysVisible: 90);
|
||||
}
|
||||
|
||||
/// Calculate navigation target for month click.
|
||||
///
|
||||
/// Centers on the 15th of the month with 30 days visible.
|
||||
static BreadcrumbNavigationTarget calculateMonthTarget(
|
||||
DateTime viewportCenter,
|
||||
) {
|
||||
final centerDate = DateTime(viewportCenter.year, viewportCenter.month, 15);
|
||||
return BreadcrumbNavigationTarget(centerDate: centerDate, daysVisible: 30);
|
||||
}
|
||||
|
||||
/// Calculate navigation target for week click.
|
||||
///
|
||||
/// Centers on the viewport center with 7 days visible.
|
||||
static BreadcrumbNavigationTarget calculateWeekTarget(
|
||||
DateTime viewportCenter,
|
||||
) {
|
||||
return BreadcrumbNavigationTarget(
|
||||
centerDate: viewportCenter,
|
||||
daysVisible: 7,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Full Segment Calculation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Calculate all breadcrumb segments for the current viewport.
|
||||
///
|
||||
/// Returns segments in hierarchical order: Year > Quarter > Month > Week.
|
||||
/// Each segment includes a visibility flag for animation control.
|
||||
///
|
||||
/// The [start] and [end] parameters define the visible time domain.
|
||||
static List<BreadcrumbSegment> calculateSegments({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) {
|
||||
final visibleDays = calculateVisibleDays(start, end);
|
||||
final zoomLevel = determineZoomLevel(visibleDays);
|
||||
final center = start.add(Duration(days: visibleDays ~/ 2));
|
||||
|
||||
return [
|
||||
BreadcrumbSegment(
|
||||
type: BreadcrumbSegmentType.year,
|
||||
label: generateYearLabel(start, end),
|
||||
navigationTarget: calculateYearTarget(center),
|
||||
isVisible: isYearVisible(zoomLevel),
|
||||
),
|
||||
BreadcrumbSegment(
|
||||
type: BreadcrumbSegmentType.quarter,
|
||||
label: generateQuarterLabel(start, end),
|
||||
navigationTarget: calculateQuarterTarget(center),
|
||||
isVisible: isQuarterVisible(zoomLevel),
|
||||
),
|
||||
BreadcrumbSegment(
|
||||
type: BreadcrumbSegmentType.month,
|
||||
label: generateMonthLabel(start, end),
|
||||
navigationTarget: calculateMonthTarget(center),
|
||||
isVisible: isMonthVisible(zoomLevel),
|
||||
),
|
||||
BreadcrumbSegment(
|
||||
type: BreadcrumbSegmentType.week,
|
||||
label: generateWeekLabel(start, end),
|
||||
navigationTarget: calculateWeekTarget(center),
|
||||
isVisible: isWeekVisible(zoomLevel),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Calculate the domain (start, end) from a navigation target.
|
||||
///
|
||||
/// Used when user clicks a breadcrumb segment to navigate.
|
||||
/// Returns a record with the new viewport start and end dates.
|
||||
static ({DateTime start, DateTime end}) calculateDomainFromTarget(
|
||||
BreadcrumbNavigationTarget target,
|
||||
) {
|
||||
final halfDays = target.daysVisible ~/ 2;
|
||||
final start = target.centerDate.subtract(Duration(days: halfDays));
|
||||
final end = target.centerDate.add(
|
||||
Duration(days: target.daysVisible - halfDays),
|
||||
);
|
||||
return (start: start, end: end);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Private Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Get quarter number (1-4) from a date.
|
||||
static int _getQuarter(DateTime date) => ((date.month - 1) ~/ 3) + 1;
|
||||
|
||||
/// Get ISO week number from a date.
|
||||
///
|
||||
/// Simplified calculation - returns week of year (1-53).
|
||||
static int _getWeekNumber(DateTime date) {
|
||||
final firstDayOfYear = DateTime(date.year, 1, 1);
|
||||
final daysDiff = date.difference(firstDayOfYear).inDays;
|
||||
return (daysDiff / 7).floor() + 1;
|
||||
}
|
||||
|
||||
/// Get full month name.
|
||||
static String _getMonthName(int month) {
|
||||
const names = [
|
||||
'',
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
return names[month];
|
||||
}
|
||||
|
||||
/// Get abbreviated month name.
|
||||
static String _getMonthAbbrev(int month) {
|
||||
const names = [
|
||||
'',
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
return names[month];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/timeline_entry.dart';
|
||||
|
||||
/// Result of resolving placement for an entry.
|
||||
@immutable
|
||||
class ResolvedPlacement {
|
||||
const ResolvedPlacement({required this.lane, required this.end});
|
||||
|
||||
/// The resolved lane (collision-free).
|
||||
final int lane;
|
||||
|
||||
/// The calculated end time (preserving duration).
|
||||
final DateTime end;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ResolvedPlacement && other.lane == lane && other.end == end;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(lane, end);
|
||||
|
||||
@override
|
||||
String toString() => 'ResolvedPlacement(lane: $lane, end: $end)';
|
||||
}
|
||||
|
||||
/// Pure utility for entry placement validation and resolution.
|
||||
///
|
||||
/// Handles collision detection and lane assignment for drag-and-drop
|
||||
/// operations.
|
||||
class EntryPlacementService {
|
||||
const EntryPlacementService._();
|
||||
|
||||
/// Check if a position is available for an entry.
|
||||
///
|
||||
/// Returns true if no other entries occupy the same lane and time range.
|
||||
/// The entry being dragged (identified by [entryId]) is excluded from
|
||||
/// collision detection.
|
||||
static bool isPositionAvailable({
|
||||
required String entryId,
|
||||
required String targetGroupId,
|
||||
required int targetLane,
|
||||
required DateTime targetStart,
|
||||
required DateTime targetEnd,
|
||||
required List<TimelineEntry> existingEntries,
|
||||
}) {
|
||||
// Filter entries in target group and lane, excluding dragged entry
|
||||
final conflicting = existingEntries.where(
|
||||
(e) =>
|
||||
e.id != entryId && e.groupId == targetGroupId && e.lane == targetLane,
|
||||
);
|
||||
|
||||
// Check for time overlaps
|
||||
for (final existing in conflicting) {
|
||||
if (_entriesOverlap(
|
||||
targetStart,
|
||||
targetEnd,
|
||||
existing.start,
|
||||
existing.end,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Find the nearest available lane from target.
|
||||
///
|
||||
/// Returns the target lane if available, otherwise searches in expanding
|
||||
/// radius: target, target+1, target-1, target+2, target-2, etc.
|
||||
static int findNearestAvailableLane({
|
||||
required String entryId,
|
||||
required String targetGroupId,
|
||||
required int targetLane,
|
||||
required DateTime targetStart,
|
||||
required DateTime targetEnd,
|
||||
required List<TimelineEntry> existingEntries,
|
||||
}) {
|
||||
// Check target lane first
|
||||
if (isPositionAvailable(
|
||||
entryId: entryId,
|
||||
targetGroupId: targetGroupId,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
targetEnd: targetEnd,
|
||||
existingEntries: existingEntries,
|
||||
)) {
|
||||
return targetLane;
|
||||
}
|
||||
|
||||
// Search in expanding radius: +1, -1, +2, -2, ...
|
||||
var offset = 1;
|
||||
while (offset <= 100) {
|
||||
// Try lane above
|
||||
final upperLane = targetLane + offset;
|
||||
if (isPositionAvailable(
|
||||
entryId: entryId,
|
||||
targetGroupId: targetGroupId,
|
||||
targetLane: upperLane,
|
||||
targetStart: targetStart,
|
||||
targetEnd: targetEnd,
|
||||
existingEntries: existingEntries,
|
||||
)) {
|
||||
return upperLane;
|
||||
}
|
||||
|
||||
// Try lane below (if valid)
|
||||
final lowerLane = targetLane - offset;
|
||||
if (lowerLane >= 1 &&
|
||||
isPositionAvailable(
|
||||
entryId: entryId,
|
||||
targetGroupId: targetGroupId,
|
||||
targetLane: lowerLane,
|
||||
targetStart: targetStart,
|
||||
targetEnd: targetEnd,
|
||||
existingEntries: existingEntries,
|
||||
)) {
|
||||
return lowerLane;
|
||||
}
|
||||
|
||||
offset++;
|
||||
}
|
||||
|
||||
// Fallback: new lane above all existing
|
||||
final maxLane = existingEntries
|
||||
.where((e) => e.groupId == targetGroupId)
|
||||
.fold<int>(0, (max, e) => e.lane > max ? e.lane : max);
|
||||
return maxLane + 1;
|
||||
}
|
||||
|
||||
/// Resolve final placement with collision avoidance.
|
||||
///
|
||||
/// Returns a [ResolvedPlacement] with the calculated lane (avoiding
|
||||
/// collisions) and end time (preserving the original entry's duration).
|
||||
static ResolvedPlacement resolvePlacement({
|
||||
required TimelineEntry entry,
|
||||
required String targetGroupId,
|
||||
required int targetLane,
|
||||
required DateTime targetStart,
|
||||
required List<TimelineEntry> existingEntries,
|
||||
}) {
|
||||
final duration = entry.end.difference(entry.start);
|
||||
final targetEnd = targetStart.add(duration);
|
||||
|
||||
final lane = findNearestAvailableLane(
|
||||
entryId: entry.id,
|
||||
targetGroupId: targetGroupId,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
targetEnd: targetEnd,
|
||||
existingEntries: existingEntries,
|
||||
);
|
||||
|
||||
return ResolvedPlacement(lane: lane, end: targetEnd);
|
||||
}
|
||||
|
||||
/// Check if two time ranges overlap.
|
||||
static bool _entriesOverlap(
|
||||
DateTime s1,
|
||||
DateTime e1,
|
||||
DateTime s2,
|
||||
DateTime e2,
|
||||
) {
|
||||
return s1.isBefore(e2) && e1.isAfter(s2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import '../constants.dart';
|
||||
|
||||
/// Handles ALL coordinate transformations for timeline layout.
|
||||
///
|
||||
/// This service centralizes position calculations to ensure consistency
|
||||
/// between different components (pills, ghost overlays, drop targets).
|
||||
///
|
||||
/// ## Coordinate Spaces
|
||||
///
|
||||
/// The timeline uses two coordinate spaces:
|
||||
///
|
||||
/// 1. **Normalized** `[0.0, 1.0]`: Position relative to the time domain.
|
||||
/// Used by [TimeScaleService] and stored in [ProjectedEntry].
|
||||
///
|
||||
/// 2. **Widget** `[0.0, contentWidth]`: Pixel space inside the timeline.
|
||||
/// What gets passed to [Positioned] widgets.
|
||||
///
|
||||
/// All positioned elements (pills, ghost overlay, drop targets) share the
|
||||
/// same coordinate space inside the Padding widget.
|
||||
class LayoutCoordinateService {
|
||||
const LayoutCoordinateService._();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Width Calculations
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Calculate item width (pill or ghost) from normalized width.
|
||||
///
|
||||
/// This is the CANONICAL width calculation. Both pills and ghosts use this
|
||||
/// to ensure they have identical widths.
|
||||
///
|
||||
/// The horizontal padding is subtracted to create visual spacing between
|
||||
/// adjacent items.
|
||||
static double calculateItemWidth({
|
||||
required double normalizedWidth,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
return (normalizedWidth * contentWidth)
|
||||
.clamp(0.0, double.infinity);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Horizontal Position Transformations
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert normalized X position to widget X coordinate.
|
||||
///
|
||||
/// Adds horizontal padding to create left margin for items.
|
||||
/// Used by both pills and ghost overlay for left positioning.
|
||||
static double normalizedToWidgetX({
|
||||
required double normalizedX,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
return normalizedX * contentWidth;
|
||||
}
|
||||
|
||||
/// Convert widget X coordinate to normalized position.
|
||||
///
|
||||
/// Used by drop targets to convert cursor position back to normalized
|
||||
/// space for time calculation.
|
||||
static double widgetXToNormalized({
|
||||
required double widgetX,
|
||||
required double contentWidth,
|
||||
}) {
|
||||
final adjustedX =
|
||||
(widgetX).clamp(0.0, contentWidth);
|
||||
return contentWidth == 0 ? 0.0 : adjustedX / contentWidth;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Vertical Position Transformations
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Calculate Y position for a lane.
|
||||
///
|
||||
/// Used by all positioned elements (pills, ghost overlay) within the
|
||||
/// timeline Stack. Lanes are 1-indexed, so lane 1 starts at Y=0.
|
||||
static double laneToY({
|
||||
required int lane,
|
||||
required double laneHeight,
|
||||
}) {
|
||||
return (lane - 1) * (laneHeight + ZTimelineConstants.laneVerticalSpacing);
|
||||
}
|
||||
|
||||
/// Convert Y coordinate to lane number.
|
||||
///
|
||||
/// Used by drop targets to determine which lane the cursor is over.
|
||||
/// The Y coordinate should be relative to the timeline Stack.
|
||||
static int yToLane({
|
||||
required double y,
|
||||
required double laneHeight,
|
||||
}) {
|
||||
final laneStep = laneHeight + ZTimelineConstants.laneVerticalSpacing;
|
||||
return (y / laneStep).floor() + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import '../models/tier_config.dart';
|
||||
import '../models/tier_section.dart';
|
||||
import '../models/tiered_tick_data.dart';
|
||||
|
||||
/// Pure static functions for tiered tick generation.
|
||||
///
|
||||
/// This service handles all logic for generating multi-tier timeline headers.
|
||||
/// It provides methods for aligning timestamps to unit boundaries, calculating
|
||||
/// unit ends, formatting labels, and generating complete tier data.
|
||||
///
|
||||
/// All methods are pure functions with no side effects.
|
||||
class TieredTickService {
|
||||
const TieredTickService._();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Constants
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Fixed epoch for deterministic alignment (Jan 1, 2000 00:00:00 UTC).
|
||||
///
|
||||
/// This epoch is used to ensure consistent alignment across different
|
||||
/// time ranges, particularly for units that don't have natural boundaries
|
||||
/// (like 3-hour or 2-day intervals).
|
||||
static final DateTime epoch = DateTime.utc(2000, 1, 1);
|
||||
|
||||
/// Milliseconds in one hour.
|
||||
static const int msHour = 3600000;
|
||||
|
||||
/// Milliseconds in one day.
|
||||
static const int msDay = 86400000;
|
||||
|
||||
/// Milliseconds in one week.
|
||||
static const int msWeek = msDay * 7;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tier Config Selection
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Select the appropriate tier configuration based on zoom level.
|
||||
///
|
||||
/// The [msPerPixel] value determines which configuration is selected.
|
||||
/// Lower values mean more zoomed in (showing finer time units),
|
||||
/// higher values mean more zoomed out (showing coarser time units).
|
||||
static TierConfig selectTierConfig(double msPerPixel) {
|
||||
for (final config in defaultTierConfigs) {
|
||||
if (msPerPixel >= config.minMsPerPixel &&
|
||||
msPerPixel < config.maxMsPerPixel) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
// Fallback to the last (most zoomed out) configuration
|
||||
return defaultTierConfigs.last;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Time Alignment
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Align a timestamp to a unit boundary.
|
||||
///
|
||||
/// Returns the start of the time period containing [time] for the given
|
||||
/// [unit]. For example, aligning to [TierUnit.day] returns midnight UTC
|
||||
/// of that day.
|
||||
static DateTime alignToUnit(DateTime time, TierUnit unit) {
|
||||
final timeMs = time.toUtc().millisecondsSinceEpoch;
|
||||
final epochMs = epoch.millisecondsSinceEpoch;
|
||||
|
||||
switch (unit) {
|
||||
case TierUnit.hour:
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
epochMs + ((timeMs - epochMs) ~/ msHour) * msHour,
|
||||
isUtc: true,
|
||||
);
|
||||
|
||||
case TierUnit.hour3:
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
epochMs + ((timeMs - epochMs) ~/ (msHour * 3)) * msHour * 3,
|
||||
isUtc: true,
|
||||
);
|
||||
|
||||
case TierUnit.hour6:
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
epochMs + ((timeMs - epochMs) ~/ (msHour * 6)) * msHour * 6,
|
||||
isUtc: true,
|
||||
);
|
||||
|
||||
case TierUnit.day:
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
epochMs + ((timeMs - epochMs) ~/ msDay) * msDay,
|
||||
isUtc: true,
|
||||
);
|
||||
|
||||
case TierUnit.day2:
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
epochMs + ((timeMs - epochMs) ~/ (msDay * 2)) * msDay * 2,
|
||||
isUtc: true,
|
||||
);
|
||||
|
||||
case TierUnit.week:
|
||||
// Weeks start on Sunday. Calculate days to go back to reach Sunday.
|
||||
final date = time.toUtc();
|
||||
// Dart weekday: Monday=1, Sunday=7
|
||||
// To get to previous Sunday: if Sunday, go back 0; if Monday, go back 1; etc.
|
||||
final daysBack = date.weekday % 7; // Sunday(7)%7=0, Monday(1)%7=1, etc.
|
||||
return DateTime.utc(date.year, date.month, date.day - daysBack);
|
||||
|
||||
case TierUnit.month:
|
||||
final date = time.toUtc();
|
||||
return DateTime.utc(date.year, date.month);
|
||||
|
||||
case TierUnit.quarter:
|
||||
final date = time.toUtc();
|
||||
final quarterMonth = ((date.month - 1) ~/ 3) * 3 + 1;
|
||||
return DateTime.utc(date.year, quarterMonth);
|
||||
|
||||
case TierUnit.year:
|
||||
return DateTime.utc(time.toUtc().year);
|
||||
|
||||
case TierUnit.year2:
|
||||
final year = time.toUtc().year;
|
||||
return DateTime.utc((year ~/ 2) * 2);
|
||||
|
||||
case TierUnit.year5:
|
||||
final year = time.toUtc().year;
|
||||
return DateTime.utc((year ~/ 5) * 5);
|
||||
|
||||
case TierUnit.decade:
|
||||
final year = time.toUtc().year;
|
||||
return DateTime.utc((year ~/ 10) * 10);
|
||||
|
||||
case TierUnit.year20:
|
||||
final year = time.toUtc().year;
|
||||
return DateTime.utc((year ~/ 20) * 20);
|
||||
|
||||
case TierUnit.century:
|
||||
final year = time.toUtc().year;
|
||||
return DateTime.utc((year ~/ 100) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the end of a unit period (start of the next period).
|
||||
///
|
||||
/// Returns the timestamp that marks the boundary between the period
|
||||
/// containing [time] and the next period.
|
||||
static DateTime getUnitEnd(DateTime time, TierUnit unit) {
|
||||
final aligned = alignToUnit(time, unit);
|
||||
|
||||
switch (unit) {
|
||||
case TierUnit.hour:
|
||||
return aligned.add(const Duration(hours: 1));
|
||||
|
||||
case TierUnit.hour3:
|
||||
return aligned.add(const Duration(hours: 3));
|
||||
|
||||
case TierUnit.hour6:
|
||||
return aligned.add(const Duration(hours: 6));
|
||||
|
||||
case TierUnit.day:
|
||||
return aligned.add(const Duration(days: 1));
|
||||
|
||||
case TierUnit.day2:
|
||||
return aligned.add(const Duration(days: 2));
|
||||
|
||||
case TierUnit.week:
|
||||
return aligned.add(const Duration(days: 7));
|
||||
|
||||
case TierUnit.month:
|
||||
return DateTime.utc(aligned.year, aligned.month + 1);
|
||||
|
||||
case TierUnit.quarter:
|
||||
return DateTime.utc(aligned.year, aligned.month + 3);
|
||||
|
||||
case TierUnit.year:
|
||||
return DateTime.utc(aligned.year + 1);
|
||||
|
||||
case TierUnit.year2:
|
||||
return DateTime.utc(aligned.year + 2);
|
||||
|
||||
case TierUnit.year5:
|
||||
return DateTime.utc(aligned.year + 5);
|
||||
|
||||
case TierUnit.decade:
|
||||
return DateTime.utc(aligned.year + 10);
|
||||
|
||||
case TierUnit.year20:
|
||||
return DateTime.utc(aligned.year + 20);
|
||||
|
||||
case TierUnit.century:
|
||||
return DateTime.utc(aligned.year + 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Label Formatting
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Format a timestamp for display.
|
||||
///
|
||||
/// Uses the [format] to determine the output format for the given [time].
|
||||
static String formatTime(DateTime time, TierLabelFormat format) {
|
||||
final utc = time.toUtc();
|
||||
|
||||
switch (format) {
|
||||
case TierLabelFormat.dayWithWeekday:
|
||||
return '${_getWeekdayShort(utc.weekday)}, ${_getMonthShort(utc.month)} ${utc.day}';
|
||||
|
||||
case TierLabelFormat.hourAmPm:
|
||||
final hour = utc.hour;
|
||||
if (hour == 0) return '12 AM';
|
||||
if (hour == 12) return '12 PM';
|
||||
if (hour < 12) return '$hour AM';
|
||||
return '${hour - 12} PM';
|
||||
|
||||
case TierLabelFormat.dayNumber:
|
||||
return '${utc.day}';
|
||||
|
||||
case TierLabelFormat.monthShort:
|
||||
return _getMonthShort(utc.month);
|
||||
|
||||
case TierLabelFormat.monthLongYear:
|
||||
return '${_getMonthLong(utc.month)} ${utc.year}';
|
||||
|
||||
case TierLabelFormat.yearOnly:
|
||||
return '${utc.year}';
|
||||
}
|
||||
}
|
||||
|
||||
static String _getWeekdayShort(int weekday) {
|
||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
return days[weekday - 1];
|
||||
}
|
||||
|
||||
static String _getMonthShort(int month) {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
return months[month - 1];
|
||||
}
|
||||
|
||||
static String _getMonthLong(int month) {
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
return months[month - 1];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Section Generation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate sections for a single tier.
|
||||
///
|
||||
/// Creates [TierSection]s that cover the time range from [start] to [end]
|
||||
/// using the specified [unit] and [format].
|
||||
static List<TierSection> generateTierSections({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
required TierUnit unit,
|
||||
required TierLabelFormat format,
|
||||
}) {
|
||||
final sections = <TierSection>[];
|
||||
final startUtc = start.toUtc();
|
||||
final endUtc = end.toUtc();
|
||||
|
||||
// Start one period before to ensure we cover partial sections at the edge
|
||||
var current = alignToUnit(startUtc, unit);
|
||||
final oneBeforeStart =
|
||||
alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit);
|
||||
current = oneBeforeStart;
|
||||
|
||||
while (current.isBefore(endUtc)) {
|
||||
final sectionEnd = getUnitEnd(current, unit);
|
||||
|
||||
sections.add(
|
||||
TierSection(
|
||||
start: current,
|
||||
end: sectionEnd,
|
||||
label: formatTime(current, format),
|
||||
unit: unit,
|
||||
),
|
||||
);
|
||||
|
||||
current = sectionEnd;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Main Generation Method
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate all tier data for the visible range.
|
||||
///
|
||||
/// This is the main entry point for tier generation. It calculates the
|
||||
/// appropriate tier configuration based on the zoom level (derived from
|
||||
/// [widthPx] and the time span) and generates sections for both tiers.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - [start]: The start of the visible time range
|
||||
/// - [end]: The end of the visible time range
|
||||
/// - [widthPx]: The width of the viewport in pixels
|
||||
///
|
||||
/// Returns a [TieredTickData] containing two [TierRow]s.
|
||||
static TieredTickData generateTiers({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
required double widthPx,
|
||||
}) {
|
||||
assert(!end.isBefore(start), 'End date must not be before start date');
|
||||
assert(widthPx > 0, 'Width must be positive');
|
||||
|
||||
final spanMs =
|
||||
end.toUtc().millisecondsSinceEpoch -
|
||||
start.toUtc().millisecondsSinceEpoch;
|
||||
final msPerPixel = spanMs / widthPx;
|
||||
|
||||
final config = selectTierConfig(msPerPixel);
|
||||
|
||||
final tiers = <TierRow>[];
|
||||
for (final tierDef in config.tiers) {
|
||||
final sections = generateTierSections(
|
||||
start: start,
|
||||
end: end,
|
||||
unit: tierDef.unit,
|
||||
format: tierDef.format,
|
||||
);
|
||||
|
||||
tiers.add(TierRow(unit: tierDef.unit, sections: sections));
|
||||
}
|
||||
|
||||
return TieredTickData(configName: config.name, tiers: tiers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
class TimeScaleService {
|
||||
const TimeScaleService._();
|
||||
|
||||
/// Maps a time to a position in the domain.
|
||||
/// The result is a value between 0.0 and 1.0 and it is the relative position
|
||||
/// of the time in the domain.
|
||||
static double mapTimeToPosition(
|
||||
DateTime time,
|
||||
DateTime domainStart,
|
||||
DateTime domainEnd,
|
||||
) {
|
||||
final timeMs = time.millisecondsSinceEpoch.toDouble();
|
||||
final startMs = domainStart.millisecondsSinceEpoch.toDouble();
|
||||
final endMs = domainEnd.millisecondsSinceEpoch.toDouble();
|
||||
return (timeMs - startMs) / (endMs - startMs);
|
||||
}
|
||||
|
||||
/// Maps a position in the domain to a time.
|
||||
/// The result is a DateTime value and it is the time corresponding to the
|
||||
/// position in the domain.
|
||||
static DateTime mapPositionToTime(
|
||||
double position,
|
||||
DateTime domainStart,
|
||||
DateTime domainEnd,
|
||||
) {
|
||||
final startMs = domainStart.millisecondsSinceEpoch.toDouble();
|
||||
final endMs = domainEnd.millisecondsSinceEpoch.toDouble();
|
||||
final timeMs = startMs + position * (endMs - startMs);
|
||||
return DateTime.fromMillisecondsSinceEpoch(timeMs.round());
|
||||
}
|
||||
|
||||
/// Calculates the duration of the domain in milliseconds.
|
||||
static double domainDuration(DateTime domainStart, DateTime domainEnd) {
|
||||
return (domainEnd.millisecondsSinceEpoch -
|
||||
domainStart.millisecondsSinceEpoch)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
/// Calculates a new domain after applying a zoom operation.
|
||||
/// The result is a record with the new (start, end) domain.
|
||||
static ({DateTime start, DateTime end}) calculateZoomedDomain(
|
||||
DateTime domainStart,
|
||||
DateTime domainEnd, {
|
||||
required double factor,
|
||||
double focusPosition = 0.5,
|
||||
}) {
|
||||
final oldDuration = domainDuration(domainStart, domainEnd);
|
||||
final newDuration = oldDuration / factor;
|
||||
final focusTime = mapPositionToTime(focusPosition, domainStart, domainEnd);
|
||||
final newStartOffset = focusPosition * newDuration;
|
||||
final newStart = DateTime.fromMillisecondsSinceEpoch(
|
||||
(focusTime.millisecondsSinceEpoch - newStartOffset).round(),
|
||||
);
|
||||
final newEnd = DateTime.fromMillisecondsSinceEpoch(
|
||||
(focusTime.millisecondsSinceEpoch + (newDuration - newStartOffset))
|
||||
.round(),
|
||||
);
|
||||
return (start: newStart, end: newEnd);
|
||||
}
|
||||
|
||||
/// Calculates a new domain after applying a pan operation.
|
||||
/// The result is a record with the new (start, end) domain.
|
||||
static ({DateTime start, DateTime end}) calculatePannedDomain(
|
||||
DateTime domainStart,
|
||||
DateTime domainEnd, {
|
||||
required double ratio,
|
||||
}) {
|
||||
final shiftAmount = domainDuration(domainStart, domainEnd) * ratio;
|
||||
final shiftMs = shiftAmount.round();
|
||||
final newStart = DateTime.fromMillisecondsSinceEpoch(
|
||||
domainStart.millisecondsSinceEpoch + shiftMs,
|
||||
);
|
||||
final newEnd = DateTime.fromMillisecondsSinceEpoch(
|
||||
domainEnd.millisecondsSinceEpoch + shiftMs,
|
||||
);
|
||||
return (start: newStart, end: newEnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import 'time_scale_service.dart';
|
||||
|
||||
class TimelineProjectionService {
|
||||
const TimelineProjectionService();
|
||||
|
||||
Map<String, List<ProjectedEntry>> project({
|
||||
required Iterable<TimelineEntry> entries,
|
||||
required DateTime domainStart,
|
||||
required DateTime domainEnd,
|
||||
}) {
|
||||
final byGroup = <String, List<ProjectedEntry>>{};
|
||||
for (final e in entries) {
|
||||
if (e.overlaps(domainStart, domainEnd)) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
e.start.isBefore(domainStart) ? domainStart : e.start,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
).clamp(0.0, 1.0);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
e.end.isAfter(domainEnd) ? domainEnd : e.end,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
).clamp(0.0, 1.0);
|
||||
|
||||
final pe = ProjectedEntry(entry: e, startX: startX, endX: endX);
|
||||
(byGroup[e.groupId] ??= <ProjectedEntry>[]).add(pe);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep original order stable by lane then startX
|
||||
for (final list in byGroup.values) {
|
||||
list.sort((a, b) {
|
||||
final laneCmp = a.entry.lane.compareTo(b.entry.lane);
|
||||
if (laneCmp != 0) return laneCmp;
|
||||
return a.startX.compareTo(b.startX);
|
||||
});
|
||||
}
|
||||
return byGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../models/interaction_state.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
|
||||
/// Notifier for transient interaction state.
|
||||
///
|
||||
/// Separate from viewport to handle UI feedback state like cursor changes
|
||||
/// and drag-drop coordination.
|
||||
class ZTimelineInteractionNotifier extends ChangeNotifier {
|
||||
ZTimelineInteractionNotifier();
|
||||
|
||||
ZTimelineInteractionState _state = const ZTimelineInteractionState();
|
||||
EntryDragState? _dragState;
|
||||
|
||||
/// The current interaction state.
|
||||
ZTimelineInteractionState get state => _state;
|
||||
|
||||
/// The current drag state, or null if no drag is active.
|
||||
EntryDragState? get dragState => _dragState;
|
||||
|
||||
/// Whether the user is actively panning (for cursor feedback).
|
||||
bool get isGrabbing => _state.isGrabbing;
|
||||
|
||||
/// Whether an entry is being dragged (disables pan gesture).
|
||||
bool get isDraggingEntry => _state.isDraggingEntry;
|
||||
|
||||
/// Update the grabbing state.
|
||||
void setGrabbing(bool value) {
|
||||
if (_state.isGrabbing == value) return;
|
||||
_state = _state.copyWith(isGrabbing: value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update the dragging entry state.
|
||||
void setDraggingEntry(bool value) {
|
||||
if (_state.isDraggingEntry == value) return;
|
||||
_state = _state.copyWith(isDraggingEntry: value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Begin dragging an entry.
|
||||
///
|
||||
/// Sets drag state and marks [isDraggingEntry] as true.
|
||||
void beginDrag(TimelineEntry entry) {
|
||||
_dragState = EntryDragState(
|
||||
entryId: entry.id,
|
||||
originalEntry: entry,
|
||||
targetGroupId: entry.groupId,
|
||||
targetLane: entry.lane,
|
||||
targetStart: entry.start,
|
||||
);
|
||||
setDraggingEntry(true);
|
||||
}
|
||||
|
||||
/// Update the drag target position.
|
||||
///
|
||||
/// Called during drag to update where the entry would land.
|
||||
void updateDragTarget({
|
||||
required String targetGroupId,
|
||||
required int targetLane,
|
||||
required DateTime targetStart,
|
||||
}) {
|
||||
if (_dragState == null) return;
|
||||
final newState = _dragState!.copyWith(
|
||||
targetGroupId: targetGroupId,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
);
|
||||
if (newState == _dragState) return;
|
||||
_dragState = newState;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// End the drag and clear state.
|
||||
void endDrag() {
|
||||
_dragState = null;
|
||||
setDraggingEntry(false);
|
||||
}
|
||||
|
||||
/// Cancel the drag (alias for [endDrag]).
|
||||
void cancelDrag() => endDrag();
|
||||
|
||||
/// Called by drag-drop system when an entry drag starts.
|
||||
@Deprecated('Use beginDrag instead')
|
||||
void beginEntryDrag() => setDraggingEntry(true);
|
||||
|
||||
/// Called by drag-drop system when an entry drag ends.
|
||||
@Deprecated('Use endDrag instead')
|
||||
void endEntryDrag() => setDraggingEntry(false);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
|
||||
class TimelineViewportNotifier extends ChangeNotifier {
|
||||
TimelineViewportNotifier({required DateTime start, required DateTime end})
|
||||
: assert(start.isBefore(end), 'Viewport start must be before end'),
|
||||
_start = start,
|
||||
_end = end;
|
||||
|
||||
DateTime _start;
|
||||
DateTime _end;
|
||||
|
||||
DateTime get start => _start;
|
||||
DateTime get end => _end;
|
||||
|
||||
void setDomain(DateTime start, DateTime end) {
|
||||
if (!start.isBefore(end)) return;
|
||||
if (start == _start && end == _end) return;
|
||||
_start = start;
|
||||
_end = end;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void zoom(double factor, {double focusPosition = 0.5}) {
|
||||
final next = TimeScaleService.calculateZoomedDomain(
|
||||
_start,
|
||||
_end,
|
||||
factor: factor,
|
||||
focusPosition: focusPosition,
|
||||
);
|
||||
if (!next.start.isBefore(next.end)) return;
|
||||
_start = next.start;
|
||||
_end = next.end;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void pan(double ratio) {
|
||||
final next = TimeScaleService.calculatePannedDomain(
|
||||
_start,
|
||||
_end,
|
||||
ratio: ratio,
|
||||
);
|
||||
if (!next.start.isBefore(next.end)) return;
|
||||
_start = next.start;
|
||||
_end = next.end;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/breadcrumb_segment.dart';
|
||||
|
||||
/// Individual breadcrumb segment chip with fade animation.
|
||||
///
|
||||
/// Animates opacity based on [segment.isVisible]. When visible, the chip
|
||||
/// is clickable and navigates to that time period.
|
||||
///
|
||||
/// Uses [AnimatedOpacity] and [AnimatedSize] for smooth transitions when
|
||||
/// segments appear or disappear based on zoom level changes.
|
||||
class BreadcrumbSegmentChip extends StatelessWidget {
|
||||
const BreadcrumbSegmentChip({
|
||||
required this.segment,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The segment data containing label, type, and visibility.
|
||||
final BreadcrumbSegment segment;
|
||||
|
||||
/// Callback invoked when the chip is tapped.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedOpacity(
|
||||
opacity: segment.isVisible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: segment.isVisible ? _buildChip(context) : const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChip(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
segment.label,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import 'timeline_scope.dart';
|
||||
import 'timeline_view.dart';
|
||||
|
||||
/// A draggable event pill widget.
|
||||
///
|
||||
/// Renders a timeline entry as a pill that can be dragged to move it
|
||||
/// to a new position. Uses Flutter's built-in [Draggable] widget.
|
||||
class DraggableEventPill extends StatelessWidget {
|
||||
const DraggableEventPill({
|
||||
required this.entry,
|
||||
required this.laneHeight,
|
||||
required this.labelBuilder,
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ProjectedEntry entry;
|
||||
final double laneHeight;
|
||||
final EntryLabelBuilder labelBuilder;
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double contentWidth;
|
||||
final bool enableDrag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pill = _buildPill(context);
|
||||
|
||||
// Use centralized coordinate service for consistent positioning
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: entry.entry.lane,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: entry.startX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final width = LayoutCoordinateService.calculateItemWidth(
|
||||
normalizedWidth: entry.widthX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
|
||||
if (!enableDrag) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
height: laneHeight,
|
||||
child: pill,
|
||||
);
|
||||
}
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
height: laneHeight,
|
||||
child: Draggable<TimelineEntry>(
|
||||
data: entry.entry,
|
||||
onDragStarted: () {
|
||||
scope.interaction.beginDrag(entry.entry);
|
||||
},
|
||||
onDraggableCanceled: (_, __) {
|
||||
scope.interaction.cancelDrag();
|
||||
},
|
||||
onDragCompleted: () {
|
||||
// Handled by DragTarget
|
||||
},
|
||||
feedback: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.labelMedium ?? const TextStyle(),
|
||||
child: Opacity(
|
||||
opacity: 0.8,
|
||||
child: SizedBox(
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
height: laneHeight,
|
||||
child: pill,
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.3, child: pill),
|
||||
child: pill,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPill(BuildContext context) {
|
||||
final color = colorBuilder(entry.entry);
|
||||
final onColor =
|
||||
ThemeData.estimateBrightnessForColor(color) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
|
||||
return Container(
|
||||
padding: ZTimelineConstants.pillPadding,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
labelBuilder(entry.entry),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: onColor,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
|
||||
/// A semi-transparent ghost overlay showing where an entry will land.
|
||||
///
|
||||
/// Displayed during drag operations to give visual feedback about the
|
||||
/// target position.
|
||||
class GhostOverlay extends StatelessWidget {
|
||||
const GhostOverlay({
|
||||
required this.dragState,
|
||||
required this.viewport,
|
||||
required this.contentWidth,
|
||||
required this.laneHeight,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final EntryDragState dragState;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final double contentWidth;
|
||||
final double laneHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final startX = TimeScaleService.mapTimeToPosition(
|
||||
dragState.targetStart,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
final endX = TimeScaleService.mapTimeToPosition(
|
||||
dragState.targetEnd,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
|
||||
// Use centralized coordinate service to ensure ghost matches pill layout
|
||||
final left = LayoutCoordinateService.normalizedToWidgetX(
|
||||
normalizedX: startX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final width = LayoutCoordinateService.calculateItemWidth(
|
||||
normalizedWidth: endX - startX,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
final top = LayoutCoordinateService.laneToY(
|
||||
lane: dragState.targetLane,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Positioned(
|
||||
left: left.clamp(0.0, double.infinity),
|
||||
width: width.clamp(0.0, double.infinity),
|
||||
top: top,
|
||||
height: laneHeight,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primary.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(
|
||||
ZTimelineConstants.pillBorderRadius,
|
||||
),
|
||||
border: Border.all(
|
||||
color: scheme.primary.withValues(alpha: 0.6),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/timeline_group.dart';
|
||||
import '../services/entry_placement_service.dart';
|
||||
import '../services/layout_coordinate_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'timeline_scope.dart';
|
||||
import 'timeline_view.dart';
|
||||
|
||||
/// A drop target wrapper for a timeline group.
|
||||
///
|
||||
/// Wraps group lanes content and handles drag-and-drop operations.
|
||||
/// The ghost overlay is rendered by the parent widget in the same Stack.
|
||||
class GroupDropTarget extends StatelessWidget {
|
||||
const GroupDropTarget({
|
||||
required this.group,
|
||||
required this.entries,
|
||||
required this.allEntries,
|
||||
required this.viewport,
|
||||
required this.contentWidth,
|
||||
required this.laneHeight,
|
||||
required this.lanesCount,
|
||||
required this.onEntryMoved,
|
||||
required this.child,
|
||||
this.verticalOffset = 0.0,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineGroup group;
|
||||
final List<ProjectedEntry> entries;
|
||||
final List<TimelineEntry> allEntries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final double contentWidth;
|
||||
final double laneHeight;
|
||||
final int lanesCount;
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
final Widget child;
|
||||
|
||||
/// Vertical offset from the top of this widget to the top of the lanes area.
|
||||
/// Used to correctly map pointer y-coordinates to lanes when this target
|
||||
/// wraps content above the lanes (e.g. group headers).
|
||||
final double verticalOffset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
return DragTarget<TimelineEntry>(
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
return child;
|
||||
},
|
||||
onWillAcceptWithDetails: (details) => true,
|
||||
onMove: (details) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
if (renderBox == null) return;
|
||||
|
||||
final local = renderBox.globalToLocal(details.offset);
|
||||
|
||||
// Use centralized coordinate service for consistent transformations
|
||||
final ratio = LayoutCoordinateService.widgetXToNormalized(
|
||||
widgetX: local.dx,
|
||||
contentWidth: contentWidth,
|
||||
);
|
||||
|
||||
final targetStart = TimeScaleService.mapPositionToTime(
|
||||
ratio,
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
);
|
||||
|
||||
// Adjust y to account for content above the lanes (e.g. group header)
|
||||
final adjustedY = local.dy - verticalOffset;
|
||||
|
||||
final rawLane = LayoutCoordinateService.yToLane(
|
||||
y: adjustedY,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
final maxAllowedLane = (lanesCount <= 0 ? 1 : lanesCount) + 1;
|
||||
final targetLane = rawLane.clamp(1, maxAllowedLane);
|
||||
|
||||
// Resolve with collision avoidance
|
||||
final resolved = EntryPlacementService.resolvePlacement(
|
||||
entry: details.data,
|
||||
targetGroupId: group.id,
|
||||
targetLane: targetLane,
|
||||
targetStart: targetStart,
|
||||
existingEntries: allEntries,
|
||||
);
|
||||
|
||||
scope.interaction.updateDragTarget(
|
||||
targetGroupId: group.id,
|
||||
targetLane: resolved.lane,
|
||||
targetStart: targetStart,
|
||||
);
|
||||
},
|
||||
onAcceptWithDetails: (details) {
|
||||
final dragState = scope.interaction.dragState;
|
||||
if (dragState == null || onEntryMoved == null) {
|
||||
scope.interaction.endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
final resolved = EntryPlacementService.resolvePlacement(
|
||||
entry: details.data,
|
||||
targetGroupId: dragState.targetGroupId,
|
||||
targetLane: dragState.targetLane,
|
||||
targetStart: dragState.targetStart,
|
||||
existingEntries: allEntries,
|
||||
);
|
||||
|
||||
onEntryMoved!(
|
||||
details.data,
|
||||
dragState.targetStart,
|
||||
dragState.targetGroupId,
|
||||
resolved.lane,
|
||||
);
|
||||
|
||||
scope.interaction.endDrag();
|
||||
},
|
||||
onLeave: (data) {
|
||||
// Don't clear on leave - entry might move to another group
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/breadcrumb_segment.dart';
|
||||
import '../models/zoom_level.dart';
|
||||
import '../services/breadcrumb_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'breadcrumb_segment_chip.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
/// Timeline breadcrumb navigation showing temporal context.
|
||||
///
|
||||
/// Displays hierarchical segments (Year > Quarter > Month > Week) that
|
||||
/// appear/disappear based on zoom level. Clicking a segment navigates
|
||||
/// to that time period.
|
||||
///
|
||||
/// Must be used within a [ZTimelineScope], or provide a [viewport] explicitly.
|
||||
///
|
||||
/// ```dart
|
||||
/// ZTimelineScope(
|
||||
/// viewport: myViewport,
|
||||
/// child: Column(
|
||||
/// children: [
|
||||
/// const ZTimelineBreadcrumb(),
|
||||
/// Expanded(
|
||||
/// child: ZTimelineInteractor(
|
||||
/// child: ZTimelineView(...),
|
||||
/// ),
|
||||
/// ),
|
||||
/// ],
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ZTimelineBreadcrumb extends StatelessWidget {
|
||||
const ZTimelineBreadcrumb({
|
||||
super.key,
|
||||
this.viewport,
|
||||
this.showZoomIndicator = true,
|
||||
this.height = 40.0,
|
||||
this.padding = const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
});
|
||||
|
||||
/// Optional viewport override.
|
||||
///
|
||||
/// If null, uses [ZTimelineScope.of(context).viewport].
|
||||
final TimelineViewportNotifier? viewport;
|
||||
|
||||
/// Whether to show the zoom level indicator at the end.
|
||||
///
|
||||
/// Defaults to true.
|
||||
final bool showZoomIndicator;
|
||||
|
||||
/// Height of the breadcrumb bar.
|
||||
///
|
||||
/// Defaults to 40.0.
|
||||
final double height;
|
||||
|
||||
/// Padding around the breadcrumb content.
|
||||
///
|
||||
/// Defaults to horizontal 16.0.
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveViewport = viewport ?? ZTimelineScope.of(context).viewport;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: effectiveViewport,
|
||||
builder: (context, _) {
|
||||
final segments = BreadcrumbService.calculateSegments(
|
||||
start: effectiveViewport.start,
|
||||
end: effectiveViewport.end,
|
||||
);
|
||||
|
||||
final visibleDays = BreadcrumbService.calculateVisibleDays(
|
||||
effectiveViewport.start,
|
||||
effectiveViewport.end,
|
||||
);
|
||||
final zoomLevel = BreadcrumbService.determineZoomLevel(visibleDays);
|
||||
|
||||
return Container(
|
||||
height: height,
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _BreadcrumbSegmentRow(
|
||||
segments: segments,
|
||||
viewport: effectiveViewport,
|
||||
),
|
||||
),
|
||||
if (showZoomIndicator) _ZoomLevelIndicator(level: zoomLevel),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BreadcrumbSegmentRow extends StatelessWidget {
|
||||
const _BreadcrumbSegmentRow({
|
||||
required this.segments,
|
||||
required this.viewport,
|
||||
});
|
||||
|
||||
final List<BreadcrumbSegment> segments;
|
||||
final TimelineViewportNotifier viewport;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < segments.length; i++) ...[
|
||||
BreadcrumbSegmentChip(
|
||||
segment: segments[i],
|
||||
onTap: () => _handleSegmentTap(segments[i]),
|
||||
),
|
||||
if (i < segments.length - 1 &&
|
||||
segments[i].isVisible &&
|
||||
_hasVisibleSegmentAfter(segments, i))
|
||||
const _BreadcrumbSeparator(),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasVisibleSegmentAfter(List<BreadcrumbSegment> segments, int index) {
|
||||
for (int i = index + 1; i < segments.length; i++) {
|
||||
if (segments[i].isVisible) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleSegmentTap(BreadcrumbSegment segment) {
|
||||
final domain = BreadcrumbService.calculateDomainFromTarget(
|
||||
segment.navigationTarget,
|
||||
);
|
||||
viewport.setDomain(domain.start, domain.end);
|
||||
}
|
||||
}
|
||||
|
||||
class _BreadcrumbSeparator extends StatelessWidget {
|
||||
const _BreadcrumbSeparator();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomLevelIndicator extends StatelessWidget {
|
||||
const _ZoomLevelIndicator({required this.level});
|
||||
|
||||
final ZoomLevel level;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
level.label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/gestures.dart'
|
||||
show PointerScrollEvent, PointerSignalEvent;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
/// Handles pan/zoom gestures for the timeline.
|
||||
///
|
||||
/// Must be used within a [ZTimelineScope]. Supports:
|
||||
/// - Two-finger pinch-to-zoom (with focal point)
|
||||
/// - Single-finger horizontal pan
|
||||
/// - Ctrl/Cmd + mouse wheel zoom
|
||||
/// - Horizontal mouse scroll for pan
|
||||
/// - Keyboard shortcuts: arrows (pan), +/- (zoom)
|
||||
/// - Mouse cursor feedback (grab/grabbing)
|
||||
///
|
||||
/// ```dart
|
||||
/// ZTimelineScope(
|
||||
/// viewport: viewport,
|
||||
/// child: ZTimelineInteractor(
|
||||
/// child: ZTimelineView(...),
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ZTimelineInteractor extends StatefulWidget {
|
||||
const ZTimelineInteractor({
|
||||
required this.child,
|
||||
this.autofocus = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The widget to wrap with gesture handling.
|
||||
final Widget child;
|
||||
|
||||
/// Whether to automatically focus this widget for keyboard input.
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
|
||||
}
|
||||
|
||||
class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
|
||||
double _prevScaleValue = 1.0;
|
||||
Offset? _lastFocalPoint;
|
||||
late FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get _width {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
return renderBox?.size.width ?? 1.0;
|
||||
}
|
||||
|
||||
void _handleScaleStart(ScaleStartDetails details) {
|
||||
_prevScaleValue = 1.0;
|
||||
_lastFocalPoint = details.focalPoint;
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setGrabbing(true);
|
||||
}
|
||||
|
||||
void _handleScaleUpdate(ScaleUpdateDetails details) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final config = scope.config;
|
||||
final width = _width;
|
||||
|
||||
// Two-finger pinch-to-zoom
|
||||
if (details.pointerCount >= 2 && config.enablePinchZoom) {
|
||||
if (details.scale != _prevScaleValue) {
|
||||
final scaleFactor = details.scale / _prevScaleValue;
|
||||
_prevScaleValue = details.scale;
|
||||
|
||||
final focalPosition = (details.focalPoint.dx / width).clamp(0.0, 1.0);
|
||||
_performZoom(scaleFactor, focusPosition: focalPosition);
|
||||
}
|
||||
}
|
||||
// Single-finger pan
|
||||
else if (details.pointerCount == 1 &&
|
||||
config.enablePan &&
|
||||
!scope.interaction.isDraggingEntry) {
|
||||
if (_lastFocalPoint != null) {
|
||||
final diff = details.focalPoint - _lastFocalPoint!;
|
||||
final ratio = -diff.dx / width;
|
||||
|
||||
if (ratio != 0) {
|
||||
scope.viewport.pan(ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lastFocalPoint = details.focalPoint;
|
||||
}
|
||||
|
||||
void _handleScaleEnd(ScaleEndDetails details) {
|
||||
_lastFocalPoint = null;
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
scope.interaction.setGrabbing(false);
|
||||
}
|
||||
|
||||
void _handlePointerSignal(PointerSignalEvent event) {
|
||||
if (event is! PointerScrollEvent) return;
|
||||
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final config = scope.config;
|
||||
final width = _width;
|
||||
|
||||
final pressed = HardwareKeyboard.instance.logicalKeysPressed;
|
||||
final isCtrlOrMeta =
|
||||
pressed.contains(LogicalKeyboardKey.controlLeft) ||
|
||||
pressed.contains(LogicalKeyboardKey.controlRight) ||
|
||||
pressed.contains(LogicalKeyboardKey.metaLeft) ||
|
||||
pressed.contains(LogicalKeyboardKey.metaRight);
|
||||
|
||||
// Ctrl/Cmd + scroll = zoom
|
||||
if (isCtrlOrMeta && config.enableMouseWheelZoom) {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final local = renderBox?.globalToLocal(event.position) ?? Offset.zero;
|
||||
final focusPosition = (local.dx / width).clamp(0.0, 1.0);
|
||||
final factor = event.scrollDelta.dy < 0
|
||||
? config.zoomFactorIn
|
||||
: config.zoomFactorOut;
|
||||
_performZoom(factor, focusPosition: focusPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal scroll = pan
|
||||
if (config.enablePan) {
|
||||
final dx = event.scrollDelta.dx;
|
||||
if (dx == 0.0) return;
|
||||
|
||||
final ratio = dx / width;
|
||||
if (ratio != 0) {
|
||||
scope.viewport.pan(ratio);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _performZoom(double factor, {double focusPosition = 0.5}) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
final config = scope.config;
|
||||
final viewport = scope.viewport;
|
||||
|
||||
// Check limits before zooming
|
||||
final currentDuration = viewport.end.difference(viewport.start);
|
||||
final newDurationMs = (currentDuration.inMilliseconds / factor).round();
|
||||
final newDuration = Duration(milliseconds: newDurationMs);
|
||||
|
||||
// Prevent zooming in too far
|
||||
if (factor > 1 && newDuration < config.minZoomDuration) return;
|
||||
// Prevent zooming out too far
|
||||
if (factor < 1 && newDuration > config.maxZoomDuration) return;
|
||||
|
||||
viewport.zoom(factor, focusPosition: focusPosition);
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, VoidCallback> _buildKeyboardBindings(
|
||||
ZTimelineScopeData scope,
|
||||
) {
|
||||
final config = scope.config;
|
||||
|
||||
if (!config.enableKeyboardShortcuts) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
return {
|
||||
// Pan left
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () {
|
||||
if (config.enablePan) {
|
||||
scope.viewport.pan(-config.keyboardPanRatio);
|
||||
}
|
||||
},
|
||||
// Pan right
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () {
|
||||
if (config.enablePan) {
|
||||
scope.viewport.pan(config.keyboardPanRatio);
|
||||
}
|
||||
},
|
||||
// Zoom in (equals key, typically + without shift)
|
||||
const SingleActivator(LogicalKeyboardKey.equal): () {
|
||||
_performZoom(config.zoomFactorIn);
|
||||
},
|
||||
// Zoom out
|
||||
const SingleActivator(LogicalKeyboardKey.minus): () {
|
||||
_performZoom(config.zoomFactorOut);
|
||||
},
|
||||
// Zoom in (numpad +)
|
||||
const SingleActivator(LogicalKeyboardKey.numpadAdd): () {
|
||||
_performZoom(config.zoomFactorIn);
|
||||
},
|
||||
// Zoom out (numpad -)
|
||||
const SingleActivator(LogicalKeyboardKey.numpadSubtract): () {
|
||||
_performZoom(config.zoomFactorOut);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.of(context);
|
||||
|
||||
return CallbackShortcuts(
|
||||
bindings: _buildKeyboardBindings(scope),
|
||||
child: Focus(
|
||||
autofocus: widget.autofocus,
|
||||
focusNode: _focusNode,
|
||||
child: Listener(
|
||||
onPointerSignal: _handlePointerSignal,
|
||||
child: ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, child) {
|
||||
return MouseRegion(
|
||||
cursor: scope.interaction.isGrabbing
|
||||
? SystemMouseCursors.grabbing
|
||||
: SystemMouseCursors.grab,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: GestureDetector(
|
||||
onScaleStart: _handleScaleStart,
|
||||
onScaleUpdate: _handleScaleUpdate,
|
||||
onScaleEnd: _handleScaleEnd,
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/interaction_config.dart';
|
||||
import '../state/timeline_interaction_notifier.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
|
||||
/// Provides timeline viewport and interaction state to descendants.
|
||||
///
|
||||
/// Similar to Flutter's Theme or MediaQuery pattern. Wrap your timeline
|
||||
/// widgets with this scope to enable interactions.
|
||||
///
|
||||
/// ```dart
|
||||
/// ZTimelineScope(
|
||||
/// viewport: myViewportNotifier,
|
||||
/// config: const ZTimelineInteractionConfig(
|
||||
/// minZoomDuration: Duration(days: 1),
|
||||
/// ),
|
||||
/// child: ZTimelineInteractor(
|
||||
/// child: ZTimelineView(...),
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ZTimelineScope extends StatefulWidget {
|
||||
const ZTimelineScope({
|
||||
required this.viewport,
|
||||
required this.child,
|
||||
this.config = ZTimelineInteractionConfig.defaults,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The viewport notifier managing the visible time domain.
|
||||
final TimelineViewportNotifier viewport;
|
||||
|
||||
/// Configuration for interaction behavior.
|
||||
final ZTimelineInteractionConfig config;
|
||||
|
||||
/// The widget subtree that can access this scope.
|
||||
final Widget child;
|
||||
|
||||
/// Get the nearest [ZTimelineScopeData] or throw an assertion error.
|
||||
static ZTimelineScopeData of(BuildContext context) {
|
||||
final data = maybeOf(context);
|
||||
assert(
|
||||
data != null,
|
||||
'No ZTimelineScope found in context. '
|
||||
'Wrap your widget tree with ZTimelineScope.',
|
||||
);
|
||||
return data!;
|
||||
}
|
||||
|
||||
/// Get the nearest [ZTimelineScopeData] or null if not found.
|
||||
static ZTimelineScopeData? maybeOf(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<_ZTimelineScopeInherited>()
|
||||
?.data;
|
||||
}
|
||||
|
||||
@override
|
||||
State<ZTimelineScope> createState() => _ZTimelineScopeState();
|
||||
}
|
||||
|
||||
class _ZTimelineScopeState extends State<ZTimelineScope> {
|
||||
late final ZTimelineInteractionNotifier _interactionNotifier;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_interactionNotifier = ZTimelineInteractionNotifier();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_interactionNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _ZTimelineScopeInherited(
|
||||
data: ZTimelineScopeData(
|
||||
viewport: widget.viewport,
|
||||
interaction: _interactionNotifier,
|
||||
config: widget.config,
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ZTimelineScopeInherited extends InheritedWidget {
|
||||
const _ZTimelineScopeInherited({required this.data, required super.child});
|
||||
|
||||
final ZTimelineScopeData data;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_ZTimelineScopeInherited oldWidget) {
|
||||
return data != oldWidget.data;
|
||||
}
|
||||
}
|
||||
|
||||
/// Data provided by [ZTimelineScope].
|
||||
@immutable
|
||||
class ZTimelineScopeData {
|
||||
const ZTimelineScopeData({
|
||||
required this.viewport,
|
||||
required this.interaction,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
/// The viewport notifier for domain state (start/end times).
|
||||
final TimelineViewportNotifier viewport;
|
||||
|
||||
/// The interaction notifier for transient UI state (grabbing, dragging).
|
||||
final ZTimelineInteractionNotifier interaction;
|
||||
|
||||
/// Configuration for interaction behavior.
|
||||
final ZTimelineInteractionConfig config;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is ZTimelineScopeData &&
|
||||
other.viewport == viewport &&
|
||||
other.interaction == interaction &&
|
||||
other.config == config;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(viewport, interaction, config);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/tier_section.dart';
|
||||
import '../models/tiered_tick_data.dart';
|
||||
import '../services/tiered_tick_service.dart';
|
||||
import '../services/time_scale_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
/// A multi-tier timeline header that displays time sections at different
|
||||
/// granularities based on the current zoom level.
|
||||
///
|
||||
/// Renders two rows of time sections (e.g., months in the top row, days in
|
||||
/// the bottom row). The displayed units automatically adapt based on the
|
||||
/// viewport's zoom level.
|
||||
///
|
||||
/// Must be used within a [ZTimelineScope], or provide a [viewport] explicitly.
|
||||
///
|
||||
/// ```dart
|
||||
/// ZTimelineScope(
|
||||
/// viewport: myViewport,
|
||||
/// child: Column(
|
||||
/// children: [
|
||||
/// const ZTimelineTieredHeader(),
|
||||
/// Expanded(
|
||||
/// child: ZTimelineInteractor(
|
||||
/// child: ZTimelineView(...),
|
||||
/// ),
|
||||
/// ),
|
||||
/// ],
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
class ZTimelineTieredHeader extends StatelessWidget {
|
||||
const ZTimelineTieredHeader({
|
||||
super.key,
|
||||
this.viewport,
|
||||
this.tierHeight = 28.0,
|
||||
this.showConfigIndicator = false,
|
||||
});
|
||||
|
||||
/// Optional viewport override.
|
||||
///
|
||||
/// If null, uses [ZTimelineScope.of(context).viewport].
|
||||
final TimelineViewportNotifier? viewport;
|
||||
|
||||
/// Height of each tier row.
|
||||
///
|
||||
/// Total header height will be `tierHeight * 2` plus borders.
|
||||
/// Defaults to 28.0.
|
||||
final double tierHeight;
|
||||
|
||||
/// Whether to show the zoom level indicator.
|
||||
///
|
||||
/// When true, displays the current config name (e.g., "days", "months").
|
||||
/// Useful for debugging. Defaults to false.
|
||||
final bool showConfigIndicator;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveViewport = viewport ?? ZTimelineScope.of(context).viewport;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: effectiveViewport,
|
||||
builder: (context, _) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
if (width <= 0) return const SizedBox.shrink();
|
||||
|
||||
final tierData = TieredTickService.generateTiers(
|
||||
start: effectiveViewport.start,
|
||||
end: effectiveViewport.end,
|
||||
widthPx: width,
|
||||
);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showConfigIndicator)
|
||||
_ConfigIndicator(configName: tierData.configName),
|
||||
Container(
|
||||
height: tierHeight * 2 + 1, // +1 for middle border
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
size: Size(width, tierHeight * 2 + 1),
|
||||
painter: _TieredHeaderPainter(
|
||||
tierData: tierData,
|
||||
tierHeight: tierHeight,
|
||||
domainStart: effectiveViewport.start,
|
||||
domainEnd: effectiveViewport.end,
|
||||
borderColor: Theme.of(context).colorScheme.outlineVariant,
|
||||
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||
secondaryLabelColor:
|
||||
Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfigIndicator extends StatelessWidget {
|
||||
const _ConfigIndicator({required this.configName});
|
||||
|
||||
final String configName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
'Zoom: $configName',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TieredHeaderPainter extends CustomPainter {
|
||||
_TieredHeaderPainter({
|
||||
required this.tierData,
|
||||
required this.tierHeight,
|
||||
required this.domainStart,
|
||||
required this.domainEnd,
|
||||
required this.borderColor,
|
||||
required this.labelColor,
|
||||
required this.secondaryLabelColor,
|
||||
});
|
||||
|
||||
final TieredTickData tierData;
|
||||
final double tierHeight;
|
||||
final DateTime domainStart;
|
||||
final DateTime domainEnd;
|
||||
final Color borderColor;
|
||||
final Color labelColor;
|
||||
final Color secondaryLabelColor;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final borderPaint =
|
||||
Paint()
|
||||
..color = borderColor
|
||||
..strokeWidth = 1.0;
|
||||
|
||||
// Draw horizontal border between tiers
|
||||
canvas.drawLine(
|
||||
Offset(0, tierHeight),
|
||||
Offset(size.width, tierHeight),
|
||||
borderPaint,
|
||||
);
|
||||
|
||||
// Draw primary tier (top)
|
||||
_drawTier(
|
||||
canvas: canvas,
|
||||
size: size,
|
||||
sections: tierData.primaryTier.sections,
|
||||
topOffset: 0,
|
||||
height: tierHeight,
|
||||
isPrimary: true,
|
||||
borderPaint: borderPaint,
|
||||
);
|
||||
|
||||
// Draw secondary tier (bottom)
|
||||
_drawTier(
|
||||
canvas: canvas,
|
||||
size: size,
|
||||
sections: tierData.secondaryTier.sections,
|
||||
topOffset: tierHeight + 1,
|
||||
height: tierHeight,
|
||||
isPrimary: false,
|
||||
borderPaint: borderPaint,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawTier({
|
||||
required Canvas canvas,
|
||||
required Size size,
|
||||
required List<TierSection> sections,
|
||||
required double topOffset,
|
||||
required double height,
|
||||
required bool isPrimary,
|
||||
required Paint borderPaint,
|
||||
}) {
|
||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||
|
||||
for (final section in sections) {
|
||||
final leftPos = TimeScaleService.mapTimeToPosition(
|
||||
section.start,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
);
|
||||
final rightPos = TimeScaleService.mapTimeToPosition(
|
||||
section.end,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
);
|
||||
|
||||
final leftX = leftPos * size.width;
|
||||
final rightX = rightPos * size.width;
|
||||
|
||||
// Skip if completely outside viewport
|
||||
if (rightX < 0 || leftX > size.width) continue;
|
||||
|
||||
// Draw right border (vertical line at section end)
|
||||
if (rightX >= 0 && rightX <= size.width) {
|
||||
canvas.drawLine(
|
||||
Offset(rightX, topOffset),
|
||||
Offset(rightX, topOffset + height),
|
||||
borderPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate visible portion for label centering
|
||||
final visibleLeft = leftX.clamp(0.0, size.width);
|
||||
final visibleRight = rightX.clamp(0.0, size.width);
|
||||
final visibleWidth = visibleRight - visibleLeft;
|
||||
|
||||
// Only draw label if there's enough visible space
|
||||
if (visibleWidth < 20) continue;
|
||||
|
||||
// Draw label centered in visible portion
|
||||
final style = TextStyle(
|
||||
color: isPrimary ? labelColor : secondaryLabelColor,
|
||||
fontSize: isPrimary ? 12 : 11,
|
||||
fontWeight: isPrimary ? FontWeight.w500 : FontWeight.normal,
|
||||
);
|
||||
|
||||
textPainter
|
||||
..text = TextSpan(text: section.label, style: style)
|
||||
..layout();
|
||||
|
||||
// Only draw if label fits in visible space
|
||||
if (textPainter.width <= visibleWidth - 8) {
|
||||
final labelX = visibleLeft + (visibleWidth - textPainter.width) / 2;
|
||||
final labelY = topOffset + (height - textPainter.height) / 2;
|
||||
|
||||
textPainter.paint(canvas, Offset(labelX, labelY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_TieredHeaderPainter oldDelegate) {
|
||||
return tierData != oldDelegate.tierData ||
|
||||
tierHeight != oldDelegate.tierHeight ||
|
||||
domainStart != oldDelegate.domainStart ||
|
||||
domainEnd != oldDelegate.domainEnd ||
|
||||
borderColor != oldDelegate.borderColor ||
|
||||
labelColor != oldDelegate.labelColor ||
|
||||
secondaryLabelColor != oldDelegate.secondaryLabelColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../constants.dart';
|
||||
import '../models/entry_drag_state.dart';
|
||||
import '../models/projected_entry.dart';
|
||||
import '../models/timeline_entry.dart';
|
||||
import '../models/timeline_group.dart';
|
||||
import '../services/timeline_projection_service.dart';
|
||||
import '../state/timeline_viewport_notifier.dart';
|
||||
import 'draggable_event_pill.dart';
|
||||
import 'ghost_overlay.dart';
|
||||
import 'group_drop_target.dart';
|
||||
import 'timeline_scope.dart';
|
||||
|
||||
typedef EntryLabelBuilder = String Function(TimelineEntry entry);
|
||||
typedef EntryColorBuilder = Color Function(TimelineEntry entry);
|
||||
|
||||
/// Callback signature for when an entry is moved via drag-and-drop.
|
||||
typedef OnEntryMoved =
|
||||
void Function(
|
||||
TimelineEntry entry,
|
||||
DateTime newStart,
|
||||
String newGroupId,
|
||||
int newLane,
|
||||
);
|
||||
|
||||
/// Base timeline view: renders groups with between-group headers and
|
||||
/// lane rows containing event pills.
|
||||
class ZTimelineView extends StatelessWidget {
|
||||
const ZTimelineView({
|
||||
super.key,
|
||||
required this.groups,
|
||||
required this.entries,
|
||||
required this.viewport,
|
||||
required this.labelBuilder,
|
||||
required this.colorBuilder,
|
||||
this.laneHeight = ZTimelineConstants.laneHeight,
|
||||
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
|
||||
this.onEntryMoved,
|
||||
this.enableDrag = true,
|
||||
});
|
||||
|
||||
final List<TimelineGroup> groups;
|
||||
final List<TimelineEntry> entries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final EntryLabelBuilder labelBuilder;
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double laneHeight;
|
||||
final double groupHeaderHeight;
|
||||
|
||||
/// Callback invoked when an entry is moved via drag-and-drop.
|
||||
///
|
||||
/// The consumer is responsible for updating their state with the new
|
||||
/// position. The [newLane] is calculated to avoid collisions.
|
||||
final OnEntryMoved? onEntryMoved;
|
||||
|
||||
/// Whether drag-and-drop is enabled.
|
||||
final bool enableDrag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: viewport,
|
||||
builder: (context, _) {
|
||||
final projected = const TimelineProjectionService().project(
|
||||
entries: entries,
|
||||
domainStart: viewport.start,
|
||||
domainEnd: viewport.end,
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentWidth = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: ZTimelineConstants.minContentWidth;
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: groups.length,
|
||||
itemBuilder: (context, index) {
|
||||
final group = groups[index];
|
||||
final groupEntries =
|
||||
projected[group.id] ?? const <ProjectedEntry>[];
|
||||
final lanesCount = _countLanes(groupEntries);
|
||||
|
||||
Widget groupColumn = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||
_GroupLanes(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
viewport: viewport,
|
||||
lanesCount: lanesCount,
|
||||
laneHeight: laneHeight,
|
||||
colorBuilder: colorBuilder,
|
||||
labelBuilder: labelBuilder,
|
||||
contentWidth: contentWidth,
|
||||
enableDrag: enableDrag,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Wrap the entire group (header + lanes) in a DragTarget
|
||||
// so dragging over headers doesn't create a dead zone.
|
||||
if (enableDrag && onEntryMoved != null) {
|
||||
groupColumn = GroupDropTarget(
|
||||
group: group,
|
||||
entries: groupEntries,
|
||||
allEntries: entries,
|
||||
viewport: viewport,
|
||||
contentWidth: contentWidth,
|
||||
laneHeight: laneHeight,
|
||||
lanesCount: lanesCount,
|
||||
onEntryMoved: onEntryMoved,
|
||||
verticalOffset:
|
||||
groupHeaderHeight +
|
||||
ZTimelineConstants.verticalOuterPadding,
|
||||
child: groupColumn,
|
||||
);
|
||||
}
|
||||
|
||||
return groupColumn;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _countLanes(List<ProjectedEntry> entries) {
|
||||
var maxLane = 0;
|
||||
for (final e in entries) {
|
||||
if (e.entry.lane > maxLane) maxLane = e.entry.lane;
|
||||
}
|
||||
return maxLane.clamp(0, 1000); // basic guard
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupHeader extends StatelessWidget {
|
||||
const _GroupHeader({required this.title, required this.height});
|
||||
final String title;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: height,
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupLanes extends StatelessWidget {
|
||||
const _GroupLanes({
|
||||
required this.group,
|
||||
required this.entries,
|
||||
required this.viewport,
|
||||
required this.lanesCount,
|
||||
required this.laneHeight,
|
||||
required this.labelBuilder,
|
||||
required this.colorBuilder,
|
||||
required this.contentWidth,
|
||||
required this.enableDrag,
|
||||
});
|
||||
|
||||
final TimelineGroup group;
|
||||
final List<ProjectedEntry> entries;
|
||||
final TimelineViewportNotifier viewport;
|
||||
final int lanesCount;
|
||||
final double laneHeight;
|
||||
final EntryLabelBuilder labelBuilder;
|
||||
final EntryColorBuilder colorBuilder;
|
||||
final double contentWidth;
|
||||
final bool enableDrag;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
|
||||
// If no scope (drag not enabled), use static height
|
||||
if (scope == null || !enableDrag) {
|
||||
return _buildContent(context, lanesCount);
|
||||
}
|
||||
|
||||
// Listen to interaction notifier for drag state changes
|
||||
return ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
final effectiveLanesCount = _calculateEffectiveLanesCount(
|
||||
actualLanesCount: lanesCount,
|
||||
dragState: scope.interaction.dragState,
|
||||
groupId: group.id,
|
||||
);
|
||||
|
||||
return _buildContent(context, effectiveLanesCount);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
int _calculateEffectiveLanesCount({
|
||||
required int actualLanesCount,
|
||||
required EntryDragState? dragState,
|
||||
required String groupId,
|
||||
}) {
|
||||
// No drag active - use actual lane count
|
||||
if (dragState == null) {
|
||||
return actualLanesCount;
|
||||
}
|
||||
|
||||
// Drag active but over different group - use actual lane count
|
||||
if (dragState.targetGroupId != groupId) {
|
||||
return actualLanesCount;
|
||||
}
|
||||
|
||||
// Drag active over this group - expand to accommodate target lane
|
||||
return actualLanesCount > dragState.targetLane
|
||||
? actualLanesCount
|
||||
: dragState.targetLane;
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, int effectiveLanesCount) {
|
||||
final totalHeight =
|
||||
effectiveLanesCount * laneHeight +
|
||||
(effectiveLanesCount > 0
|
||||
? (effectiveLanesCount - 1) * ZTimelineConstants.laneVerticalSpacing
|
||||
: 0);
|
||||
final scope = ZTimelineScope.maybeOf(context);
|
||||
|
||||
// The inner Stack with pills and ghost overlay
|
||||
Widget innerStack = SizedBox(
|
||||
height: totalHeight,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Event pills
|
||||
for (final e in entries)
|
||||
DraggableEventPill(
|
||||
entry: e,
|
||||
laneHeight: laneHeight,
|
||||
labelBuilder: labelBuilder,
|
||||
colorBuilder: colorBuilder,
|
||||
contentWidth: contentWidth,
|
||||
enableDrag: enableDrag,
|
||||
),
|
||||
|
||||
// Ghost overlay (rendered in same coordinate space as pills)
|
||||
if (enableDrag && scope != null)
|
||||
ListenableBuilder(
|
||||
listenable: scope.interaction,
|
||||
builder: (context, _) {
|
||||
final dragState = scope.interaction.dragState;
|
||||
if (dragState == null || dragState.targetGroupId != group.id) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return GhostOverlay(
|
||||
dragState: dragState,
|
||||
viewport: viewport,
|
||||
contentWidth: contentWidth,
|
||||
laneHeight: laneHeight,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: ZTimelineConstants.verticalOuterPadding,
|
||||
),
|
||||
child: innerStack,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
packages/z-flutter/packages/z_timeline/lib/z_timeline.dart
Normal file
41
packages/z-flutter/packages/z_timeline/lib/z_timeline.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
/// Reusable timeline visualization package.
|
||||
library;
|
||||
|
||||
// Constants
|
||||
export 'src/constants.dart';
|
||||
|
||||
// Models
|
||||
export 'src/models/breadcrumb_segment.dart';
|
||||
export 'src/models/entry_drag_state.dart';
|
||||
export 'src/models/interaction_config.dart';
|
||||
export 'src/models/interaction_state.dart';
|
||||
export 'src/models/projected_entry.dart';
|
||||
export 'src/models/tier_config.dart';
|
||||
export 'src/models/tier_section.dart';
|
||||
export 'src/models/tiered_tick_data.dart';
|
||||
export 'src/models/timeline_entry.dart';
|
||||
export 'src/models/timeline_group.dart';
|
||||
export 'src/models/zoom_level.dart';
|
||||
|
||||
// Services
|
||||
export 'src/services/breadcrumb_service.dart';
|
||||
export 'src/services/entry_placement_service.dart';
|
||||
export 'src/services/layout_coordinate_service.dart';
|
||||
export 'src/services/tiered_tick_service.dart';
|
||||
export 'src/services/time_scale_service.dart';
|
||||
export 'src/services/timeline_projection_service.dart';
|
||||
|
||||
// State
|
||||
export 'src/state/timeline_interaction_notifier.dart';
|
||||
export 'src/state/timeline_viewport_notifier.dart';
|
||||
|
||||
// Widgets
|
||||
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||
export 'src/widgets/draggable_event_pill.dart';
|
||||
export 'src/widgets/ghost_overlay.dart';
|
||||
export 'src/widgets/group_drop_target.dart';
|
||||
export 'src/widgets/timeline_breadcrumb.dart';
|
||||
export 'src/widgets/timeline_interactor.dart';
|
||||
export 'src/widgets/timeline_scope.dart';
|
||||
export 'src/widgets/timeline_tiered_header.dart';
|
||||
export 'src/widgets/timeline_view.dart';
|
||||
17
packages/z-flutter/packages/z_timeline/pubspec.yaml
Normal file
17
packages/z-flutter/packages/z_timeline/pubspec.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
name: z_timeline
|
||||
description: "Reusable timeline visualization package."
|
||||
version: 0.0.1
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
212
packages/z-flutter/pubspec.lock
Normal file
212
packages/z-flutter/pubspec.lock
Normal file
@@ -0,0 +1,212 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.9"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
z_timeline:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/z_timeline"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
21
packages/z-flutter/pubspec.yaml
Normal file
21
packages/z-flutter/pubspec.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
name: z_flutter
|
||||
description: "A new Flutter project."
|
||||
publish_to: "none"
|
||||
version: 0.1.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
z_timeline:
|
||||
path: packages/z_timeline
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
20
packages/z-flutter/scripts/copy-build.mjs
Normal file
20
packages/z-flutter/scripts/copy-build.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import { cpSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const src = resolve(__dirname, "../build/web");
|
||||
const dest = resolve(__dirname, "../../../apps/web/public/flutter");
|
||||
|
||||
cpSync(src, dest, { recursive: true });
|
||||
console.log(`Copied Flutter build: ${src} → ${dest}`);
|
||||
|
||||
// Extract buildConfig from flutter_bootstrap.js so the React app can fetch it
|
||||
const bootstrap = readFileSync(resolve(dest, "flutter_bootstrap.js"), "utf-8");
|
||||
const match = bootstrap.match(/_flutter\.buildConfig\s*=\s*({.*?});/);
|
||||
if (match) {
|
||||
writeFileSync(resolve(dest, "build_config.json"), match[1]);
|
||||
console.log("Extracted build_config.json");
|
||||
} else {
|
||||
console.warn("Could not extract buildConfig from flutter_bootstrap.js");
|
||||
}
|
||||
BIN
packages/z-flutter/web/favicon.png
Normal file
BIN
packages/z-flutter/web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
packages/z-flutter/web/icons/Icon-192.png
Normal file
BIN
packages/z-flutter/web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
packages/z-flutter/web/icons/Icon-512.png
Normal file
BIN
packages/z-flutter/web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
packages/z-flutter/web/icons/Icon-maskable-192.png
Normal file
BIN
packages/z-flutter/web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/z-flutter/web/icons/Icon-maskable-512.png
Normal file
BIN
packages/z-flutter/web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
46
packages/z-flutter/web/index.html
Normal file
46
packages/z-flutter/web/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="z_flutter">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>z_flutter</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
35
packages/z-flutter/web/manifest.json
Normal file
35
packages/z-flutter/web/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "z_flutter",
|
||||
"short_name": "z_flutter",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user