From abb97d84fb058d173614b219ca7de1af7dcede7e Mon Sep 17 00:00:00 2001 From: Jonatan Granqvist Date: Mon, 2 Mar 2026 15:50:38 +0100 Subject: [PATCH] use latest version of timline poc --- CLAUDE.md | 6 +- apps/web/CLAUDE.md | 2 +- apps/web/package.json | 2 +- apps/web/src/lib/flutter-bridge.ts | 2 - packages/{z-timeline => z-flutter}/.gitignore | 0 packages/{z-timeline => z-flutter}/.metadata | 0 packages/{z-timeline => z-flutter}/README.md | 2 +- .../analysis_options.yaml | 0 .../{z-timeline => z-flutter}/lib/bridge.dart | 0 .../{z-timeline => z-flutter}/lib/main.dart | 44 ++- .../{z-timeline => z-flutter}/lib/state.dart | 0 .../{z-timeline => z-flutter}/package.json | 4 +- .../z_timeline}/lib/src/constants.dart | 0 .../lib/src/models/breadcrumb_segment.dart | 82 ++++ .../lib/src/models/entry_drag_state.dart | 0 .../lib/src/models/interaction_config.dart | 0 .../lib/src/models/interaction_state.dart | 0 .../lib/src/models/projected_entry.dart | 0 .../lib/src/models/tier_config.dart | 261 +++++++++++++ .../lib/src/models/tier_section.dart | 30 ++ .../lib/src/models/tiered_tick_data.dart | 45 +++ .../lib/src/models/timeline_entry.dart | 0 .../lib/src/models/timeline_group.dart | 0 .../z_timeline/lib/src/models/zoom_level.dart | 43 +++ .../lib/src/services/breadcrumb_service.dart | 292 ++++++++++++++ .../src/services/entry_placement_service.dart | 0 .../services/layout_coordinate_service.dart | 0 .../lib/src/services/tiered_tick_service.dart | 356 ++++++++++++++++++ .../lib/src/services/time_scale_service.dart | 0 .../services/timeline_projection_service.dart | 0 .../state/timeline_interaction_notifier.dart | 0 .../src/state/timeline_viewport_notifier.dart | 0 .../src/widgets/breadcrumb_segment_chip.dart | 61 +++ .../lib/src/widgets/draggable_event_pill.dart | 2 +- .../lib/src/widgets/ghost_overlay.dart | 0 .../lib/src/widgets/group_drop_target.dart | 22 +- .../lib/src/widgets/timeline_breadcrumb.dart | 182 +++++++++ .../lib/src/widgets/timeline_interactor.dart | 0 .../lib/src/widgets/timeline_scope.dart | 0 .../src/widgets/timeline_tiered_header.dart | 270 +++++++++++++ .../lib/src/widgets/timeline_view.dart | 16 +- .../packages/z_timeline/lib/z_timeline.dart} | 45 ++- .../packages/z_timeline/pubspec.yaml | 17 + .../{z-timeline => z-flutter}/pubspec.lock | 7 + .../{z-timeline => z-flutter}/pubspec.yaml | 6 +- .../scripts/copy-build.mjs | 0 .../{z-timeline => z-flutter}/web/favicon.png | Bin .../web/icons/Icon-192.png | Bin .../web/icons/Icon-512.png | Bin .../web/icons/Icon-maskable-192.png | Bin .../web/icons/Icon-maskable-512.png | Bin packages/z-flutter/web/index.html | 46 +++ .../web/manifest.json | 4 +- .../lib/src/services/time_tick_builder.dart | 124 ------ packages/z-timeline/web/index.html | 116 ------ pnpm-lock.yaml | 6 +- 56 files changed, 1787 insertions(+), 308 deletions(-) rename packages/{z-timeline => z-flutter}/.gitignore (100%) rename packages/{z-timeline => z-flutter}/.metadata (100%) rename packages/{z-timeline => z-flutter}/README.md (64%) rename packages/{z-timeline => z-flutter}/analysis_options.yaml (100%) rename packages/{z-timeline => z-flutter}/lib/bridge.dart (100%) rename packages/{z-timeline => z-flutter}/lib/main.dart (83%) rename packages/{z-timeline => z-flutter}/lib/state.dart (100%) rename packages/{z-timeline => z-flutter}/package.json (72%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/constants.dart (100%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/models/breadcrumb_segment.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/models/entry_drag_state.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/models/interaction_config.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/models/interaction_state.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/models/projected_entry.dart (100%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/models/tier_config.dart create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/models/tier_section.dart create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/models/tiered_tick_data.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/models/timeline_entry.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/models/timeline_group.dart (100%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/models/zoom_level.dart create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/services/breadcrumb_service.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/services/entry_placement_service.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/services/layout_coordinate_service.dart (100%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/services/time_scale_service.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/services/timeline_projection_service.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/state/timeline_interaction_notifier.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/state/timeline_viewport_notifier.dart (100%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/widgets/draggable_event_pill.dart (98%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/widgets/ghost_overlay.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/widgets/group_drop_target.dart (85%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/widgets/timeline_interactor.dart (100%) rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/widgets/timeline_scope.dart (100%) create mode 100644 packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart rename packages/{z-timeline => z-flutter/packages/z_timeline}/lib/src/widgets/timeline_view.dart (94%) rename packages/{z-timeline/lib/timeline.dart => z-flutter/packages/z_timeline/lib/z_timeline.dart} (54%) create mode 100644 packages/z-flutter/packages/z_timeline/pubspec.yaml rename packages/{z-timeline => z-flutter}/pubspec.lock (97%) rename packages/{z-timeline => z-flutter}/pubspec.yaml (75%) rename packages/{z-timeline => z-flutter}/scripts/copy-build.mjs (100%) rename packages/{z-timeline => z-flutter}/web/favicon.png (100%) rename packages/{z-timeline => z-flutter}/web/icons/Icon-192.png (100%) rename packages/{z-timeline => z-flutter}/web/icons/Icon-512.png (100%) rename packages/{z-timeline => z-flutter}/web/icons/Icon-maskable-192.png (100%) rename packages/{z-timeline => z-flutter}/web/icons/Icon-maskable-512.png (100%) create mode 100644 packages/z-flutter/web/index.html rename packages/{z-timeline => z-flutter}/web/manifest.json (93%) delete mode 100644 packages/z-timeline/lib/src/services/time_tick_builder.dart delete mode 100644 packages/z-timeline/web/index.html diff --git a/CLAUDE.md b/CLAUDE.md index 6085d77..f5be79b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Turborepo monorepo (pnpm) for a timeline web app. TanStack Start frontend with a - **packages/env** — Type-safe env vars (t3-env + Zod) - **packages/config** — Shared TypeScript config - **packages/eslint-config** — Shared ESLint config (base + react) -- **packages/z-timeline** — Flutter web app (embedded in the web page) +- **packages/z-flutter** — Flutter web app (embedded in the web page) ## Lint Commands @@ -36,8 +36,8 @@ pnpm --filter @zendegi/auth check-types pnpm --filter @zendegi/db check-types pnpm --filter @zendegi/env check-types -# Flutter (packages/z-timeline) -cd packages/z-timeline && dart analyze +# Flutter (packages/z-flutter) +cd packages/z-flutter && dart analyze ``` ### Prettier (root-level) diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 5d79fc1..b1818e7 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -4,4 +4,4 @@ Zendegi is a web app for creating and exploring timelines. They could be persona The timelines are shown horizontally and are interactive. -The app is built with Tanstack Start. The timeline is implemented in flutter (packages/z-timeline) and embedded in the web page. +The app is built with Tanstack Start. The timeline is implemented in flutter (packages/z-flutter) and embedded in the web page. diff --git a/apps/web/package.json b/apps/web/package.json index 7660f00..900eb3c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,7 @@ "@zendegi/auth": "workspace:*", "@zendegi/db": "workspace:*", "@zendegi/env": "workspace:*", - "@zendegi/z-timeline": "workspace:*", + "@zendegi/z-flutter": "workspace:*", "better-auth": "catalog:", "clsx": "^2.1.1", "dotenv": "catalog:", diff --git a/apps/web/src/lib/flutter-bridge.ts b/apps/web/src/lib/flutter-bridge.ts index 2c714ae..bf87a96 100644 --- a/apps/web/src/lib/flutter-bridge.ts +++ b/apps/web/src/lib/flutter-bridge.ts @@ -7,8 +7,6 @@ * The bridge uses a **normalized** shape: groups and items are stored as * Record maps keyed by ID, with `groupOrder` preserving display order. * - * Keep this file in sync with `packages/z-timeline/lib/state.dart` and - * the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`. */ // --------------------------------------------------------------------------- diff --git a/packages/z-timeline/.gitignore b/packages/z-flutter/.gitignore similarity index 100% rename from packages/z-timeline/.gitignore rename to packages/z-flutter/.gitignore diff --git a/packages/z-timeline/.metadata b/packages/z-flutter/.metadata similarity index 100% rename from packages/z-timeline/.metadata rename to packages/z-flutter/.metadata diff --git a/packages/z-timeline/README.md b/packages/z-flutter/README.md similarity index 64% rename from packages/z-timeline/README.md rename to packages/z-flutter/README.md index 5966dfb..a9e2afe 100644 --- a/packages/z-timeline/README.md +++ b/packages/z-flutter/README.md @@ -1,3 +1,3 @@ -# z_timeline +# z_flutter A new Flutter project. diff --git a/packages/z-timeline/analysis_options.yaml b/packages/z-flutter/analysis_options.yaml similarity index 100% rename from packages/z-timeline/analysis_options.yaml rename to packages/z-flutter/analysis_options.yaml diff --git a/packages/z-timeline/lib/bridge.dart b/packages/z-flutter/lib/bridge.dart similarity index 100% rename from packages/z-timeline/lib/bridge.dart rename to packages/z-flutter/lib/bridge.dart diff --git a/packages/z-timeline/lib/main.dart b/packages/z-flutter/lib/main.dart similarity index 83% rename from packages/z-timeline/lib/main.dart rename to packages/z-flutter/lib/main.dart index 5bea99c..fa4bbf1 100644 --- a/packages/z-timeline/lib/main.dart +++ b/packages/z-flutter/lib/main.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:z_timeline/z_timeline.dart'; + import 'bridge.dart'; import 'state.dart'; -import 'timeline.dart'; void main() { runApp(const MainApp()); @@ -22,6 +23,12 @@ class _MainAppState extends State { 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(); @@ -127,7 +134,7 @@ class _MainAppState extends State { final duration = entry.end.difference(entry.start); final newEnd = entry.hasEnd ? newStart.add(duration) : null; - // Optimistic update – apply locally before the host round-trips. + // Optimistic update -- apply locally before the host round-trips. if (_state case final state?) { final oldItem = state.items[entry.id]; if (oldItem != null) { @@ -147,6 +154,7 @@ class _MainAppState extends State { items: updatedItems, groupOrder: state.groupOrder, selectedItemId: state.selectedItemId, + darkMode: state.darkMode, ); _applyState(updatedState); } @@ -162,7 +170,9 @@ class _MainAppState extends State { } void _emitContentHeight() { - var totalHeight = 0.0; + // 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); @@ -218,16 +228,24 @@ class _MainAppState extends State { ? const Center(child: Text('Waiting for state...')) : ZTimelineScope( viewport: viewport, - child: ZTimelineInteractor( - child: ZTimelineView( - groups: _groups, - entries: _entries, - viewport: viewport, - labelBuilder: _labelForEntry, - colorBuilder: _colorForEntry, - enableDrag: true, - onEntryMoved: _onEntryMoved, - ), + 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, + ), + ), + ), + ], ), ), ), diff --git a/packages/z-timeline/lib/state.dart b/packages/z-flutter/lib/state.dart similarity index 100% rename from packages/z-timeline/lib/state.dart rename to packages/z-flutter/lib/state.dart diff --git a/packages/z-timeline/package.json b/packages/z-flutter/package.json similarity index 72% rename from packages/z-timeline/package.json rename to packages/z-flutter/package.json index c725cbf..222f80b 100644 --- a/packages/z-timeline/package.json +++ b/packages/z-flutter/package.json @@ -1,6 +1,6 @@ { - "name": "@zendegi/z-timeline", - "version": "0.0.0", + "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" diff --git a/packages/z-timeline/lib/src/constants.dart b/packages/z-flutter/packages/z_timeline/lib/src/constants.dart similarity index 100% rename from packages/z-timeline/lib/src/constants.dart rename to packages/z-flutter/packages/z_timeline/lib/src/constants.dart diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/breadcrumb_segment.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/breadcrumb_segment.dart new file mode 100644 index 0000000..477db20 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/breadcrumb_segment.dart @@ -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)'; +} diff --git a/packages/z-timeline/lib/src/models/entry_drag_state.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/entry_drag_state.dart similarity index 100% rename from packages/z-timeline/lib/src/models/entry_drag_state.dart rename to packages/z-flutter/packages/z_timeline/lib/src/models/entry_drag_state.dart diff --git a/packages/z-timeline/lib/src/models/interaction_config.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/interaction_config.dart similarity index 100% rename from packages/z-timeline/lib/src/models/interaction_config.dart rename to packages/z-flutter/packages/z_timeline/lib/src/models/interaction_config.dart diff --git a/packages/z-timeline/lib/src/models/interaction_state.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/interaction_state.dart similarity index 100% rename from packages/z-timeline/lib/src/models/interaction_state.dart rename to packages/z-flutter/packages/z_timeline/lib/src/models/interaction_state.dart diff --git a/packages/z-timeline/lib/src/models/projected_entry.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/projected_entry.dart similarity index 100% rename from packages/z-timeline/lib/src/models/projected_entry.dart rename to packages/z-flutter/packages/z_timeline/lib/src/models/projected_entry.dart diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/tier_config.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/tier_config.dart new file mode 100644 index 0000000..e04bbcd --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/tier_config.dart @@ -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 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 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), + ], + ), +]; diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/tier_section.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/tier_section.dart new file mode 100644 index 0000000..1335533 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/tier_section.dart @@ -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)'; +} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/tiered_tick_data.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/tiered_tick_data.dart new file mode 100644 index 0000000..a4596b6 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/tiered_tick_data.dart @@ -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 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 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})'; +} diff --git a/packages/z-timeline/lib/src/models/timeline_entry.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/timeline_entry.dart similarity index 100% rename from packages/z-timeline/lib/src/models/timeline_entry.dart rename to packages/z-flutter/packages/z_timeline/lib/src/models/timeline_entry.dart diff --git a/packages/z-timeline/lib/src/models/timeline_group.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/timeline_group.dart similarity index 100% rename from packages/z-timeline/lib/src/models/timeline_group.dart rename to packages/z-flutter/packages/z_timeline/lib/src/models/timeline_group.dart diff --git a/packages/z-flutter/packages/z_timeline/lib/src/models/zoom_level.dart b/packages/z-flutter/packages/z_timeline/lib/src/models/zoom_level.dart new file mode 100644 index 0000000..092dcbc --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/models/zoom_level.dart @@ -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; +} diff --git a/packages/z-flutter/packages/z_timeline/lib/src/services/breadcrumb_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/breadcrumb_service.dart new file mode 100644 index 0000000..e581568 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/services/breadcrumb_service.dart @@ -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 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]; + } +} diff --git a/packages/z-timeline/lib/src/services/entry_placement_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/entry_placement_service.dart similarity index 100% rename from packages/z-timeline/lib/src/services/entry_placement_service.dart rename to packages/z-flutter/packages/z_timeline/lib/src/services/entry_placement_service.dart diff --git a/packages/z-timeline/lib/src/services/layout_coordinate_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/layout_coordinate_service.dart similarity index 100% rename from packages/z-timeline/lib/src/services/layout_coordinate_service.dart rename to packages/z-flutter/packages/z_timeline/lib/src/services/layout_coordinate_service.dart diff --git a/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart new file mode 100644 index 0000000..7c40dcb --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/services/tiered_tick_service.dart @@ -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 generateTierSections({ + required DateTime start, + required DateTime end, + required TierUnit unit, + required TierLabelFormat format, + }) { + final sections = []; + 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 = []; + 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); + } +} diff --git a/packages/z-timeline/lib/src/services/time_scale_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/time_scale_service.dart similarity index 100% rename from packages/z-timeline/lib/src/services/time_scale_service.dart rename to packages/z-flutter/packages/z_timeline/lib/src/services/time_scale_service.dart diff --git a/packages/z-timeline/lib/src/services/timeline_projection_service.dart b/packages/z-flutter/packages/z_timeline/lib/src/services/timeline_projection_service.dart similarity index 100% rename from packages/z-timeline/lib/src/services/timeline_projection_service.dart rename to packages/z-flutter/packages/z_timeline/lib/src/services/timeline_projection_service.dart diff --git a/packages/z-timeline/lib/src/state/timeline_interaction_notifier.dart b/packages/z-flutter/packages/z_timeline/lib/src/state/timeline_interaction_notifier.dart similarity index 100% rename from packages/z-timeline/lib/src/state/timeline_interaction_notifier.dart rename to packages/z-flutter/packages/z_timeline/lib/src/state/timeline_interaction_notifier.dart diff --git a/packages/z-timeline/lib/src/state/timeline_viewport_notifier.dart b/packages/z-flutter/packages/z_timeline/lib/src/state/timeline_viewport_notifier.dart similarity index 100% rename from packages/z-timeline/lib/src/state/timeline_viewport_notifier.dart rename to packages/z-flutter/packages/z_timeline/lib/src/state/timeline_viewport_notifier.dart diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart new file mode 100644 index 0000000..f17146b --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/breadcrumb_segment_chip.dart @@ -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, + ), + ), + ), + ), + ); + } +} diff --git a/packages/z-timeline/lib/src/widgets/draggable_event_pill.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart similarity index 98% rename from packages/z-timeline/lib/src/widgets/draggable_event_pill.dart rename to packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart index 6fe3ad0..ec33e09 100644 --- a/packages/z-timeline/lib/src/widgets/draggable_event_pill.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/draggable_event_pill.dart @@ -69,7 +69,7 @@ class DraggableEventPill extends StatelessWidget { onDragStarted: () { scope.interaction.beginDrag(entry.entry); }, - onDraggableCanceled: (_, _) { + onDraggableCanceled: (_, __) { scope.interaction.cancelDrag(); }, onDragCompleted: () { diff --git a/packages/z-timeline/lib/src/widgets/ghost_overlay.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/ghost_overlay.dart similarity index 100% rename from packages/z-timeline/lib/src/widgets/ghost_overlay.dart rename to packages/z-flutter/packages/z_timeline/lib/src/widgets/ghost_overlay.dart diff --git a/packages/z-timeline/lib/src/widgets/group_drop_target.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/group_drop_target.dart similarity index 85% rename from packages/z-timeline/lib/src/widgets/group_drop_target.dart rename to packages/z-flutter/packages/z_timeline/lib/src/widgets/group_drop_target.dart index 26af004..3f8915d 100644 --- a/packages/z-timeline/lib/src/widgets/group_drop_target.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/group_drop_target.dart @@ -12,10 +12,7 @@ import 'timeline_view.dart'; /// A drop target wrapper for a timeline group. /// -/// Wraps the entire group column (header + lanes) and handles drag-and-drop -/// operations. The [verticalOffset] accounts for the header height and padding -/// so that lane calculations are correct relative to the lanes stack. -/// +/// 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({ @@ -27,8 +24,8 @@ class GroupDropTarget extends StatelessWidget { required this.laneHeight, required this.lanesCount, required this.onEntryMoved, - required this.verticalOffset, required this.child, + this.verticalOffset = 0.0, super.key, }); @@ -40,13 +37,13 @@ class GroupDropTarget extends StatelessWidget { final double laneHeight; final int lanesCount; final OnEntryMoved? onEntryMoved; - - /// The vertical offset from the top of this widget to the top of the lanes - /// stack. This accounts for the group header height and any padding. - final double verticalOffset; - 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); @@ -74,10 +71,9 @@ class GroupDropTarget extends StatelessWidget { viewport.end, ); - // Subtract header + padding offset so Y is relative to the lanes stack. - // When the cursor is over the header, adjustedY is negative and clamps - // to lane 1. + // 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, diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart new file mode 100644 index 0000000..f6da961 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_breadcrumb.dart @@ -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 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 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, + ), + ), + ); + } +} diff --git a/packages/z-timeline/lib/src/widgets/timeline_interactor.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart similarity index 100% rename from packages/z-timeline/lib/src/widgets/timeline_interactor.dart rename to packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_interactor.dart diff --git a/packages/z-timeline/lib/src/widgets/timeline_scope.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_scope.dart similarity index 100% rename from packages/z-timeline/lib/src/widgets/timeline_scope.dart rename to packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_scope.dart diff --git a/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart new file mode 100644 index 0000000..81ba4a7 --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_tiered_header.dart @@ -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 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; + } +} diff --git a/packages/z-timeline/lib/src/widgets/timeline_view.dart b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart similarity index 94% rename from packages/z-timeline/lib/src/widgets/timeline_view.dart rename to packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart index 6215997..fbf2438 100644 --- a/packages/z-timeline/lib/src/widgets/timeline_view.dart +++ b/packages/z-flutter/packages/z_timeline/lib/src/widgets/timeline_view.dart @@ -75,8 +75,6 @@ class ZTimelineView extends StatelessWidget { : ZTimelineConstants.minContentWidth; return ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, itemCount: groups.length, itemBuilder: (context, index) { final group = groups[index]; @@ -84,14 +82,13 @@ class ZTimelineView extends StatelessWidget { projected[group.id] ?? const []; final lanesCount = _countLanes(groupEntries); - final column = Column( + Widget groupColumn = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _GroupHeader(title: group.title, height: groupHeaderHeight), _GroupLanes( group: group, entries: groupEntries, - allEntries: entries, viewport: viewport, lanesCount: lanesCount, laneHeight: laneHeight, @@ -104,9 +101,9 @@ class ZTimelineView extends StatelessWidget { ); // Wrap the entire group (header + lanes) in a DragTarget - // so dragging over headers still updates the ghost position. + // so dragging over headers doesn't create a dead zone. if (enableDrag && onEntryMoved != null) { - return GroupDropTarget( + groupColumn = GroupDropTarget( group: group, entries: groupEntries, allEntries: entries, @@ -118,11 +115,11 @@ class ZTimelineView extends StatelessWidget { verticalOffset: groupHeaderHeight + ZTimelineConstants.verticalOuterPadding, - child: column, + child: groupColumn, ); } - return column; + return groupColumn; }, ); }, @@ -150,7 +147,6 @@ class _GroupHeader extends StatelessWidget { final scheme = Theme.of(context).colorScheme; return Container( height: height, - padding: const EdgeInsets.only(left: 16.0), alignment: Alignment.centerLeft, decoration: BoxDecoration( color: scheme.surfaceContainerHighest, @@ -170,7 +166,6 @@ class _GroupLanes extends StatelessWidget { const _GroupLanes({ required this.group, required this.entries, - required this.allEntries, required this.viewport, required this.lanesCount, required this.laneHeight, @@ -182,7 +177,6 @@ class _GroupLanes extends StatelessWidget { final TimelineGroup group; final List entries; - final List allEntries; final TimelineViewportNotifier viewport; final int lanesCount; final double laneHeight; diff --git a/packages/z-timeline/lib/timeline.dart b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart similarity index 54% rename from packages/z-timeline/lib/timeline.dart rename to packages/z-flutter/packages/z_timeline/lib/z_timeline.dart index 4c2fa69..1625449 100644 --- a/packages/z-timeline/lib/timeline.dart +++ b/packages/z-flutter/packages/z_timeline/lib/z_timeline.dart @@ -1,24 +1,41 @@ +/// Reusable timeline visualization package. +library; + +// Constants +export 'src/constants.dart'; + // Models -export 'src/models/timeline_group.dart'; -export 'src/models/timeline_entry.dart'; -export 'src/models/projected_entry.dart'; +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/entry_drag_state.dart'; - -// State -export 'src/state/timeline_viewport_notifier.dart'; -export 'src/state/timeline_interaction_notifier.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/time_scale_service.dart'; -export 'src/services/time_tick_builder.dart'; -export 'src/services/timeline_projection_service.dart'; +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/constants.dart'; -export 'src/widgets/timeline_view.dart'; -export 'src/widgets/timeline_scope.dart'; +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'; diff --git a/packages/z-flutter/packages/z_timeline/pubspec.yaml b/packages/z-flutter/packages/z_timeline/pubspec.yaml new file mode 100644 index 0000000..861371c --- /dev/null +++ b/packages/z-flutter/packages/z_timeline/pubspec.yaml @@ -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 diff --git a/packages/z-timeline/pubspec.lock b/packages/z-flutter/pubspec.lock similarity index 97% rename from packages/z-timeline/pubspec.lock rename to packages/z-flutter/pubspec.lock index eb0a0b8..b447f70 100644 --- a/packages/z-timeline/pubspec.lock +++ b/packages/z-flutter/pubspec.lock @@ -200,6 +200,13 @@ packages: 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" diff --git a/packages/z-timeline/pubspec.yaml b/packages/z-flutter/pubspec.yaml similarity index 75% rename from packages/z-timeline/pubspec.yaml rename to packages/z-flutter/pubspec.yaml index 5fa331c..437b855 100644 --- a/packages/z-timeline/pubspec.yaml +++ b/packages/z-flutter/pubspec.yaml @@ -1,6 +1,6 @@ -name: z_timeline +name: z_flutter description: "A new Flutter project." -publish_to: 'none' +publish_to: "none" version: 0.1.0+1 environment: @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + z_timeline: + path: packages/z_timeline dev_dependencies: flutter_test: diff --git a/packages/z-timeline/scripts/copy-build.mjs b/packages/z-flutter/scripts/copy-build.mjs similarity index 100% rename from packages/z-timeline/scripts/copy-build.mjs rename to packages/z-flutter/scripts/copy-build.mjs diff --git a/packages/z-timeline/web/favicon.png b/packages/z-flutter/web/favicon.png similarity index 100% rename from packages/z-timeline/web/favicon.png rename to packages/z-flutter/web/favicon.png diff --git a/packages/z-timeline/web/icons/Icon-192.png b/packages/z-flutter/web/icons/Icon-192.png similarity index 100% rename from packages/z-timeline/web/icons/Icon-192.png rename to packages/z-flutter/web/icons/Icon-192.png diff --git a/packages/z-timeline/web/icons/Icon-512.png b/packages/z-flutter/web/icons/Icon-512.png similarity index 100% rename from packages/z-timeline/web/icons/Icon-512.png rename to packages/z-flutter/web/icons/Icon-512.png diff --git a/packages/z-timeline/web/icons/Icon-maskable-192.png b/packages/z-flutter/web/icons/Icon-maskable-192.png similarity index 100% rename from packages/z-timeline/web/icons/Icon-maskable-192.png rename to packages/z-flutter/web/icons/Icon-maskable-192.png diff --git a/packages/z-timeline/web/icons/Icon-maskable-512.png b/packages/z-flutter/web/icons/Icon-maskable-512.png similarity index 100% rename from packages/z-timeline/web/icons/Icon-maskable-512.png rename to packages/z-flutter/web/icons/Icon-maskable-512.png diff --git a/packages/z-flutter/web/index.html b/packages/z-flutter/web/index.html new file mode 100644 index 0000000..eb7a840 --- /dev/null +++ b/packages/z-flutter/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + z_flutter + + + + + + + diff --git a/packages/z-timeline/web/manifest.json b/packages/z-flutter/web/manifest.json similarity index 93% rename from packages/z-timeline/web/manifest.json rename to packages/z-flutter/web/manifest.json index 3305ed9..96dee91 100644 --- a/packages/z-timeline/web/manifest.json +++ b/packages/z-flutter/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "z_timeline", - "short_name": "z_timeline", + "name": "z_flutter", + "short_name": "z_flutter", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/packages/z-timeline/lib/src/services/time_tick_builder.dart b/packages/z-timeline/lib/src/services/time_tick_builder.dart deleted file mode 100644 index 59da42f..0000000 --- a/packages/z-timeline/lib/src/services/time_tick_builder.dart +++ /dev/null @@ -1,124 +0,0 @@ -class TimeInterval { - const TimeInterval(this.unit, this.step, this.approx); - final TimeUnit unit; - final int step; - final Duration approx; - - DateTime floor(DateTime d) { - switch (unit) { - case TimeUnit.second: - return DateTime.utc( - d.year, - d.month, - d.day, - d.hour, - d.minute, - (d.second ~/ step) * step, - ); - case TimeUnit.minute: - return DateTime.utc( - d.year, - d.month, - d.day, - d.hour, - (d.minute ~/ step) * step, - ); - case TimeUnit.hour: - return DateTime.utc(d.year, d.month, d.day, (d.hour ~/ step) * step); - case TimeUnit.day: - return DateTime.utc(d.year, d.month, (d.day - 1) ~/ step * step + 1); - case TimeUnit.week: - final monday = d.subtract(Duration(days: d.weekday - 1)); - return DateTime.utc( - monday.year, - monday.month, - monday.day, - ).subtract(Duration(days: 0 % step)); - case TimeUnit.month: - return DateTime.utc(d.year, ((d.month - 1) ~/ step) * step + 1); - case TimeUnit.year: - return DateTime.utc((d.year ~/ step) * step); - } - } - - DateTime addTo(DateTime d) { - switch (unit) { - case TimeUnit.second: - return d.add(Duration(seconds: step)); - case TimeUnit.minute: - return d.add(Duration(minutes: step)); - case TimeUnit.hour: - return d.add(Duration(hours: step)); - case TimeUnit.day: - return d.add(Duration(days: step)); - case TimeUnit.week: - return d.add(Duration(days: 7 * step)); - case TimeUnit.month: - return DateTime.utc(d.year, d.month + step, d.day); - case TimeUnit.year: - return DateTime.utc(d.year + step, d.month, d.day); - } - } -} - -enum TimeUnit { second, minute, hour, day, week, month, year } - -class TickConfig { - const TickConfig({ - this.minPixelsPerTick = 60, - this.candidates = _defaultIntervals, - }); - final double minPixelsPerTick; - final List candidates; -} - -List dateTicks({ - required DateTime start, - required DateTime end, - required double widthPx, - TickConfig config = const TickConfig(), -}) { - assert(!end.isBefore(start), 'End date must not be before start date'); - final spanMs = end.difference(start).inMilliseconds; - final msPerPixel = spanMs / widthPx; - - var chosen = config.candidates.last; - for (final i in config.candidates) { - final pixels = i.approx.inMilliseconds / msPerPixel; - if (pixels >= config.minPixelsPerTick) { - chosen = i; - break; - } - } - - final ticks = []; - var t = chosen.floor(start); - while (!t.isAfter(end)) { - ticks.add(t); - t = chosen.addTo(t); - } - return ticks; -} - -const _defaultIntervals = [ - TimeInterval(TimeUnit.second, 1, Duration(seconds: 1)), - TimeInterval(TimeUnit.second, 5, Duration(seconds: 5)), - TimeInterval(TimeUnit.second, 15, Duration(seconds: 15)), - TimeInterval(TimeUnit.second, 30, Duration(seconds: 30)), - TimeInterval(TimeUnit.minute, 1, Duration(minutes: 1)), - TimeInterval(TimeUnit.minute, 5, Duration(minutes: 5)), - TimeInterval(TimeUnit.minute, 15, Duration(minutes: 15)), - TimeInterval(TimeUnit.minute, 30, Duration(minutes: 30)), - TimeInterval(TimeUnit.hour, 1, Duration(hours: 1)), - TimeInterval(TimeUnit.hour, 3, Duration(hours: 3)), - TimeInterval(TimeUnit.hour, 6, Duration(hours: 6)), - TimeInterval(TimeUnit.hour, 12, Duration(hours: 12)), - TimeInterval(TimeUnit.day, 1, Duration(days: 1)), - TimeInterval(TimeUnit.day, 7, Duration(days: 7)), - TimeInterval(TimeUnit.month, 1, Duration(days: 30)), - TimeInterval(TimeUnit.month, 3, Duration(days: 90)), - TimeInterval(TimeUnit.month, 6, Duration(days: 180)), - TimeInterval(TimeUnit.year, 1, Duration(days: 365)), - TimeInterval(TimeUnit.year, 5, Duration(days: 365 * 5)), - TimeInterval(TimeUnit.year, 10, Duration(days: 365 * 10)), -]; diff --git a/packages/z-timeline/web/index.html b/packages/z-timeline/web/index.html deleted file mode 100644 index 61b81a9..0000000 --- a/packages/z-timeline/web/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - z_timeline - - - - - - - - - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6de2616..a7496d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,9 +105,9 @@ importers: '@zendegi/env': specifier: workspace:* version: link:../../packages/env - '@zendegi/z-timeline': + '@zendegi/z-flutter': specifier: workspace:* - version: link:../../packages/z-timeline + version: link:../../packages/z-flutter better-auth: specifier: 'catalog:' version: 1.4.19(@tanstack/react-start@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0))(pg@8.18.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -322,6 +322,8 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/z-flutter: {} + packages/z-timeline: {} packages: