feat(todo): the hour grid leaves a strip free beside blocks; a stretch of time chosen by dragging

As Proton Calendar's (the developer's review, 10 Nov 2026): each day's
blocks leave 12 pixels free on its right, where a tap or a click still
adds a task beside them; a mouse dragged, or a finger held and dragged,
chooses a stretch of quarter hours, shown with its times, which quick
add opens with. A tap or a click still adds half an hour.

Whats-New: In Calendar and 3-day, drag across the hours to add a task for that long; tasks leave room beside them to add another.
Whats-New-De: In Kalender und 3 Tage ziehst du über die Stunden, um eine Aufgabe für so lange hinzuzufügen; neben Aufgaben bleibt Platz für eine weitere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 00:35:53 +01:00
commit 8b862b2c1b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/gestures.dart'
show DragStartBehavior, PointerDeviceKind;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
@ -83,6 +85,11 @@ class const HourGrid({
/// The key of the line at the time it is now.
static const nowKey = ValueKey('grid-now');
/// The strip of a day's width its blocks leave free on the right, where
/// a tap, a click or a stretch held and dragged still adds a task beside
/// them, as Proton Calendar's (the developer's review, 10 Nov 2026).
static const freeStrip = 12.0;
@override
State<HourGrid> createState() => _HourGridState();
}
@ -447,18 +454,74 @@ class const _DayColumn({
}
class _DayColumnState() extends State<_DayColumn> {
/// The quarter hour held or clicked, in minutes after midnight, while
/// quick add is open.
int? _held;
/// The stretch chosen, while it is dragged and while quick add is open
/// for it.
({int start, int minutes})? _held;
Future<void> _add(double dy) async {
final minutes = (dy / (_hourHeight / 4)).floor().clamp(0, 95) * 15;
setState(() => _held = minutes);
/// The quarter hour pressed, from 0 to 95, while a stretch is dragged.
int? _anchor;
/// The quarter hour the pointer is over, while a stretch is dragged.
int? _reach;
static int _quarterAt(double dy) =>
(dy / (_hourHeight / 4)).floor().clamp(0, 24 * 4 - 1);
/// From the quarter pressed to the one the pointer is over, both whole,
/// whichever way it was dragged; half an hour from the quarter pressed
/// where it never left it, as a tap gives.
({int start, int minutes}) _stretch(int anchor, int reach) {
if (anchor == reach) {
return (start: anchor * 15, minutes: minBlockMinutes);
}
final first = anchor < reach ? anchor : reach;
final last = anchor < reach ? reach : anchor;
return (start: first * 15, minutes: (last - first + 1) * 15);
}
void _press(double dy) {
final quarter = _quarterAt(dy);
setState(() {
_anchor = quarter;
_reach = quarter;
_held = _stretch(quarter, quarter);
});
}
void _drag(double dy) {
final anchor = _anchor;
final quarter = _quarterAt(dy);
if (anchor == null || quarter == _reach) {
return;
}
setState(() {
_reach = quarter;
_held = _stretch(anchor, quarter);
});
}
void _letGo() {
final held = _held;
_anchor = null;
_reach = null;
if (held != null) {
unawaited(_add(held));
}
}
void _cancel() => setState(() {
_anchor = null;
_reach = null;
_held = null;
});
Future<void> _add(({int start, int minutes}) stretch) async {
setState(() => _held = stretch);
await showQuickAdd(
context,
day: widget.day,
time: minutes,
duration: const Duration(minutes: minBlockMinutes),
time: stretch.start,
duration: Duration(minutes: stretch.minutes),
);
if (mounted) {
setState(() => _held = null);
@ -502,111 +565,141 @@ class _DayColumnState() extends State<_DayColumn> {
}
Widget _hours(ThemeData theme, Color line) {
final scheme = theme.colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) => unawaited(_add(details.localPosition.dy)),
// A finger held, then dragged, chooses a stretch, as a mouse
// dragged does at once: the day scrolls under a finger alone.
onLongPressStart: (details) => _press(details.localPosition.dy),
onLongPressMoveUpdate: (details) => _drag(details.localPosition.dy),
onLongPressEnd: (_) => _letGo(),
onLongPressCancel: _cancel,
// Where it lands says when, so a tap or a click is enough (D33); a
// tap that stops the day scrolling doesn't reach here.
onTapUp: (details) => unawaited(_add(details.localPosition.dy)),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(left: BorderSide(color: line)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
double top(int minutes) => minutes / 60 * _hourHeight;
return Stack(
children: [
for (var hour = 1; hour < 24; hour++)
Positioned(
top: top(hour * 60),
left: 0,
right: 0,
child: Divider(height: 1, color: line),
onTapUp: (details) {
final quarter = _quarterAt(details.localPosition.dy);
unawaited(_add(_stretch(quarter, quarter)));
},
child: GestureDetector(
supportedDevices: const {PointerDeviceKind.mouse},
dragStartBehavior: DragStartBehavior.down,
onVerticalDragStart: (details) => _press(details.localPosition.dy),
onVerticalDragUpdate: (details) => _drag(details.localPosition.dy),
onVerticalDragEnd: (_) => _letGo(),
onVerticalDragCancel: _cancel,
child: _column(theme, line),
),
);
}
Widget _column(ThemeData theme, Color line) {
final scheme = theme.colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
border: Border(left: BorderSide(color: line)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final blocksWidth = constraints.maxWidth - HourGrid.freeStrip;
double top(int minutes) => minutes / 60 * _hourHeight;
return Stack(
children: [
for (var hour = 1; hour < 24; hour++)
Positioned(
top: top(hour * 60),
left: 0,
right: 0,
child: Divider(height: 1, color: line),
),
if (_held case final held?)
Positioned(
top: top(held.start),
height: top(held.minutes),
left: 2,
right: HourGrid.freeStrip,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${timeText(context, held.start)} – '
'${timeText(context, held.start + held.minutes)}',
maxLines: 1,
overflow: TextOverflow.clip,
style: theme.textTheme.labelSmall,
),
),
),
if (_held case final held?)
Positioned(
top: top(held),
height: _hourHeight / 2,
left: 2,
right: 2,
),
if (_hover case final hover?)
Positioned(
top: top(hover),
height: _hourHeight / 2,
left: 2,
right: HourGrid.freeStrip,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
color: scheme.primary.withValues(alpha: 0.12),
border: Border.all(color: scheme.primary),
borderRadius: BorderRadius.circular(8),
),
),
),
if (_hover case final hover?)
Positioned(
top: top(hover),
height: _hourHeight / 2,
left: 2,
right: 2,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
border: Border.all(color: scheme.primary),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
timeText(context, hover),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
timeText(context, hover),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
),
),
),
),
),
for (final block in widget.blocks)
Positioned(
top: top(block.start) + 1,
height: top(block.end) - top(block.start) - 2,
left: block.lane * width / block.lanes + 1,
width: width / block.lanes - 2,
child: _TaskBlock(
key: HourGrid.blockKey(
block.entry.task.id,
widget.day,
),
entry: block.entry,
coloring: widget.coloring,
),
for (final block in widget.blocks)
Positioned(
top: top(block.start) + 1,
height: top(block.end) - top(block.start) - 2,
left: block.lane * blocksWidth / block.lanes + 1,
width: blocksWidth / block.lanes - 2,
child: _TaskBlock(
key: HourGrid.blockKey(
block.entry.task.id,
widget.day,
),
entry: block.entry,
coloring: widget.coloring,
),
),
if (widget.now case final now?)
Positioned(
key: HourGrid.nowKey,
top: top(now) - 4,
left: 0,
right: 0,
height: 8,
child: IgnorePointer(
child: Row(
children: [
DecoratedBox(
decoration: BoxDecoration(
color: widget.nowColor,
shape: BoxShape.circle,
),
child: const SizedBox.square(dimension: 8),
),
Expanded(
child: Container(height: 2, color: widget.nowColor),
),
],
),
),
if (widget.now case final now?)
Positioned(
key: HourGrid.nowKey,
top: top(now) - 4,
left: 0,
right: 0,
height: 8,
child: IgnorePointer(
child: Row(
children: [
DecoratedBox(
decoration: BoxDecoration(
color: widget.nowColor,
shape: BoxShape.circle,
),
child: const SizedBox.square(dimension: 8),
),
Expanded(
child: Container(height: 2, color: widget.nowColor),
),
],
),
),
),
],
);
},
),
),
],
);
},
),
);
}