use latest version of timline poc
@@ -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/env** — Type-safe env vars (t3-env + Zod)
|
||||||
- **packages/config** — Shared TypeScript config
|
- **packages/config** — Shared TypeScript config
|
||||||
- **packages/eslint-config** — Shared ESLint config (base + react)
|
- **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
|
## Lint Commands
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ pnpm --filter @zendegi/auth check-types
|
|||||||
pnpm --filter @zendegi/db check-types
|
pnpm --filter @zendegi/db check-types
|
||||||
pnpm --filter @zendegi/env check-types
|
pnpm --filter @zendegi/env check-types
|
||||||
|
|
||||||
# Flutter (packages/z-timeline)
|
# Flutter (packages/z-flutter)
|
||||||
cd packages/z-timeline && dart analyze
|
cd packages/z-flutter && dart analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
### Prettier (root-level)
|
### Prettier (root-level)
|
||||||
|
|||||||
@@ -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 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.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@zendegi/auth": "workspace:*",
|
"@zendegi/auth": "workspace:*",
|
||||||
"@zendegi/db": "workspace:*",
|
"@zendegi/db": "workspace:*",
|
||||||
"@zendegi/env": "workspace:*",
|
"@zendegi/env": "workspace:*",
|
||||||
"@zendegi/z-timeline": "workspace:*",
|
"@zendegi/z-flutter": "workspace:*",
|
||||||
"better-auth": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "catalog:",
|
"dotenv": "catalog:",
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
* The bridge uses a **normalized** shape: groups and items are stored as
|
* The bridge uses a **normalized** shape: groups and items are stored as
|
||||||
* Record maps keyed by ID, with `groupOrder` preserving display order.
|
* 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`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# z_timeline
|
# z_flutter
|
||||||
|
|
||||||
A new Flutter project.
|
A new Flutter project.
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:z_timeline/z_timeline.dart';
|
||||||
|
|
||||||
import 'bridge.dart';
|
import 'bridge.dart';
|
||||||
import 'state.dart';
|
import 'state.dart';
|
||||||
import 'timeline.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const MainApp());
|
runApp(const MainApp());
|
||||||
@@ -22,6 +23,12 @@ class _MainAppState extends State<MainApp> {
|
|||||||
TimelineViewportNotifier? _viewport;
|
TimelineViewportNotifier? _viewport;
|
||||||
bool _darkMode = true;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -127,7 +134,7 @@ class _MainAppState extends State<MainApp> {
|
|||||||
final duration = entry.end.difference(entry.start);
|
final duration = entry.end.difference(entry.start);
|
||||||
final newEnd = entry.hasEnd ? newStart.add(duration) : null;
|
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?) {
|
if (_state case final state?) {
|
||||||
final oldItem = state.items[entry.id];
|
final oldItem = state.items[entry.id];
|
||||||
if (oldItem != null) {
|
if (oldItem != null) {
|
||||||
@@ -147,6 +154,7 @@ class _MainAppState extends State<MainApp> {
|
|||||||
items: updatedItems,
|
items: updatedItems,
|
||||||
groupOrder: state.groupOrder,
|
groupOrder: state.groupOrder,
|
||||||
selectedItemId: state.selectedItemId,
|
selectedItemId: state.selectedItemId,
|
||||||
|
darkMode: state.darkMode,
|
||||||
);
|
);
|
||||||
_applyState(updatedState);
|
_applyState(updatedState);
|
||||||
}
|
}
|
||||||
@@ -162,7 +170,9 @@ class _MainAppState extends State<MainApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _emitContentHeight() {
|
void _emitContentHeight() {
|
||||||
var totalHeight = 0.0;
|
// Start with the fixed chrome heights.
|
||||||
|
var totalHeight = _tieredHeaderHeight + _breadcrumbHeight;
|
||||||
|
|
||||||
for (final group in _groups) {
|
for (final group in _groups) {
|
||||||
totalHeight += ZTimelineConstants.groupHeaderHeight;
|
totalHeight += ZTimelineConstants.groupHeaderHeight;
|
||||||
final groupEntries = _entries.where((e) => e.groupId == group.id);
|
final groupEntries = _entries.where((e) => e.groupId == group.id);
|
||||||
@@ -218,16 +228,24 @@ class _MainAppState extends State<MainApp> {
|
|||||||
? const Center(child: Text('Waiting for state...'))
|
? const Center(child: Text('Waiting for state...'))
|
||||||
: ZTimelineScope(
|
: ZTimelineScope(
|
||||||
viewport: viewport,
|
viewport: viewport,
|
||||||
child: ZTimelineInteractor(
|
child: Column(
|
||||||
child: ZTimelineView(
|
children: [
|
||||||
groups: _groups,
|
const ZTimelineBreadcrumb(),
|
||||||
entries: _entries,
|
const ZTimelineTieredHeader(),
|
||||||
viewport: viewport,
|
Expanded(
|
||||||
labelBuilder: _labelForEntry,
|
child: ZTimelineInteractor(
|
||||||
colorBuilder: _colorForEntry,
|
child: ZTimelineView(
|
||||||
enableDrag: true,
|
groups: _groups,
|
||||||
onEntryMoved: _onEntryMoved,
|
entries: _entries,
|
||||||
),
|
viewport: viewport,
|
||||||
|
labelBuilder: _labelForEntry,
|
||||||
|
colorBuilder: _colorForEntry,
|
||||||
|
enableDrag: true,
|
||||||
|
onEntryMoved: _onEntryMoved,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@zendegi/z-timeline",
|
"name": "@zendegi/z-flutter",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "flutter build web --release --wasm --base-href /flutter/ && node scripts/copy-build.mjs"
|
"build": "flutter build web --release --wasm --base-href /flutter/ && node scripts/copy-build.mjs"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Type of temporal segment in breadcrumb.
|
||||||
|
///
|
||||||
|
/// Segments are displayed in hierarchical order: Year > Quarter > Month > Week
|
||||||
|
enum BreadcrumbSegmentType { year, quarter, month, week }
|
||||||
|
|
||||||
|
/// Navigation target for breadcrumb click.
|
||||||
|
///
|
||||||
|
/// Defines where the viewport should navigate when a breadcrumb segment
|
||||||
|
/// is clicked. The viewport will be centered on [centerDate] with
|
||||||
|
/// [daysVisible] days shown.
|
||||||
|
@immutable
|
||||||
|
class BreadcrumbNavigationTarget {
|
||||||
|
const BreadcrumbNavigationTarget({
|
||||||
|
required this.centerDate,
|
||||||
|
required this.daysVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Center date for the new viewport.
|
||||||
|
final DateTime centerDate;
|
||||||
|
|
||||||
|
/// Number of days to show in viewport.
|
||||||
|
final int daysVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BreadcrumbNavigationTarget &&
|
||||||
|
centerDate == other.centerDate &&
|
||||||
|
daysVisible == other.daysVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(centerDate, daysVisible);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'BreadcrumbNavigationTarget(centerDate: $centerDate, daysVisible: $daysVisible)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable model for a breadcrumb segment.
|
||||||
|
///
|
||||||
|
/// Represents a single segment in the breadcrumb trail (e.g., "2024", "Q1",
|
||||||
|
/// "March", "W12"). Each segment knows its type, display label, navigation
|
||||||
|
/// target, and whether it should be visible at the current zoom level.
|
||||||
|
@immutable
|
||||||
|
class BreadcrumbSegment {
|
||||||
|
const BreadcrumbSegment({
|
||||||
|
required this.type,
|
||||||
|
required this.label,
|
||||||
|
required this.navigationTarget,
|
||||||
|
required this.isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Type of temporal unit (year, quarter, month, week).
|
||||||
|
final BreadcrumbSegmentType type;
|
||||||
|
|
||||||
|
/// Display label (e.g., "2024", "Q1-Q2", "March", "Mar-Apr").
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Navigation target when clicked (center date and days visible).
|
||||||
|
final BreadcrumbNavigationTarget navigationTarget;
|
||||||
|
|
||||||
|
/// Whether this segment should be visible at current zoom level.
|
||||||
|
final bool isVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is BreadcrumbSegment &&
|
||||||
|
type == other.type &&
|
||||||
|
label == other.label &&
|
||||||
|
navigationTarget == other.navigationTarget &&
|
||||||
|
isVisible == other.isVisible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(type, label, navigationTarget, isVisible);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'BreadcrumbSegment(type: $type, label: $label, isVisible: $isVisible)';
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/// Time units for tiered tick generation.
|
||||||
|
///
|
||||||
|
/// These units represent different granularities of time, from hours to
|
||||||
|
/// centuries. Each unit can be used for aligning timestamps and generating
|
||||||
|
/// tier sections.
|
||||||
|
enum TierUnit {
|
||||||
|
/// 1-hour intervals
|
||||||
|
hour,
|
||||||
|
|
||||||
|
/// 3-hour intervals
|
||||||
|
hour3,
|
||||||
|
|
||||||
|
/// 6-hour intervals
|
||||||
|
hour6,
|
||||||
|
|
||||||
|
/// 1-day intervals
|
||||||
|
day,
|
||||||
|
|
||||||
|
/// 2-day intervals
|
||||||
|
day2,
|
||||||
|
|
||||||
|
/// 7-day (week) intervals, aligned to Sunday
|
||||||
|
week,
|
||||||
|
|
||||||
|
/// 1-month intervals
|
||||||
|
month,
|
||||||
|
|
||||||
|
/// 3-month (quarter) intervals
|
||||||
|
quarter,
|
||||||
|
|
||||||
|
/// 1-year intervals
|
||||||
|
year,
|
||||||
|
|
||||||
|
/// 2-year intervals
|
||||||
|
year2,
|
||||||
|
|
||||||
|
/// 5-year intervals
|
||||||
|
year5,
|
||||||
|
|
||||||
|
/// 10-year (decade) intervals
|
||||||
|
decade,
|
||||||
|
|
||||||
|
/// 20-year intervals
|
||||||
|
year20,
|
||||||
|
|
||||||
|
/// 100-year (century) intervals
|
||||||
|
century,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label format options for tier sections.
|
||||||
|
///
|
||||||
|
/// Determines how dates are formatted for display in tier headers.
|
||||||
|
enum TierLabelFormat {
|
||||||
|
/// Day name with month and day number (e.g., "Wed, Mar 15")
|
||||||
|
dayWithWeekday,
|
||||||
|
|
||||||
|
/// Hour with AM/PM (e.g., "9 AM")
|
||||||
|
hourAmPm,
|
||||||
|
|
||||||
|
/// Day number only (e.g., "15")
|
||||||
|
dayNumber,
|
||||||
|
|
||||||
|
/// Month abbreviated (e.g., "Mar")
|
||||||
|
monthShort,
|
||||||
|
|
||||||
|
/// Month and year (e.g., "March 2024")
|
||||||
|
monthLongYear,
|
||||||
|
|
||||||
|
/// Year only (e.g., "2024")
|
||||||
|
yearOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Defines a single tier within a tier configuration.
|
||||||
|
///
|
||||||
|
/// Each tier configuration has two tiers - a primary (coarser) tier and
|
||||||
|
/// a secondary (finer) tier.
|
||||||
|
class TierDefinition {
|
||||||
|
const TierDefinition({required this.unit, required this.format});
|
||||||
|
|
||||||
|
/// The time unit for this tier.
|
||||||
|
final TierUnit unit;
|
||||||
|
|
||||||
|
/// The label format for sections in this tier.
|
||||||
|
final TierLabelFormat format;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a specific zoom level.
|
||||||
|
///
|
||||||
|
/// Each configuration maps a range of msPerPixel values to a pair of tiers
|
||||||
|
/// that should be displayed at that zoom level.
|
||||||
|
class TierConfig {
|
||||||
|
/// Creates a tier configuration.
|
||||||
|
///
|
||||||
|
/// The [tiers] list must contain exactly 2 tiers: primary (index 0) and
|
||||||
|
/// secondary (index 1).
|
||||||
|
const TierConfig({
|
||||||
|
required this.name,
|
||||||
|
required this.minMsPerPixel,
|
||||||
|
required this.maxMsPerPixel,
|
||||||
|
required this.tiers,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Display name for this configuration (e.g., "hours", "days", "months").
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Minimum msPerPixel for this configuration (inclusive).
|
||||||
|
final double minMsPerPixel;
|
||||||
|
|
||||||
|
/// Maximum msPerPixel for this configuration (exclusive).
|
||||||
|
/// Use double.infinity for the last configuration.
|
||||||
|
final double maxMsPerPixel;
|
||||||
|
|
||||||
|
/// The two tiers to display: [0] is primary (top), [1] is secondary (bottom).
|
||||||
|
final List<TierDefinition> tiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Predefined tier configurations for all zoom levels.
|
||||||
|
///
|
||||||
|
/// These configurations are based on the React TimeTickService implementation.
|
||||||
|
/// The msPerPixel ranges determine which configuration is selected based on
|
||||||
|
/// the current viewport zoom level.
|
||||||
|
const List<TierConfig> defaultTierConfigs = [
|
||||||
|
// Hours: < 1 min/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'hours',
|
||||||
|
minMsPerPixel: 0,
|
||||||
|
maxMsPerPixel: 60000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(
|
||||||
|
unit: TierUnit.day,
|
||||||
|
format: TierLabelFormat.dayWithWeekday,
|
||||||
|
),
|
||||||
|
TierDefinition(unit: TierUnit.hour, format: TierLabelFormat.hourAmPm),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Hours sparse: 1-3 min/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'hours-sparse',
|
||||||
|
minMsPerPixel: 60000,
|
||||||
|
maxMsPerPixel: 180000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(
|
||||||
|
unit: TierUnit.day,
|
||||||
|
format: TierLabelFormat.dayWithWeekday,
|
||||||
|
),
|
||||||
|
TierDefinition(unit: TierUnit.hour3, format: TierLabelFormat.hourAmPm),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Days: 3-30 min/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'days',
|
||||||
|
minMsPerPixel: 180000,
|
||||||
|
maxMsPerPixel: 1800000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(
|
||||||
|
unit: TierUnit.month,
|
||||||
|
format: TierLabelFormat.monthLongYear,
|
||||||
|
),
|
||||||
|
TierDefinition(unit: TierUnit.day, format: TierLabelFormat.dayNumber),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Days sparse: 30-90 min/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'days-sparse',
|
||||||
|
minMsPerPixel: 1800000,
|
||||||
|
maxMsPerPixel: 5400000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(
|
||||||
|
unit: TierUnit.month,
|
||||||
|
format: TierLabelFormat.monthLongYear,
|
||||||
|
),
|
||||||
|
TierDefinition(unit: TierUnit.day2, format: TierLabelFormat.dayNumber),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Weeks: 1.5-4 hours/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'weeks',
|
||||||
|
minMsPerPixel: 5400000,
|
||||||
|
maxMsPerPixel: 14400000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(
|
||||||
|
unit: TierUnit.month,
|
||||||
|
format: TierLabelFormat.monthLongYear,
|
||||||
|
),
|
||||||
|
TierDefinition(unit: TierUnit.week, format: TierLabelFormat.dayNumber),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Months: 4-24 hours/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'months',
|
||||||
|
minMsPerPixel: 14400000,
|
||||||
|
maxMsPerPixel: 86400000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(unit: TierUnit.year, format: TierLabelFormat.yearOnly),
|
||||||
|
TierDefinition(unit: TierUnit.month, format: TierLabelFormat.monthShort),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Months sparse: 1-3 days/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'months-sparse',
|
||||||
|
minMsPerPixel: 86400000,
|
||||||
|
maxMsPerPixel: 259200000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(unit: TierUnit.year, format: TierLabelFormat.yearOnly),
|
||||||
|
TierDefinition(
|
||||||
|
unit: TierUnit.quarter,
|
||||||
|
format: TierLabelFormat.monthShort,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Years: 3-10 days/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'years',
|
||||||
|
minMsPerPixel: 259200000,
|
||||||
|
maxMsPerPixel: 864000000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(unit: TierUnit.decade, format: TierLabelFormat.yearOnly),
|
||||||
|
TierDefinition(unit: TierUnit.year, format: TierLabelFormat.yearOnly),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Years sparse: 10-30 days/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'years-sparse',
|
||||||
|
minMsPerPixel: 864000000,
|
||||||
|
maxMsPerPixel: 2592000000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(unit: TierUnit.decade, format: TierLabelFormat.yearOnly),
|
||||||
|
TierDefinition(unit: TierUnit.year2, format: TierLabelFormat.yearOnly),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Decades: 30-100 days/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'decades',
|
||||||
|
minMsPerPixel: 2592000000,
|
||||||
|
maxMsPerPixel: 8640000000,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(unit: TierUnit.century, format: TierLabelFormat.yearOnly),
|
||||||
|
TierDefinition(unit: TierUnit.decade, format: TierLabelFormat.yearOnly),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Decades sparse: >100 days/px
|
||||||
|
TierConfig(
|
||||||
|
name: 'decades-sparse',
|
||||||
|
minMsPerPixel: 8640000000,
|
||||||
|
maxMsPerPixel: double.infinity,
|
||||||
|
tiers: [
|
||||||
|
TierDefinition(unit: TierUnit.century, format: TierLabelFormat.yearOnly),
|
||||||
|
TierDefinition(unit: TierUnit.year20, format: TierLabelFormat.yearOnly),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'tier_config.dart';
|
||||||
|
|
||||||
|
/// Represents a single section in a tier row.
|
||||||
|
///
|
||||||
|
/// Each section spans a time range and displays a label. Sections are
|
||||||
|
/// generated based on the [TierUnit] and cover the visible time domain.
|
||||||
|
class TierSection {
|
||||||
|
const TierSection({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.label,
|
||||||
|
required this.unit,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The start time of this section (inclusive).
|
||||||
|
final DateTime start;
|
||||||
|
|
||||||
|
/// The end time of this section (exclusive, start of next section).
|
||||||
|
final DateTime end;
|
||||||
|
|
||||||
|
/// The formatted label to display for this section.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// The time unit this section represents, used for identification.
|
||||||
|
final TierUnit unit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'TierSection(start: $start, end: $end, label: $label, unit: $unit)';
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'tier_config.dart';
|
||||||
|
import 'tier_section.dart';
|
||||||
|
|
||||||
|
/// A single row of tier sections.
|
||||||
|
///
|
||||||
|
/// Each [TieredTickData] contains two [TierRow]s - one for the primary
|
||||||
|
/// (coarser) tier and one for the secondary (finer) tier.
|
||||||
|
class TierRow {
|
||||||
|
const TierRow({required this.unit, required this.sections});
|
||||||
|
|
||||||
|
/// The time unit for this tier row.
|
||||||
|
final TierUnit unit;
|
||||||
|
|
||||||
|
/// The sections in this tier row, ordered by time.
|
||||||
|
final List<TierSection> sections;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'TierRow(unit: $unit, sections: ${sections.length} sections)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete tier data for the visible time range.
|
||||||
|
///
|
||||||
|
/// Generated by [TieredTickService.generateTiers] based on the viewport
|
||||||
|
/// domain and zoom level. Contains exactly two tier rows.
|
||||||
|
class TieredTickData {
|
||||||
|
const TieredTickData({required this.configName, required this.tiers})
|
||||||
|
: assert(tiers.length == 2, 'TieredTickData must have exactly 2 tiers');
|
||||||
|
|
||||||
|
/// The name of the tier configuration used (e.g., "hours", "days", "months").
|
||||||
|
final String configName;
|
||||||
|
|
||||||
|
/// The two tier rows: [0] is primary (top), [1] is secondary (bottom).
|
||||||
|
final List<TierRow> tiers;
|
||||||
|
|
||||||
|
/// The primary (top) tier row with coarser time units.
|
||||||
|
TierRow get primaryTier => tiers[0];
|
||||||
|
|
||||||
|
/// The secondary (bottom) tier row with finer time units.
|
||||||
|
TierRow get secondaryTier => tiers[1];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'TieredTickData(configName: $configName, tiers: ${tiers.length})';
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// Zoom level determined by visible days in viewport.
|
||||||
|
///
|
||||||
|
/// Each level corresponds to a different temporal granularity for the
|
||||||
|
/// breadcrumb display. As users zoom in, more granular segments appear.
|
||||||
|
enum ZoomLevel {
|
||||||
|
/// ≤14 days visible - shows Year, Quarter, Month, Week
|
||||||
|
days,
|
||||||
|
|
||||||
|
/// ≤60 days visible - shows Year, Quarter, Month
|
||||||
|
weeks,
|
||||||
|
|
||||||
|
/// ≤180 days visible - shows Year, Quarter
|
||||||
|
months,
|
||||||
|
|
||||||
|
/// ≤730 days visible - shows Year only
|
||||||
|
quarters,
|
||||||
|
|
||||||
|
/// >730 days visible - shows Year only
|
||||||
|
years;
|
||||||
|
|
||||||
|
/// Display label for the zoom level indicator.
|
||||||
|
String get label => name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Threshold constants for zoom level determination.
|
||||||
|
///
|
||||||
|
/// These values define the boundaries between zoom levels based on
|
||||||
|
/// the number of days visible in the viewport.
|
||||||
|
class ZoomLevelThresholds {
|
||||||
|
const ZoomLevelThresholds._();
|
||||||
|
|
||||||
|
/// Maximum days for "days" zoom level (shows week segment)
|
||||||
|
static const int daysThreshold = 14;
|
||||||
|
|
||||||
|
/// Maximum days for "weeks" zoom level (shows month segment)
|
||||||
|
static const int weeksThreshold = 60;
|
||||||
|
|
||||||
|
/// Maximum days for "months" zoom level (shows quarter segment)
|
||||||
|
static const int monthsThreshold = 180;
|
||||||
|
|
||||||
|
/// Maximum days for "quarters" zoom level (shows year segment)
|
||||||
|
static const int quartersThreshold = 730;
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import '../models/breadcrumb_segment.dart';
|
||||||
|
import '../models/zoom_level.dart';
|
||||||
|
|
||||||
|
/// Pure static functions for breadcrumb calculations.
|
||||||
|
///
|
||||||
|
/// This service handles all logic for determining zoom levels, generating
|
||||||
|
/// segment labels, and calculating navigation targets. All methods are
|
||||||
|
/// pure functions with no side effects, following the same pattern as
|
||||||
|
/// [LayoutCoordinateService] and [TimeScaleService].
|
||||||
|
class BreadcrumbService {
|
||||||
|
const BreadcrumbService._();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Zoom Level Detection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Calculate visible days from viewport domain.
|
||||||
|
///
|
||||||
|
/// Returns the number of whole days between [start] and [end].
|
||||||
|
static int calculateVisibleDays(DateTime start, DateTime end) {
|
||||||
|
return end.difference(start).inDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine zoom level from visible days.
|
||||||
|
///
|
||||||
|
/// Uses [ZoomLevelThresholds] to map days to the appropriate zoom level.
|
||||||
|
static ZoomLevel determineZoomLevel(int visibleDays) {
|
||||||
|
if (visibleDays <= ZoomLevelThresholds.daysThreshold) return ZoomLevel.days;
|
||||||
|
if (visibleDays <= ZoomLevelThresholds.weeksThreshold) {
|
||||||
|
return ZoomLevel.weeks;
|
||||||
|
}
|
||||||
|
if (visibleDays <= ZoomLevelThresholds.monthsThreshold) {
|
||||||
|
return ZoomLevel.months;
|
||||||
|
}
|
||||||
|
if (visibleDays <= ZoomLevelThresholds.quartersThreshold) {
|
||||||
|
return ZoomLevel.quarters;
|
||||||
|
}
|
||||||
|
return ZoomLevel.years;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Segment Visibility
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Determine if year segment is visible.
|
||||||
|
///
|
||||||
|
/// Year is always visible regardless of zoom level.
|
||||||
|
static bool isYearVisible(ZoomLevel level) => true;
|
||||||
|
|
||||||
|
/// Determine if quarter segment is visible.
|
||||||
|
///
|
||||||
|
/// Visible at all levels except "years" (>730 days).
|
||||||
|
static bool isQuarterVisible(ZoomLevel level) {
|
||||||
|
return level != ZoomLevel.years;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine if month segment is visible.
|
||||||
|
///
|
||||||
|
/// Visible at "days", "weeks", and "months" levels (≤180 days).
|
||||||
|
static bool isMonthVisible(ZoomLevel level) {
|
||||||
|
return level == ZoomLevel.days ||
|
||||||
|
level == ZoomLevel.weeks ||
|
||||||
|
level == ZoomLevel.months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine if week segment is visible.
|
||||||
|
///
|
||||||
|
/// Only visible at "days" level (≤14 days).
|
||||||
|
static bool isWeekVisible(ZoomLevel level) {
|
||||||
|
return level == ZoomLevel.days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Label Generation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generate year label.
|
||||||
|
///
|
||||||
|
/// Returns single year if both dates are in the same year (e.g., "2024"),
|
||||||
|
/// or a range if they span years (e.g., "2024-2025").
|
||||||
|
static String generateYearLabel(DateTime start, DateTime end) {
|
||||||
|
if (start.year == end.year) {
|
||||||
|
return '${start.year}';
|
||||||
|
}
|
||||||
|
return '${start.year}-${end.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate quarter label.
|
||||||
|
///
|
||||||
|
/// Returns single quarter if both dates are in the same quarter and year
|
||||||
|
/// (e.g., "Q1"), or a range if they span quarters (e.g., "Q1-Q2").
|
||||||
|
static String generateQuarterLabel(DateTime start, DateTime end) {
|
||||||
|
final startQ = _getQuarter(start);
|
||||||
|
final endQ = _getQuarter(end);
|
||||||
|
if (startQ == endQ && start.year == end.year) {
|
||||||
|
return 'Q$startQ';
|
||||||
|
}
|
||||||
|
return 'Q$startQ-Q$endQ';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate month label.
|
||||||
|
///
|
||||||
|
/// Returns full month name if both dates are in the same month and year
|
||||||
|
/// (e.g., "March"), or abbreviated range if they span months (e.g., "Mar-Apr").
|
||||||
|
static String generateMonthLabel(DateTime start, DateTime end) {
|
||||||
|
if (start.month == end.month && start.year == end.year) {
|
||||||
|
return _getMonthName(start.month);
|
||||||
|
}
|
||||||
|
return '${_getMonthAbbrev(start.month)}-${_getMonthAbbrev(end.month)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate week label.
|
||||||
|
///
|
||||||
|
/// Returns single week if both dates are in the same ISO week
|
||||||
|
/// (e.g., "W12"), or a range if they span weeks (e.g., "W12-W14").
|
||||||
|
static String generateWeekLabel(DateTime start, DateTime end) {
|
||||||
|
final startWeek = _getWeekNumber(start);
|
||||||
|
final endWeek = _getWeekNumber(end);
|
||||||
|
if (startWeek == endWeek) {
|
||||||
|
return 'W$startWeek';
|
||||||
|
}
|
||||||
|
return 'W$startWeek-W$endWeek';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Navigation Target Calculation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Calculate navigation target for year click.
|
||||||
|
///
|
||||||
|
/// Centers on July 1st of the relevant year with 365 days visible.
|
||||||
|
static BreadcrumbNavigationTarget calculateYearTarget(
|
||||||
|
DateTime viewportCenter,
|
||||||
|
) {
|
||||||
|
final centerDate = DateTime(viewportCenter.year, 7, 1);
|
||||||
|
return BreadcrumbNavigationTarget(centerDate: centerDate, daysVisible: 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate navigation target for quarter click.
|
||||||
|
///
|
||||||
|
/// Centers on the 15th of the middle month of the quarter with 90 days visible.
|
||||||
|
/// Q1 (Jan-Mar) → Feb 15, Q2 (Apr-Jun) → May 15,
|
||||||
|
/// Q3 (Jul-Sep) → Aug 15, Q4 (Oct-Dec) → Nov 15.
|
||||||
|
static BreadcrumbNavigationTarget calculateQuarterTarget(
|
||||||
|
DateTime viewportCenter,
|
||||||
|
) {
|
||||||
|
final quarter = _getQuarter(viewportCenter);
|
||||||
|
// Middle month: Q1→Feb(2), Q2→May(5), Q3→Aug(8), Q4→Nov(11)
|
||||||
|
final middleMonth = (quarter - 1) * 3 + 2;
|
||||||
|
final centerDate = DateTime(viewportCenter.year, middleMonth, 15);
|
||||||
|
return BreadcrumbNavigationTarget(centerDate: centerDate, daysVisible: 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate navigation target for month click.
|
||||||
|
///
|
||||||
|
/// Centers on the 15th of the month with 30 days visible.
|
||||||
|
static BreadcrumbNavigationTarget calculateMonthTarget(
|
||||||
|
DateTime viewportCenter,
|
||||||
|
) {
|
||||||
|
final centerDate = DateTime(viewportCenter.year, viewportCenter.month, 15);
|
||||||
|
return BreadcrumbNavigationTarget(centerDate: centerDate, daysVisible: 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate navigation target for week click.
|
||||||
|
///
|
||||||
|
/// Centers on the viewport center with 7 days visible.
|
||||||
|
static BreadcrumbNavigationTarget calculateWeekTarget(
|
||||||
|
DateTime viewportCenter,
|
||||||
|
) {
|
||||||
|
return BreadcrumbNavigationTarget(
|
||||||
|
centerDate: viewportCenter,
|
||||||
|
daysVisible: 7,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Full Segment Calculation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Calculate all breadcrumb segments for the current viewport.
|
||||||
|
///
|
||||||
|
/// Returns segments in hierarchical order: Year > Quarter > Month > Week.
|
||||||
|
/// Each segment includes a visibility flag for animation control.
|
||||||
|
///
|
||||||
|
/// The [start] and [end] parameters define the visible time domain.
|
||||||
|
static List<BreadcrumbSegment> calculateSegments({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
}) {
|
||||||
|
final visibleDays = calculateVisibleDays(start, end);
|
||||||
|
final zoomLevel = determineZoomLevel(visibleDays);
|
||||||
|
final center = start.add(Duration(days: visibleDays ~/ 2));
|
||||||
|
|
||||||
|
return [
|
||||||
|
BreadcrumbSegment(
|
||||||
|
type: BreadcrumbSegmentType.year,
|
||||||
|
label: generateYearLabel(start, end),
|
||||||
|
navigationTarget: calculateYearTarget(center),
|
||||||
|
isVisible: isYearVisible(zoomLevel),
|
||||||
|
),
|
||||||
|
BreadcrumbSegment(
|
||||||
|
type: BreadcrumbSegmentType.quarter,
|
||||||
|
label: generateQuarterLabel(start, end),
|
||||||
|
navigationTarget: calculateQuarterTarget(center),
|
||||||
|
isVisible: isQuarterVisible(zoomLevel),
|
||||||
|
),
|
||||||
|
BreadcrumbSegment(
|
||||||
|
type: BreadcrumbSegmentType.month,
|
||||||
|
label: generateMonthLabel(start, end),
|
||||||
|
navigationTarget: calculateMonthTarget(center),
|
||||||
|
isVisible: isMonthVisible(zoomLevel),
|
||||||
|
),
|
||||||
|
BreadcrumbSegment(
|
||||||
|
type: BreadcrumbSegmentType.week,
|
||||||
|
label: generateWeekLabel(start, end),
|
||||||
|
navigationTarget: calculateWeekTarget(center),
|
||||||
|
isVisible: isWeekVisible(zoomLevel),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the domain (start, end) from a navigation target.
|
||||||
|
///
|
||||||
|
/// Used when user clicks a breadcrumb segment to navigate.
|
||||||
|
/// Returns a record with the new viewport start and end dates.
|
||||||
|
static ({DateTime start, DateTime end}) calculateDomainFromTarget(
|
||||||
|
BreadcrumbNavigationTarget target,
|
||||||
|
) {
|
||||||
|
final halfDays = target.daysVisible ~/ 2;
|
||||||
|
final start = target.centerDate.subtract(Duration(days: halfDays));
|
||||||
|
final end = target.centerDate.add(
|
||||||
|
Duration(days: target.daysVisible - halfDays),
|
||||||
|
);
|
||||||
|
return (start: start, end: end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Private Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Get quarter number (1-4) from a date.
|
||||||
|
static int _getQuarter(DateTime date) => ((date.month - 1) ~/ 3) + 1;
|
||||||
|
|
||||||
|
/// Get ISO week number from a date.
|
||||||
|
///
|
||||||
|
/// Simplified calculation - returns week of year (1-53).
|
||||||
|
static int _getWeekNumber(DateTime date) {
|
||||||
|
final firstDayOfYear = DateTime(date.year, 1, 1);
|
||||||
|
final daysDiff = date.difference(firstDayOfYear).inDays;
|
||||||
|
return (daysDiff / 7).floor() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get full month name.
|
||||||
|
static String _getMonthName(int month) {
|
||||||
|
const names = [
|
||||||
|
'',
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
];
|
||||||
|
return names[month];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get abbreviated month name.
|
||||||
|
static String _getMonthAbbrev(int month) {
|
||||||
|
const names = [
|
||||||
|
'',
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
return names[month];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import '../models/tier_config.dart';
|
||||||
|
import '../models/tier_section.dart';
|
||||||
|
import '../models/tiered_tick_data.dart';
|
||||||
|
|
||||||
|
/// Pure static functions for tiered tick generation.
|
||||||
|
///
|
||||||
|
/// This service handles all logic for generating multi-tier timeline headers.
|
||||||
|
/// It provides methods for aligning timestamps to unit boundaries, calculating
|
||||||
|
/// unit ends, formatting labels, and generating complete tier data.
|
||||||
|
///
|
||||||
|
/// All methods are pure functions with no side effects.
|
||||||
|
class TieredTickService {
|
||||||
|
const TieredTickService._();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Constants
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fixed epoch for deterministic alignment (Jan 1, 2000 00:00:00 UTC).
|
||||||
|
///
|
||||||
|
/// This epoch is used to ensure consistent alignment across different
|
||||||
|
/// time ranges, particularly for units that don't have natural boundaries
|
||||||
|
/// (like 3-hour or 2-day intervals).
|
||||||
|
static final DateTime epoch = DateTime.utc(2000, 1, 1);
|
||||||
|
|
||||||
|
/// Milliseconds in one hour.
|
||||||
|
static const int msHour = 3600000;
|
||||||
|
|
||||||
|
/// Milliseconds in one day.
|
||||||
|
static const int msDay = 86400000;
|
||||||
|
|
||||||
|
/// Milliseconds in one week.
|
||||||
|
static const int msWeek = msDay * 7;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tier Config Selection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Select the appropriate tier configuration based on zoom level.
|
||||||
|
///
|
||||||
|
/// The [msPerPixel] value determines which configuration is selected.
|
||||||
|
/// Lower values mean more zoomed in (showing finer time units),
|
||||||
|
/// higher values mean more zoomed out (showing coarser time units).
|
||||||
|
static TierConfig selectTierConfig(double msPerPixel) {
|
||||||
|
for (final config in defaultTierConfigs) {
|
||||||
|
if (msPerPixel >= config.minMsPerPixel &&
|
||||||
|
msPerPixel < config.maxMsPerPixel) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to the last (most zoomed out) configuration
|
||||||
|
return defaultTierConfigs.last;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Time Alignment
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Align a timestamp to a unit boundary.
|
||||||
|
///
|
||||||
|
/// Returns the start of the time period containing [time] for the given
|
||||||
|
/// [unit]. For example, aligning to [TierUnit.day] returns midnight UTC
|
||||||
|
/// of that day.
|
||||||
|
static DateTime alignToUnit(DateTime time, TierUnit unit) {
|
||||||
|
final timeMs = time.toUtc().millisecondsSinceEpoch;
|
||||||
|
final epochMs = epoch.millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case TierUnit.hour:
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
epochMs + ((timeMs - epochMs) ~/ msHour) * msHour,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TierUnit.hour3:
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
epochMs + ((timeMs - epochMs) ~/ (msHour * 3)) * msHour * 3,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TierUnit.hour6:
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
epochMs + ((timeMs - epochMs) ~/ (msHour * 6)) * msHour * 6,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TierUnit.day:
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
epochMs + ((timeMs - epochMs) ~/ msDay) * msDay,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TierUnit.day2:
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
epochMs + ((timeMs - epochMs) ~/ (msDay * 2)) * msDay * 2,
|
||||||
|
isUtc: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
case TierUnit.week:
|
||||||
|
// Weeks start on Sunday. Calculate days to go back to reach Sunday.
|
||||||
|
final date = time.toUtc();
|
||||||
|
// Dart weekday: Monday=1, Sunday=7
|
||||||
|
// To get to previous Sunday: if Sunday, go back 0; if Monday, go back 1; etc.
|
||||||
|
final daysBack = date.weekday % 7; // Sunday(7)%7=0, Monday(1)%7=1, etc.
|
||||||
|
return DateTime.utc(date.year, date.month, date.day - daysBack);
|
||||||
|
|
||||||
|
case TierUnit.month:
|
||||||
|
final date = time.toUtc();
|
||||||
|
return DateTime.utc(date.year, date.month);
|
||||||
|
|
||||||
|
case TierUnit.quarter:
|
||||||
|
final date = time.toUtc();
|
||||||
|
final quarterMonth = ((date.month - 1) ~/ 3) * 3 + 1;
|
||||||
|
return DateTime.utc(date.year, quarterMonth);
|
||||||
|
|
||||||
|
case TierUnit.year:
|
||||||
|
return DateTime.utc(time.toUtc().year);
|
||||||
|
|
||||||
|
case TierUnit.year2:
|
||||||
|
final year = time.toUtc().year;
|
||||||
|
return DateTime.utc((year ~/ 2) * 2);
|
||||||
|
|
||||||
|
case TierUnit.year5:
|
||||||
|
final year = time.toUtc().year;
|
||||||
|
return DateTime.utc((year ~/ 5) * 5);
|
||||||
|
|
||||||
|
case TierUnit.decade:
|
||||||
|
final year = time.toUtc().year;
|
||||||
|
return DateTime.utc((year ~/ 10) * 10);
|
||||||
|
|
||||||
|
case TierUnit.year20:
|
||||||
|
final year = time.toUtc().year;
|
||||||
|
return DateTime.utc((year ~/ 20) * 20);
|
||||||
|
|
||||||
|
case TierUnit.century:
|
||||||
|
final year = time.toUtc().year;
|
||||||
|
return DateTime.utc((year ~/ 100) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the end of a unit period (start of the next period).
|
||||||
|
///
|
||||||
|
/// Returns the timestamp that marks the boundary between the period
|
||||||
|
/// containing [time] and the next period.
|
||||||
|
static DateTime getUnitEnd(DateTime time, TierUnit unit) {
|
||||||
|
final aligned = alignToUnit(time, unit);
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case TierUnit.hour:
|
||||||
|
return aligned.add(const Duration(hours: 1));
|
||||||
|
|
||||||
|
case TierUnit.hour3:
|
||||||
|
return aligned.add(const Duration(hours: 3));
|
||||||
|
|
||||||
|
case TierUnit.hour6:
|
||||||
|
return aligned.add(const Duration(hours: 6));
|
||||||
|
|
||||||
|
case TierUnit.day:
|
||||||
|
return aligned.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
case TierUnit.day2:
|
||||||
|
return aligned.add(const Duration(days: 2));
|
||||||
|
|
||||||
|
case TierUnit.week:
|
||||||
|
return aligned.add(const Duration(days: 7));
|
||||||
|
|
||||||
|
case TierUnit.month:
|
||||||
|
return DateTime.utc(aligned.year, aligned.month + 1);
|
||||||
|
|
||||||
|
case TierUnit.quarter:
|
||||||
|
return DateTime.utc(aligned.year, aligned.month + 3);
|
||||||
|
|
||||||
|
case TierUnit.year:
|
||||||
|
return DateTime.utc(aligned.year + 1);
|
||||||
|
|
||||||
|
case TierUnit.year2:
|
||||||
|
return DateTime.utc(aligned.year + 2);
|
||||||
|
|
||||||
|
case TierUnit.year5:
|
||||||
|
return DateTime.utc(aligned.year + 5);
|
||||||
|
|
||||||
|
case TierUnit.decade:
|
||||||
|
return DateTime.utc(aligned.year + 10);
|
||||||
|
|
||||||
|
case TierUnit.year20:
|
||||||
|
return DateTime.utc(aligned.year + 20);
|
||||||
|
|
||||||
|
case TierUnit.century:
|
||||||
|
return DateTime.utc(aligned.year + 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Label Formatting
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Format a timestamp for display.
|
||||||
|
///
|
||||||
|
/// Uses the [format] to determine the output format for the given [time].
|
||||||
|
static String formatTime(DateTime time, TierLabelFormat format) {
|
||||||
|
final utc = time.toUtc();
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case TierLabelFormat.dayWithWeekday:
|
||||||
|
return '${_getWeekdayShort(utc.weekday)}, ${_getMonthShort(utc.month)} ${utc.day}';
|
||||||
|
|
||||||
|
case TierLabelFormat.hourAmPm:
|
||||||
|
final hour = utc.hour;
|
||||||
|
if (hour == 0) return '12 AM';
|
||||||
|
if (hour == 12) return '12 PM';
|
||||||
|
if (hour < 12) return '$hour AM';
|
||||||
|
return '${hour - 12} PM';
|
||||||
|
|
||||||
|
case TierLabelFormat.dayNumber:
|
||||||
|
return '${utc.day}';
|
||||||
|
|
||||||
|
case TierLabelFormat.monthShort:
|
||||||
|
return _getMonthShort(utc.month);
|
||||||
|
|
||||||
|
case TierLabelFormat.monthLongYear:
|
||||||
|
return '${_getMonthLong(utc.month)} ${utc.year}';
|
||||||
|
|
||||||
|
case TierLabelFormat.yearOnly:
|
||||||
|
return '${utc.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _getWeekdayShort(int weekday) {
|
||||||
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
return days[weekday - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _getMonthShort(int month) {
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
return months[month - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _getMonthLong(int month) {
|
||||||
|
const months = [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
];
|
||||||
|
return months[month - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Section Generation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generate sections for a single tier.
|
||||||
|
///
|
||||||
|
/// Creates [TierSection]s that cover the time range from [start] to [end]
|
||||||
|
/// using the specified [unit] and [format].
|
||||||
|
static List<TierSection> generateTierSections({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
required TierUnit unit,
|
||||||
|
required TierLabelFormat format,
|
||||||
|
}) {
|
||||||
|
final sections = <TierSection>[];
|
||||||
|
final startUtc = start.toUtc();
|
||||||
|
final endUtc = end.toUtc();
|
||||||
|
|
||||||
|
// Start one period before to ensure we cover partial sections at the edge
|
||||||
|
var current = alignToUnit(startUtc, unit);
|
||||||
|
final oneBeforeStart =
|
||||||
|
alignToUnit(current.subtract(const Duration(milliseconds: 1)), unit);
|
||||||
|
current = oneBeforeStart;
|
||||||
|
|
||||||
|
while (current.isBefore(endUtc)) {
|
||||||
|
final sectionEnd = getUnitEnd(current, unit);
|
||||||
|
|
||||||
|
sections.add(
|
||||||
|
TierSection(
|
||||||
|
start: current,
|
||||||
|
end: sectionEnd,
|
||||||
|
label: formatTime(current, format),
|
||||||
|
unit: unit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
current = sectionEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Main Generation Method
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generate all tier data for the visible range.
|
||||||
|
///
|
||||||
|
/// This is the main entry point for tier generation. It calculates the
|
||||||
|
/// appropriate tier configuration based on the zoom level (derived from
|
||||||
|
/// [widthPx] and the time span) and generates sections for both tiers.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// - [start]: The start of the visible time range
|
||||||
|
/// - [end]: The end of the visible time range
|
||||||
|
/// - [widthPx]: The width of the viewport in pixels
|
||||||
|
///
|
||||||
|
/// Returns a [TieredTickData] containing two [TierRow]s.
|
||||||
|
static TieredTickData generateTiers({
|
||||||
|
required DateTime start,
|
||||||
|
required DateTime end,
|
||||||
|
required double widthPx,
|
||||||
|
}) {
|
||||||
|
assert(!end.isBefore(start), 'End date must not be before start date');
|
||||||
|
assert(widthPx > 0, 'Width must be positive');
|
||||||
|
|
||||||
|
final spanMs =
|
||||||
|
end.toUtc().millisecondsSinceEpoch -
|
||||||
|
start.toUtc().millisecondsSinceEpoch;
|
||||||
|
final msPerPixel = spanMs / widthPx;
|
||||||
|
|
||||||
|
final config = selectTierConfig(msPerPixel);
|
||||||
|
|
||||||
|
final tiers = <TierRow>[];
|
||||||
|
for (final tierDef in config.tiers) {
|
||||||
|
final sections = generateTierSections(
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
unit: tierDef.unit,
|
||||||
|
format: tierDef.format,
|
||||||
|
);
|
||||||
|
|
||||||
|
tiers.add(TierRow(unit: tierDef.unit, sections: sections));
|
||||||
|
}
|
||||||
|
|
||||||
|
return TieredTickData(configName: config.name, tiers: tiers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ class DraggableEventPill extends StatelessWidget {
|
|||||||
onDragStarted: () {
|
onDragStarted: () {
|
||||||
scope.interaction.beginDrag(entry.entry);
|
scope.interaction.beginDrag(entry.entry);
|
||||||
},
|
},
|
||||||
onDraggableCanceled: (_, _) {
|
onDraggableCanceled: (_, __) {
|
||||||
scope.interaction.cancelDrag();
|
scope.interaction.cancelDrag();
|
||||||
},
|
},
|
||||||
onDragCompleted: () {
|
onDragCompleted: () {
|
||||||
@@ -12,10 +12,7 @@ import 'timeline_view.dart';
|
|||||||
|
|
||||||
/// A drop target wrapper for a timeline group.
|
/// A drop target wrapper for a timeline group.
|
||||||
///
|
///
|
||||||
/// Wraps the entire group column (header + lanes) and handles drag-and-drop
|
/// Wraps group lanes content and handles drag-and-drop operations.
|
||||||
/// operations. The [verticalOffset] accounts for the header height and padding
|
|
||||||
/// so that lane calculations are correct relative to the lanes stack.
|
|
||||||
///
|
|
||||||
/// The ghost overlay is rendered by the parent widget in the same Stack.
|
/// The ghost overlay is rendered by the parent widget in the same Stack.
|
||||||
class GroupDropTarget extends StatelessWidget {
|
class GroupDropTarget extends StatelessWidget {
|
||||||
const GroupDropTarget({
|
const GroupDropTarget({
|
||||||
@@ -27,8 +24,8 @@ class GroupDropTarget extends StatelessWidget {
|
|||||||
required this.laneHeight,
|
required this.laneHeight,
|
||||||
required this.lanesCount,
|
required this.lanesCount,
|
||||||
required this.onEntryMoved,
|
required this.onEntryMoved,
|
||||||
required this.verticalOffset,
|
|
||||||
required this.child,
|
required this.child,
|
||||||
|
this.verticalOffset = 0.0,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,13 +37,13 @@ class GroupDropTarget extends StatelessWidget {
|
|||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
final int lanesCount;
|
final int lanesCount;
|
||||||
final OnEntryMoved? onEntryMoved;
|
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;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scope = ZTimelineScope.of(context);
|
final scope = ZTimelineScope.of(context);
|
||||||
@@ -74,10 +71,9 @@ class GroupDropTarget extends StatelessWidget {
|
|||||||
viewport.end,
|
viewport.end,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subtract header + padding offset so Y is relative to the lanes stack.
|
// Adjust y to account for content above the lanes (e.g. group header)
|
||||||
// When the cursor is over the header, adjustedY is negative and clamps
|
|
||||||
// to lane 1.
|
|
||||||
final adjustedY = local.dy - verticalOffset;
|
final adjustedY = local.dy - verticalOffset;
|
||||||
|
|
||||||
final rawLane = LayoutCoordinateService.yToLane(
|
final rawLane = LayoutCoordinateService.yToLane(
|
||||||
y: adjustedY,
|
y: adjustedY,
|
||||||
laneHeight: laneHeight,
|
laneHeight: laneHeight,
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/breadcrumb_segment.dart';
|
||||||
|
import '../models/zoom_level.dart';
|
||||||
|
import '../services/breadcrumb_service.dart';
|
||||||
|
import '../state/timeline_viewport_notifier.dart';
|
||||||
|
import 'breadcrumb_segment_chip.dart';
|
||||||
|
import 'timeline_scope.dart';
|
||||||
|
|
||||||
|
/// Timeline breadcrumb navigation showing temporal context.
|
||||||
|
///
|
||||||
|
/// Displays hierarchical segments (Year > Quarter > Month > Week) that
|
||||||
|
/// appear/disappear based on zoom level. Clicking a segment navigates
|
||||||
|
/// to that time period.
|
||||||
|
///
|
||||||
|
/// Must be used within a [ZTimelineScope], or provide a [viewport] explicitly.
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// ZTimelineScope(
|
||||||
|
/// viewport: myViewport,
|
||||||
|
/// child: Column(
|
||||||
|
/// children: [
|
||||||
|
/// const ZTimelineBreadcrumb(),
|
||||||
|
/// Expanded(
|
||||||
|
/// child: ZTimelineInteractor(
|
||||||
|
/// child: ZTimelineView(...),
|
||||||
|
/// ),
|
||||||
|
/// ),
|
||||||
|
/// ],
|
||||||
|
/// ),
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
class ZTimelineBreadcrumb extends StatelessWidget {
|
||||||
|
const ZTimelineBreadcrumb({
|
||||||
|
super.key,
|
||||||
|
this.viewport,
|
||||||
|
this.showZoomIndicator = true,
|
||||||
|
this.height = 40.0,
|
||||||
|
this.padding = const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Optional viewport override.
|
||||||
|
///
|
||||||
|
/// If null, uses [ZTimelineScope.of(context).viewport].
|
||||||
|
final TimelineViewportNotifier? viewport;
|
||||||
|
|
||||||
|
/// Whether to show the zoom level indicator at the end.
|
||||||
|
///
|
||||||
|
/// Defaults to true.
|
||||||
|
final bool showZoomIndicator;
|
||||||
|
|
||||||
|
/// Height of the breadcrumb bar.
|
||||||
|
///
|
||||||
|
/// Defaults to 40.0.
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
/// Padding around the breadcrumb content.
|
||||||
|
///
|
||||||
|
/// Defaults to horizontal 16.0.
|
||||||
|
final EdgeInsets padding;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final effectiveViewport = viewport ?? ZTimelineScope.of(context).viewport;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: effectiveViewport,
|
||||||
|
builder: (context, _) {
|
||||||
|
final segments = BreadcrumbService.calculateSegments(
|
||||||
|
start: effectiveViewport.start,
|
||||||
|
end: effectiveViewport.end,
|
||||||
|
);
|
||||||
|
|
||||||
|
final visibleDays = BreadcrumbService.calculateVisibleDays(
|
||||||
|
effectiveViewport.start,
|
||||||
|
effectiveViewport.end,
|
||||||
|
);
|
||||||
|
final zoomLevel = BreadcrumbService.determineZoomLevel(visibleDays);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: height,
|
||||||
|
padding: padding,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _BreadcrumbSegmentRow(
|
||||||
|
segments: segments,
|
||||||
|
viewport: effectiveViewport,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showZoomIndicator) _ZoomLevelIndicator(level: zoomLevel),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BreadcrumbSegmentRow extends StatelessWidget {
|
||||||
|
const _BreadcrumbSegmentRow({
|
||||||
|
required this.segments,
|
||||||
|
required this.viewport,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<BreadcrumbSegment> segments;
|
||||||
|
final TimelineViewportNotifier viewport;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < segments.length; i++) ...[
|
||||||
|
BreadcrumbSegmentChip(
|
||||||
|
segment: segments[i],
|
||||||
|
onTap: () => _handleSegmentTap(segments[i]),
|
||||||
|
),
|
||||||
|
if (i < segments.length - 1 &&
|
||||||
|
segments[i].isVisible &&
|
||||||
|
_hasVisibleSegmentAfter(segments, i))
|
||||||
|
const _BreadcrumbSeparator(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasVisibleSegmentAfter(List<BreadcrumbSegment> segments, int index) {
|
||||||
|
for (int i = index + 1; i < segments.length; i++) {
|
||||||
|
if (segments[i].isVisible) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSegmentTap(BreadcrumbSegment segment) {
|
||||||
|
final domain = BreadcrumbService.calculateDomainFromTarget(
|
||||||
|
segment.navigationTarget,
|
||||||
|
);
|
||||||
|
viewport.setDomain(domain.start, domain.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BreadcrumbSeparator extends StatelessWidget {
|
||||||
|
const _BreadcrumbSeparator();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZoomLevelIndicator extends StatelessWidget {
|
||||||
|
const _ZoomLevelIndicator({required this.level});
|
||||||
|
|
||||||
|
final ZoomLevel level;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
level.label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,8 +75,6 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
: ZTimelineConstants.minContentWidth;
|
: ZTimelineConstants.minContentWidth;
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: groups.length,
|
itemCount: groups.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final group = groups[index];
|
final group = groups[index];
|
||||||
@@ -84,14 +82,13 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
projected[group.id] ?? const <ProjectedEntry>[];
|
projected[group.id] ?? const <ProjectedEntry>[];
|
||||||
final lanesCount = _countLanes(groupEntries);
|
final lanesCount = _countLanes(groupEntries);
|
||||||
|
|
||||||
final column = Column(
|
Widget groupColumn = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
_GroupHeader(title: group.title, height: groupHeaderHeight),
|
||||||
_GroupLanes(
|
_GroupLanes(
|
||||||
group: group,
|
group: group,
|
||||||
entries: groupEntries,
|
entries: groupEntries,
|
||||||
allEntries: entries,
|
|
||||||
viewport: viewport,
|
viewport: viewport,
|
||||||
lanesCount: lanesCount,
|
lanesCount: lanesCount,
|
||||||
laneHeight: laneHeight,
|
laneHeight: laneHeight,
|
||||||
@@ -104,9 +101,9 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Wrap the entire group (header + lanes) in a DragTarget
|
// 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) {
|
if (enableDrag && onEntryMoved != null) {
|
||||||
return GroupDropTarget(
|
groupColumn = GroupDropTarget(
|
||||||
group: group,
|
group: group,
|
||||||
entries: groupEntries,
|
entries: groupEntries,
|
||||||
allEntries: entries,
|
allEntries: entries,
|
||||||
@@ -118,11 +115,11 @@ class ZTimelineView extends StatelessWidget {
|
|||||||
verticalOffset:
|
verticalOffset:
|
||||||
groupHeaderHeight +
|
groupHeaderHeight +
|
||||||
ZTimelineConstants.verticalOuterPadding,
|
ZTimelineConstants.verticalOuterPadding,
|
||||||
child: column,
|
child: groupColumn,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return column;
|
return groupColumn;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -150,7 +147,6 @@ class _GroupHeader extends StatelessWidget {
|
|||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return Container(
|
||||||
height: height,
|
height: height,
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: scheme.surfaceContainerHighest,
|
color: scheme.surfaceContainerHighest,
|
||||||
@@ -170,7 +166,6 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
const _GroupLanes({
|
const _GroupLanes({
|
||||||
required this.group,
|
required this.group,
|
||||||
required this.entries,
|
required this.entries,
|
||||||
required this.allEntries,
|
|
||||||
required this.viewport,
|
required this.viewport,
|
||||||
required this.lanesCount,
|
required this.lanesCount,
|
||||||
required this.laneHeight,
|
required this.laneHeight,
|
||||||
@@ -182,7 +177,6 @@ class _GroupLanes extends StatelessWidget {
|
|||||||
|
|
||||||
final TimelineGroup group;
|
final TimelineGroup group;
|
||||||
final List<ProjectedEntry> entries;
|
final List<ProjectedEntry> entries;
|
||||||
final List<TimelineEntry> allEntries;
|
|
||||||
final TimelineViewportNotifier viewport;
|
final TimelineViewportNotifier viewport;
|
||||||
final int lanesCount;
|
final int lanesCount;
|
||||||
final double laneHeight;
|
final double laneHeight;
|
||||||
@@ -1,24 +1,41 @@
|
|||||||
|
/// Reusable timeline visualization package.
|
||||||
|
library;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export 'src/constants.dart';
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
export 'src/models/timeline_group.dart';
|
export 'src/models/breadcrumb_segment.dart';
|
||||||
export 'src/models/timeline_entry.dart';
|
export 'src/models/entry_drag_state.dart';
|
||||||
export 'src/models/projected_entry.dart';
|
|
||||||
export 'src/models/interaction_config.dart';
|
export 'src/models/interaction_config.dart';
|
||||||
export 'src/models/interaction_state.dart';
|
export 'src/models/interaction_state.dart';
|
||||||
export 'src/models/entry_drag_state.dart';
|
export 'src/models/projected_entry.dart';
|
||||||
|
export 'src/models/tier_config.dart';
|
||||||
// State
|
export 'src/models/tier_section.dart';
|
||||||
export 'src/state/timeline_viewport_notifier.dart';
|
export 'src/models/tiered_tick_data.dart';
|
||||||
export 'src/state/timeline_interaction_notifier.dart';
|
export 'src/models/timeline_entry.dart';
|
||||||
|
export 'src/models/timeline_group.dart';
|
||||||
|
export 'src/models/zoom_level.dart';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
export 'src/services/time_scale_service.dart';
|
export 'src/services/breadcrumb_service.dart';
|
||||||
export 'src/services/time_tick_builder.dart';
|
|
||||||
export 'src/services/timeline_projection_service.dart';
|
|
||||||
export 'src/services/entry_placement_service.dart';
|
export 'src/services/entry_placement_service.dart';
|
||||||
export 'src/services/layout_coordinate_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
|
// Widgets
|
||||||
export 'src/constants.dart';
|
export 'src/widgets/breadcrumb_segment_chip.dart';
|
||||||
export 'src/widgets/timeline_view.dart';
|
export 'src/widgets/draggable_event_pill.dart';
|
||||||
export 'src/widgets/timeline_scope.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_interactor.dart';
|
||||||
|
export 'src/widgets/timeline_scope.dart';
|
||||||
|
export 'src/widgets/timeline_tiered_header.dart';
|
||||||
|
export 'src/widgets/timeline_view.dart';
|
||||||
17
packages/z-flutter/packages/z_timeline/pubspec.yaml
Normal file
@@ -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
|
||||||
@@ -200,6 +200,13 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
z_timeline:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "packages/z_timeline"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.18.0-18.0.pre.54"
|
flutter: ">=3.18.0-18.0.pre.54"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
name: z_timeline
|
name: z_flutter
|
||||||
description: "A new Flutter project."
|
description: "A new Flutter project."
|
||||||
publish_to: 'none'
|
publish_to: "none"
|
||||||
version: 0.1.0+1
|
version: 0.1.0+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -9,6 +9,8 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
z_timeline:
|
||||||
|
path: packages/z_timeline
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 917 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
46
packages/z-flutter/web/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!--
|
||||||
|
If you are serving your web app in a path other than the root, change the
|
||||||
|
href value below to reflect the base path you are serving from.
|
||||||
|
|
||||||
|
The path provided below has to start and end with a slash "/" in order for
|
||||||
|
it to work correctly.
|
||||||
|
|
||||||
|
For more details:
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||||
|
|
||||||
|
This is a placeholder for base href that will be replaced by the value of
|
||||||
|
the `--base-href` argument provided to `flutter build`.
|
||||||
|
-->
|
||||||
|
<base href="$FLUTTER_BASE_HREF">
|
||||||
|
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||||
|
<meta name="description" content="A new Flutter project.">
|
||||||
|
|
||||||
|
<!-- iOS meta tags & icons -->
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="z_flutter">
|
||||||
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
|
<title>z_flutter</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
You can customize the "flutter_bootstrap.js" script.
|
||||||
|
This is useful to provide a custom configuration to the Flutter loader
|
||||||
|
or to give the user feedback during the initialization process.
|
||||||
|
|
||||||
|
For more details:
|
||||||
|
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||||
|
-->
|
||||||
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "z_timeline",
|
"name": "z_flutter",
|
||||||
"short_name": "z_timeline",
|
"short_name": "z_flutter",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
@@ -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)),
|
|
||||||
];
|
|
||||||
@@ -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
@@ -105,9 +105,9 @@ importers:
|
|||||||
'@zendegi/env':
|
'@zendegi/env':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/env
|
version: link:../../packages/env
|
||||||
'@zendegi/z-timeline':
|
'@zendegi/z-flutter':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/z-timeline
|
version: link:../../packages/z-flutter
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: 'catalog:'
|
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)
|
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
|
specifier: ^5.0.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/z-flutter: {}
|
||||||
|
|
||||||
packages/z-timeline: {}
|
packages/z-timeline: {}
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|||||||