use latest version of timline poc

This commit is contained in:
2026-03-02 15:50:38 +01:00
parent de0be12aab
commit abb97d84fb
56 changed files with 1787 additions and 308 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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:",

View File

@@ -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`.
*/
// ---------------------------------------------------------------------------

View File

@@ -1,3 +1,3 @@
# z_timeline
# z_flutter
A new Flutter project.

View File

@@ -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<MainApp> {
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<MainApp> {
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<MainApp> {
items: updatedItems,
groupOrder: state.groupOrder,
selectedItemId: state.selectedItemId,
darkMode: state.darkMode,
);
_applyState(updatedState);
}
@@ -162,7 +170,9 @@ class _MainAppState extends State<MainApp> {
}
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,6 +228,11 @@ class _MainAppState extends State<MainApp> {
? const Center(child: Text('Waiting for state...'))
: ZTimelineScope(
viewport: viewport,
child: Column(
children: [
const ZTimelineBreadcrumb(),
const ZTimelineTieredHeader(),
Expanded(
child: ZTimelineInteractor(
child: ZTimelineView(
groups: _groups,
@@ -230,6 +245,9 @@ class _MainAppState extends State<MainApp> {
),
),
),
],
),
),
),
);
}

View File

@@ -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"

View File

@@ -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)';
}

View File

@@ -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),
],
),
];

View File

@@ -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)';
}

View File

@@ -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})';
}

View File

@@ -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;
}

View File

@@ -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];
}
}

View File

@@ -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);
}
}

View File

@@ -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,
),
),
),
),
);
}
}

View File

@@ -69,7 +69,7 @@ class DraggableEventPill extends StatelessWidget {
onDragStarted: () {
scope.interaction.beginDrag(entry.entry);
},
onDraggableCanceled: (_, _) {
onDraggableCanceled: (_, __) {
scope.interaction.cancelDrag();
},
onDragCompleted: () {

View File

@@ -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,

View File

@@ -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,
),
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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 <ProjectedEntry>[];
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<ProjectedEntry> entries;
final List<TimelineEntry> allEntries;
final TimelineViewportNotifier viewport;
final int lanesCount;
final double laneHeight;

View File

@@ -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';

View 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

View File

@@ -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"

View File

@@ -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:

View File

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 917 B

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View 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>

View File

@@ -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",

View File

@@ -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<TimeInterval> candidates;
}
List<DateTime> 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 = <DateTime>[];
var t = chosen.floor(start);
while (!t.isAfter(end)) {
ticks.add(t);
t = chosen.addTo(t);
}
return ticks;
}
const _defaultIntervals = <TimeInterval>[
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)),
];

View File

@@ -1,116 +0,0 @@
<!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_timeline">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>z_timeline</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!-- Dev-mode fake data: provides window.__zendegi__ so the timeline
renders standalone without the React parent. Harmless in production
because React overwrites __zendegi__ before Flutter loads. -->
<script>
(function () {
// Only bootstrap if no bridge exists yet (i.e. not embedded in React)
if (window.__zendegi__) return;
var state = {
timeline: { id: "tl-1", title: "My Project" },
groups: {
"g-1": { id: "g-1", title: "Design", sortOrder: 0 },
"g-2": { id: "g-2", title: "Engineering", sortOrder: 1 },
"g-3": { id: "g-3", title: "Launch", sortOrder: 2 },
},
items: {
"e-1": { id: "e-1", groupId: "g-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 },
"e-2": { id: "e-2", groupId: "g-1", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 },
"e-3": { id: "e-3", groupId: "g-1", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 },
"e-10": { id: "e-10", groupId: "g-1", title: "Kickoff meeting", start: "2026-01-01", end: null, lane: 3 },
"e-4": { id: "e-4", groupId: "g-2", title: "API scaffolding", start: "2026-01-05", end: "2026-01-12", lane: 1 },
"e-5": { id: "e-5", groupId: "g-2", title: "Auth flow", start: "2026-01-10", end: "2026-01-18", lane: 2 },
"e-6": { id: "e-6", groupId: "g-2", title: "Dashboard UI", start: "2026-01-15", end: "2026-01-25", lane: 3 },
"e-7": { id: "e-7", groupId: "g-3", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 },
"e-8": { id: "e-8", groupId: "g-3", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 },
"e-9": { id: "e-9", groupId: "g-3", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 },
"e-11": { id: "e-11", groupId: "g-3", title: "Go-live", start: "2026-01-28", end: null, lane: 3 },
},
groupOrder: ["g-1", "g-2", "g-3"],
selectedItemId: null,
};
var _updateState = null;
window.__zendegi__ = {
getState: function () {
return JSON.stringify(state);
},
onEvent: function (jsonStr) {
var event = JSON.parse(jsonStr);
console.log(event);
if (event.type === "entry_moved") {
var p = event.payload;
var item = state.items[p.entryId];
if (item) {
// Update in place — normalized makes this trivial
item.start = p.newStart
? new Date(p.newStart).toISOString().split("T")[0]
: item.start;
item.end = p.newEnd
? new Date(p.newEnd).toISOString().split("T")[0]
: null;
item.groupId = p.newGroupId;
item.lane = p.newLane;
// Push updated state back to Flutter
if (_updateState) {
_updateState(JSON.stringify(state));
}
}
} else if (event.type === "content_height") {
console.log("[z-timeline dev] content_height:", event.payload.height);
} else {
console.log("[z-timeline dev] event:", event);
}
},
set updateState(callback) {
_updateState = callback;
},
};
console.log("[z-timeline dev] Standalone mode — fake data loaded");
})();
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

6
pnpm-lock.yaml generated
View File

@@ -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: