add and edit

This commit is contained in:
2026-03-07 14:47:42 +01:00
parent 724980fd31
commit 9d015c2e2c
15 changed files with 214 additions and 43 deletions

View File

@@ -125,6 +125,14 @@ class _MainAppState extends State<MainApp> {
return (start: earliest.subtract(padding), end: latest.add(padding));
}
void _onEntrySelected(TimelineEntry entry) {
emitEvent('item_selected', {'itemId': entry.id});
}
void _onBackgroundTap() {
emitEvent('item_deselected');
}
void _onEntryMoved(
TimelineEntry entry,
DateTime newStart,
@@ -346,6 +354,7 @@ class _MainAppState extends State<MainApp> {
const ZTimelineTieredHeader(),
Expanded(
child: ZTimelineInteractor(
onBackgroundTap: _onBackgroundTap,
child: ZTimelineView(
groups: _groups,
entries: _entries,
@@ -355,6 +364,8 @@ class _MainAppState extends State<MainApp> {
enableDrag: true,
onEntryMoved: _onEntryMoved,
onEntryResized: _onEntryResized,
onEntrySelected: _onEntrySelected,
selectedEntryId: _state?.selectedItemId,
),
),
),

View File

@@ -5,10 +5,16 @@ import '../constants.dart';
/// A standalone event pill widget with entry color, rounded corners,
/// and auto text color based on brightness.
class EventPill extends StatelessWidget {
const EventPill({required this.color, required this.label, super.key});
const EventPill({
required this.color,
required this.label,
this.isSelected = false,
super.key,
});
final Color color;
final String label;
final bool isSelected;
@override
Widget build(BuildContext context) {
@@ -17,6 +23,8 @@ class EventPill extends StatelessWidget {
? Colors.white
: Colors.black87;
final primaryColor = Theme.of(context).colorScheme.primary;
return Container(
padding: ZTimelineConstants.pillPadding,
decoration: BoxDecoration(
@@ -24,6 +32,9 @@ class EventPill extends StatelessWidget {
borderRadius: BorderRadius.circular(
ZTimelineConstants.pillBorderRadius,
),
border: isSelected
? Border.all(color: primaryColor, width: 2)
: null,
),
alignment: Alignment.centerLeft,
child: Text(

View File

@@ -10,11 +10,13 @@ class EventPoint extends StatelessWidget {
required this.color,
required this.label,
this.maxTextWidth,
this.isSelected = false,
super.key,
});
final Color color;
final String label;
final bool isSelected;
/// Maximum width for the label text. When provided, text truncates with
/// ellipsis. When null the text sizes naturally.
@@ -49,6 +51,12 @@ class EventPoint extends StatelessWidget {
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
),
if (showText) ...[

View File

@@ -31,6 +31,8 @@ class InteractiveEventPill extends StatefulWidget {
this.allEntries = const [],
this.onEntryResized,
this.onEntryMoved,
this.onEntrySelected,
this.selectedEntryId,
this.groupRegistry,
this.nextEntryStartX,
super.key,
@@ -46,6 +48,8 @@ class InteractiveEventPill extends StatefulWidget {
final List<TimelineEntry> allEntries;
final OnEntryResized? onEntryResized;
final OnEntryMoved? onEntryMoved;
final OnEntrySelected? onEntrySelected;
final String? selectedEntryId;
final TimelineGroupRegistry? groupRegistry;
/// Normalized startX of the next entry in the same lane, used to compute
@@ -285,6 +289,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
final isPoint = !widget.entry.entry.hasEnd;
final color = widget.colorBuilder(widget.entry.entry);
final label = widget.labelBuilder(widget.entry.entry);
final isSelected = widget.entry.entry.id == widget.selectedEntryId;
final top = LayoutCoordinateService.laneToY(
lane: widget.entry.entry.lane,
@@ -312,6 +317,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
color: color,
label: label,
maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0,
isSelected: isSelected,
);
if (!widget.enableDrag) {
@@ -355,7 +361,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
}
// Range event (hasEnd == true)
final pill = EventPill(color: color, label: label);
final pill = EventPill(color: color, label: label, isSelected: isSelected);
final width = _pillWidth;
if (!widget.enableDrag) {
@@ -434,6 +440,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
onPanDown: _onPanDown,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
@@ -453,6 +460,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
onExit: (_) => _onHoverExit(),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
onPanDown: _onPanDown,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,

View File

@@ -27,6 +27,7 @@ class ZTimelineInteractor extends StatefulWidget {
const ZTimelineInteractor({
required this.child,
this.autofocus = true,
this.onBackgroundTap,
super.key,
});
@@ -36,6 +37,9 @@ class ZTimelineInteractor extends StatefulWidget {
/// Whether to automatically focus this widget for keyboard input.
final bool autofocus;
/// Called when the user taps empty space (not on a pill/entry).
final VoidCallback? onBackgroundTap;
@override
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
}
@@ -231,6 +235,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
);
},
child: GestureDetector(
onTap: widget.onBackgroundTap,
onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate,
onScaleEnd: _handleScaleEnd,

View File

@@ -33,6 +33,9 @@ typedef OnEntryResized =
int newLane,
);
/// Callback signature for when an entry is tapped / selected.
typedef OnEntrySelected = void Function(TimelineEntry entry);
/// Base timeline view: renders groups with between-group headers and
/// lane rows containing event pills.
class ZTimelineView extends StatelessWidget {
@@ -47,7 +50,9 @@ class ZTimelineView extends StatelessWidget {
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
this.onEntryMoved,
this.onEntryResized,
this.onEntrySelected,
this.enableDrag = true,
this.selectedEntryId,
});
final List<TimelineGroup> groups;
@@ -67,9 +72,15 @@ class ZTimelineView extends StatelessWidget {
/// Callback invoked when an entry is resized via edge drag.
final OnEntryResized? onEntryResized;
/// Callback invoked when an entry is tapped / selected.
final OnEntrySelected? onEntrySelected;
/// Whether drag-and-drop is enabled.
final bool enableDrag;
/// ID of the currently selected entry, if any.
final String? selectedEntryId;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
@@ -112,6 +123,8 @@ class ZTimelineView extends StatelessWidget {
enableDrag: enableDrag,
onEntryResized: onEntryResized,
onEntryMoved: onEntryMoved,
onEntrySelected: onEntrySelected,
selectedEntryId: selectedEntryId,
),
],
);
@@ -171,6 +184,8 @@ class _GroupLanes extends StatefulWidget {
required this.enableDrag,
this.onEntryResized,
this.onEntryMoved,
this.onEntrySelected,
this.selectedEntryId,
});
final TimelineGroup group;
@@ -185,6 +200,8 @@ class _GroupLanes extends StatefulWidget {
final bool enableDrag;
final OnEntryResized? onEntryResized;
final OnEntryMoved? onEntryMoved;
final OnEntrySelected? onEntrySelected;
final String? selectedEntryId;
@override
State<_GroupLanes> createState() => _GroupLanesState();
@@ -330,6 +347,8 @@ class _GroupLanesState extends State<_GroupLanes> {
allEntries: widget.allEntries,
onEntryResized: widget.onEntryResized,
onEntryMoved: widget.onEntryMoved,
onEntrySelected: widget.onEntrySelected,
selectedEntryId: widget.selectedEntryId,
groupRegistry: scope?.groupRegistry,
nextEntryStartX: _findNextEntryStartX(
widget.entries,

View File

@@ -269,9 +269,11 @@
}
case "item_selected":
currentState.selectedItemId = event.payload.itemId;
pushState(currentState);
break;
case "item_deselected":
currentState.selectedItemId = null;
pushState(currentState);
break;
}
}