feat(todo): side panes widen by their line, and a tap adds a task (ADR 0033, D33)
The line beside the task pane and Today's Plan is dragged, between 300 px for the pane and 360 px beside it, each pane's width kept on the device. A tap or click in the hour grid opens quick add at the quarter hour under it, on a phone too, as where it lands says when; a long press takes quarter hours as well. A tap that stops the day scrolling adds nothing. Whats-New: Drag the line beside a task or the Plan to resize it; tap or click into the calendar to add a task at that time. Whats-New-De: Ziehe die Linie neben einer Aufgabe oder dem Plan, um die Breite zu ändern; tippe oder klicke in den Kalender, um dort eine Aufgabe anzulegen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8628d80d89
commit
4aec923476
14 changed files with 454 additions and 31 deletions
|
|
@ -261,6 +261,7 @@
|
||||||
"dayHeadingPattern": "EEEE, d. MMM",
|
"dayHeadingPattern": "EEEE, d. MMM",
|
||||||
"overdueHide": "Überfälliges ausblenden",
|
"overdueHide": "Überfälliges ausblenden",
|
||||||
"overdueShow": "Überfälliges einblenden",
|
"overdueShow": "Überfälliges einblenden",
|
||||||
|
"paneResize": "Größe ändern",
|
||||||
"planButton": "Plan: {count}",
|
"planButton": "Plan: {count}",
|
||||||
"planTitle": "Plan",
|
"planTitle": "Plan",
|
||||||
"planShow": "Plan einblenden",
|
"planShow": "Plan einblenden",
|
||||||
|
|
|
||||||
|
|
@ -839,6 +839,7 @@
|
||||||
},
|
},
|
||||||
"overdueHide": "Hide what is overdue",
|
"overdueHide": "Hide what is overdue",
|
||||||
"overdueShow": "Show what is overdue",
|
"overdueShow": "Show what is overdue",
|
||||||
|
"paneResize": "Resize",
|
||||||
"planButton": "Plan: {count}",
|
"planButton": "Plan: {count}",
|
||||||
"@planButton": {
|
"@planButton": {
|
||||||
"description": "Today's app bar in its Calendar layout (ADR 0033, D30, as Todoist's): shows or hides the Plan, counting the tasks it holds.",
|
"description": "Today's app bar in its Calendar layout (ADR 0033, D30, as Todoist's): shows or hides the Plan, counting the tasks it holds.",
|
||||||
|
|
|
||||||
|
|
@ -1666,6 +1666,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Show what is overdue'**
|
/// **'Show what is overdue'**
|
||||||
String get overdueShow;
|
String get overdueShow;
|
||||||
|
|
||||||
|
/// No description provided for @paneResize.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Resize'**
|
||||||
|
String get paneResize;
|
||||||
|
|
||||||
/// Today's app bar in its Calendar layout (ADR 0033, D30, as Todoist's): shows or hides the Plan, counting the tasks it holds.
|
/// Today's app bar in its Calendar layout (ADR 0033, D30, as Todoist's): shows or hides the Plan, counting the tasks it holds.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1030,6 +1030,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get overdueShow => 'Überfälliges einblenden';
|
String get overdueShow => 'Überfälliges einblenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paneResize => 'Größe ändern';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planButton(int count) {
|
String planButton(int count) {
|
||||||
return 'Plan: $count';
|
return 'Plan: $count';
|
||||||
|
|
|
||||||
|
|
@ -1030,6 +1030,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get overdueShow => 'Show what is overdue';
|
String get overdueShow => 'Show what is overdue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paneResize => 'Resize';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String planButton(int count) {
|
String planButton(int count) {
|
||||||
return 'Plan: $count';
|
return 'Plan: $count';
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_reposito
|
||||||
import 'package:halkyon_todo_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
import 'package:halkyon_todo_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
@ -79,8 +80,15 @@ class TodoPerson(
|
||||||
/// Whether the device may watch them: the device's, where it is given;
|
/// Whether the device may watch them: the device's, where it is given;
|
||||||
/// else one of their own.
|
/// else one of their own.
|
||||||
final LocationReminderAccess? _locationAccess,
|
final LocationReminderAccess? _locationAccess,
|
||||||
|
|
||||||
|
/// How wide the device keeps its side panes: the device's, where it is
|
||||||
|
/// given; else for this session.
|
||||||
|
final PaneWidths? _paneWidths,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) implements Disposable {
|
||||||
|
/// How wide the device keeps its side panes (ADR 0033, D33).
|
||||||
|
late final PaneWidths paneWidths = _paneWidths ?? PaneWidths();
|
||||||
|
|
||||||
/// Whether the device may watch the places planned (ADR 0033, D32).
|
/// Whether the device may watch the places planned (ADR 0033, D32).
|
||||||
late final LocationReminderAccess locationAccess =
|
late final LocationReminderAccess locationAccess =
|
||||||
_locationAccess ?? LocationReminderAccess(locationReminders);
|
_locationAccess ?? LocationReminderAccess(locationReminders);
|
||||||
|
|
@ -315,6 +323,7 @@ class TodoDependencies({
|
||||||
notifications: device.notifications,
|
notifications: device.notifications,
|
||||||
locationReminders: device.locationReminders,
|
locationReminders: device.locationReminders,
|
||||||
locationAccess: device.locationAccess,
|
locationAccess: device.locationAccess,
|
||||||
|
paneWidths: device.paneWidths,
|
||||||
onClosed: closed.complete,
|
onClosed: closed.complete,
|
||||||
);
|
);
|
||||||
} on Object {
|
} on Object {
|
||||||
|
|
@ -355,6 +364,7 @@ Future<TodoPerson> _openPersonData(
|
||||||
required NotificationAccess notifications,
|
required NotificationAccess notifications,
|
||||||
required LocationReminderGateway locationReminders,
|
required LocationReminderGateway locationReminders,
|
||||||
required LocationReminderAccess locationAccess,
|
required LocationReminderAccess locationAccess,
|
||||||
|
required PaneWidths paneWidths,
|
||||||
required void Function() onClosed,
|
required void Function() onClosed,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await client.createSyncSession(
|
final db = await client.createSyncSession(
|
||||||
|
|
@ -415,6 +425,7 @@ Future<TodoPerson> _openPersonData(
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
locationReminders: locationReminders,
|
locationReminders: locationReminders,
|
||||||
locationAccess: locationAccess,
|
locationAccess: locationAccess,
|
||||||
|
paneWidths: paneWidths,
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
try {
|
try {
|
||||||
await projects.dispose();
|
await projects.dispose();
|
||||||
|
|
@ -454,6 +465,9 @@ class TodoDevice({
|
||||||
|
|
||||||
/// Where the device watches places (D32): the system's on Android.
|
/// Where the device watches places (D32): the system's on Android.
|
||||||
final LocationReminderGateway locationReminders = const NoLocationReminders(),
|
final LocationReminderGateway locationReminders = const NoLocationReminders(),
|
||||||
|
|
||||||
|
/// How wide it keeps its side panes (D33).
|
||||||
|
required final PaneWidths paneWidths,
|
||||||
}) {
|
}) {
|
||||||
/// Whether the device may show reminders, asked once an app start,
|
/// Whether the device may show reminders, asked once an app start,
|
||||||
/// whoever is signed in (D31).
|
/// whoever is signed in (D31).
|
||||||
|
|
@ -473,6 +487,7 @@ class TodoDevice({
|
||||||
storage: InstanceStorage(support, builtIn: builtIn),
|
storage: InstanceStorage(support, builtIn: builtIn),
|
||||||
appBuild: int.tryParse(package.buildNumber),
|
appBuild: int.tryParse(package.buildNumber),
|
||||||
appVersion: package.version,
|
appVersion: package.version,
|
||||||
|
paneWidths: await PaneWidths.open(File(p.join(support, 'panes.json'))),
|
||||||
reminders: await _reminders(),
|
reminders: await _reminders(),
|
||||||
locationReminders: Platform.isAndroid
|
locationReminders: Platform.isAndroid
|
||||||
? const AndroidLocationReminders()
|
? const AndroidLocationReminders()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.dart'
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
@ -206,7 +208,7 @@ class _TaskSheetHostState() extends State<_TaskSheetHost> {
|
||||||
/// [child] the list, the task's details beside it on the right.
|
/// [child] the list, the task's details beside it on the right.
|
||||||
class const TaskPane({required final Widget child, super.key})
|
class const TaskPane({required final Widget child, super.key})
|
||||||
extends StatefulWidget {
|
extends StatefulWidget {
|
||||||
/// How wide the pane is.
|
/// How wide the pane is until the person chooses (D33).
|
||||||
static const width = 420.0;
|
static const width = 420.0;
|
||||||
|
|
||||||
/// The open task's ID above [context], where a pane is.
|
/// The open task's ID above [context], where a pane is.
|
||||||
|
|
@ -232,20 +234,17 @@ class _TaskPaneState() extends State<TaskPane> {
|
||||||
child: ValueListenableBuilder(
|
child: ValueListenableBuilder(
|
||||||
valueListenable: _open,
|
valueListenable: _open,
|
||||||
builder: (context, id, list) => switch (id) {
|
builder: (context, id, list) => switch (id) {
|
||||||
final id? when context.windowSize.isAtLeastExpanded => Row(
|
final id? when context.windowSize.isAtLeastExpanded => SidePane(
|
||||||
children: [
|
kind: SidePaneKind.task,
|
||||||
Expanded(child: list!),
|
widths: PersonScope.of<TodoPerson>(context).paneWidths,
|
||||||
const VerticalDivider(width: 1),
|
fallback: TaskPane.width,
|
||||||
SizedBox(
|
main: list!,
|
||||||
width: TaskPane.width,
|
pane: Material(
|
||||||
child: Material(
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
// Another task opened is another state: what was typed
|
||||||
// Another task opened is another state: what was typed
|
// into the last is kept as it goes.
|
||||||
// into the last is kept as it goes.
|
child: _TaskSheet(key: ValueKey(id), id: id, inPane: true),
|
||||||
child: _TaskSheet(key: ValueKey(id), id: id, inPane: true),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
_ => list!,
|
_ => list!,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -432,11 +432,12 @@ class const _DayColumn({
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DayColumnState() extends State<_DayColumn> {
|
class _DayColumnState() extends State<_DayColumn> {
|
||||||
/// The half hour held, in minutes after midnight, while quick add is open.
|
/// The quarter hour held or clicked, in minutes after midnight, while
|
||||||
|
/// quick add is open.
|
||||||
int? _held;
|
int? _held;
|
||||||
|
|
||||||
Future<void> _add(double dy) async {
|
Future<void> _add(double dy) async {
|
||||||
final minutes = (dy / (_hourHeight / 2)).floor().clamp(0, 47) * 30;
|
final minutes = (dy / (_hourHeight / 4)).floor().clamp(0, 95) * 15;
|
||||||
setState(() => _held = minutes);
|
setState(() => _held = minutes);
|
||||||
await showQuickAdd(
|
await showQuickAdd(
|
||||||
context,
|
context,
|
||||||
|
|
@ -490,6 +491,9 @@ class _DayColumnState() extends State<_DayColumn> {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onLongPressStart: (details) => unawaited(_add(details.localPosition.dy)),
|
onLongPressStart: (details) => unawaited(_add(details.localPosition.dy)),
|
||||||
|
// 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(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(left: BorderSide(color: line)),
|
border: Border(left: BorderSide(color: line)),
|
||||||
|
|
|
||||||
66
apps/todo/halkyon_todo_flutter/lib/src/tabs/pane_widths.dart
Normal file
66
apps/todo/halkyon_todo_flutter/lib/src/tabs/pane_widths.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// What sits beside a list or a calendar and can be widened.
|
||||||
|
enum SidePaneKind() {
|
||||||
|
/// The task open beside the list (ADR 0033, D2).
|
||||||
|
task,
|
||||||
|
|
||||||
|
/// Today's Plan (D30).
|
||||||
|
plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How wide the device keeps its side panes (ADR 0033, D33): a window's
|
||||||
|
/// matter, so the device's, whoever is signed in, and neither sealed nor
|
||||||
|
/// synced. Each pane's width while one is dragged, written to [_file], where
|
||||||
|
/// there is one, as a drag ends.
|
||||||
|
class PaneWidths([final File? _file]) extends ChangeNotifier {
|
||||||
|
final _widths = <SidePaneKind, double>{};
|
||||||
|
|
||||||
|
/// Opens what [file] holds; nothing where it can't be read.
|
||||||
|
static Future<PaneWidths> open(File file) async {
|
||||||
|
final panes = PaneWidths(file);
|
||||||
|
try {
|
||||||
|
if (file.existsSync()) {
|
||||||
|
final json = jsonDecode(await file.readAsString());
|
||||||
|
if (json is Map<String, Object?>) {
|
||||||
|
for (final kind in SidePaneKind.values) {
|
||||||
|
if (json[kind.name] case final num width) {
|
||||||
|
panes._widths[kind] = width.toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Panes not read: ${error.runtimeType}', name: 'Panes');
|
||||||
|
}
|
||||||
|
return panes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The width chosen for [kind]; null while none was.
|
||||||
|
double? of(SidePaneKind kind) => _widths[kind];
|
||||||
|
|
||||||
|
/// [width] as [kind]'s, as it is dragged.
|
||||||
|
void drag(SidePaneKind kind, double width) {
|
||||||
|
if (_widths[kind] != width) {
|
||||||
|
_widths[kind] = width;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps what was dragged.
|
||||||
|
Future<void> save() async {
|
||||||
|
try {
|
||||||
|
await _file?.writeAsString(
|
||||||
|
jsonEncode({
|
||||||
|
for (final MapEntry(:key, :value) in _widths.entries) key.name: value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Panes not kept: ${error.runtimeType}', name: 'Panes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
apps/todo/halkyon_todo_flutter/lib/src/tabs/side_pane.dart
Normal file
122
apps/todo/halkyon_todo_flutter/lib/src/tabs/side_pane.dart
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// [main] with [pane] beside it on the right, the line between them dragged
|
||||||
|
/// to widen one or the other (ADR 0033, D33): the pane from [SidePane.least]
|
||||||
|
/// wide, [main] keeping [SidePane.mainLeast], the width kept in [widths] as
|
||||||
|
/// [kind]'s, [fallback] until one is chosen. A window too narrow for what was
|
||||||
|
/// chosen shows less of it, and the choice stays.
|
||||||
|
class const SidePane({
|
||||||
|
required final SidePaneKind kind,
|
||||||
|
required final PaneWidths widths,
|
||||||
|
required final double fallback,
|
||||||
|
required final Widget main,
|
||||||
|
required final Widget pane,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
/// The narrowest a pane gets.
|
||||||
|
static const least = 300.0;
|
||||||
|
|
||||||
|
/// The narrowest a pane leaves what is beside it.
|
||||||
|
static const mainLeast = 360.0;
|
||||||
|
|
||||||
|
/// How wide the line can be taken hold of.
|
||||||
|
static const grip = 9.0;
|
||||||
|
|
||||||
|
/// A step of the keyboard or a screen reader.
|
||||||
|
static const _step = 20.0;
|
||||||
|
|
||||||
|
/// The key of the line.
|
||||||
|
static ValueKey<String> gripKey(SidePaneKind kind) =>
|
||||||
|
ValueKey('pane-grip-${kind.name}');
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SidePane> createState() => _SidePaneState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SidePaneState() extends State<SidePane> {
|
||||||
|
var _hovered = false;
|
||||||
|
var _dragging = false;
|
||||||
|
|
||||||
|
/// What may be chosen in [available]; never more than leaves any of it.
|
||||||
|
static double _fit(double width, double available) {
|
||||||
|
final most = max(SidePane.least, available - SidePane.mainLeast);
|
||||||
|
return min(width.clamp(SidePane.least, most), max(available - 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _to(double width, double available) =>
|
||||||
|
widget.widths.drag(widget.kind, _fit(width, available));
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final available = constraints.maxWidth;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: widget.widths,
|
||||||
|
// Outside, so that a drag doesn't build what is beside the line.
|
||||||
|
child: widget.main,
|
||||||
|
builder: (context, main) {
|
||||||
|
final width = _fit(
|
||||||
|
widget.widths.of(widget.kind) ?? widget.fallback,
|
||||||
|
available,
|
||||||
|
);
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(child: main!),
|
||||||
|
VerticalDivider(
|
||||||
|
width: 1,
|
||||||
|
color: _hovered || _dragging ? scheme.primary : null,
|
||||||
|
),
|
||||||
|
SizedBox(width: width, child: widget.pane),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
key: SidePane.gripKey(widget.kind),
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: width - (SidePane.grip - 1) / 2,
|
||||||
|
width: SidePane.grip,
|
||||||
|
child: Semantics(
|
||||||
|
label: AppLocalizations.of(context).paneResize,
|
||||||
|
onIncrease: () => _to(width + SidePane._step, available),
|
||||||
|
onDecrease: () => _to(width - SidePane._step, available),
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.resizeColumn,
|
||||||
|
onEnter: (_) => setState(() => _hovered = true),
|
||||||
|
onExit: (_) => setState(() => _hovered = false),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onHorizontalDragStart: (_) =>
|
||||||
|
setState(() => _dragging = true),
|
||||||
|
onHorizontalDragUpdate: (details) => _to(
|
||||||
|
(widget.widths.of(widget.kind) ?? width) -
|
||||||
|
details.delta.dx,
|
||||||
|
available,
|
||||||
|
),
|
||||||
|
onHorizontalDragEnd: (_) {
|
||||||
|
setState(() => _dragging = false);
|
||||||
|
unawaited(widget.widths.save());
|
||||||
|
},
|
||||||
|
onHorizontalDragCancel: () =>
|
||||||
|
setState(() => _dragging = false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,9 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
|
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
|
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
@ -51,6 +53,9 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
/// The projects the Plan shows, for as long as the app runs (none: all).
|
/// The projects the Plan shows, for as long as the app runs (none: all).
|
||||||
Set<String> _projects = const {};
|
Set<String> _projects = const {};
|
||||||
|
|
||||||
|
/// Keeps the day where it was scrolled as the Plan comes and goes.
|
||||||
|
final _grid = GlobalKey();
|
||||||
|
|
||||||
/// Whether the Plan stands beside the day, as the body's width allows,
|
/// Whether the Plan stands beside the day, as the body's width allows,
|
||||||
/// rather than over its foot (as last laid out).
|
/// rather than over its foot (as last laid out).
|
||||||
bool _wide = false;
|
bool _wide = false;
|
||||||
|
|
@ -143,6 +148,7 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
);
|
);
|
||||||
// The day's last hours can be scrolled above a phone's Plan.
|
// The day's last hours can be scrolled above a phone's Plan.
|
||||||
Widget grid({double foot = 88}) => HourGrid(
|
Widget grid({double foot = 88}) => HourGrid(
|
||||||
|
key: _grid,
|
||||||
days: 1,
|
days: 1,
|
||||||
foot: foot,
|
foot: foot,
|
||||||
coloring: TaskColoring.priority,
|
coloring: TaskColoring.priority,
|
||||||
|
|
@ -164,22 +170,18 @@ class _TodayTabState() extends State<TodayTab> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (_wide) {
|
if (_wide) {
|
||||||
return Row(
|
return view.plan
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
? SidePane(
|
||||||
children: [
|
kind: SidePaneKind.plan,
|
||||||
Expanded(child: grid()),
|
widths: PersonScope.of<TodoPerson>(context).paneWidths,
|
||||||
if (view.plan) ...[
|
fallback: 340,
|
||||||
const VerticalDivider(width: 1),
|
main: grid(),
|
||||||
SizedBox(
|
pane: PlanPanel(
|
||||||
width: 340,
|
|
||||||
child: PlanPanel(
|
|
||||||
projectIds: _projects,
|
projectIds: _projects,
|
||||||
onProjects: projects,
|
onProjects: projects,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
],
|
: grid();
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// A phone's Plan over the day's foot, above the floating bar; held
|
// A phone's Plan over the day's foot, above the floating bar; held
|
||||||
// rows lower it to its heading, the day free behind it.
|
// rows lower it to its heading, the day free behind it.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/gestures.dart' show kLongPressTimeout;
|
import 'package:flutter/gestures.dart'
|
||||||
|
show PointerDeviceKind, kLongPressTimeout;
|
||||||
import 'package:flutter/rendering.dart' show RenderParagraph;
|
import 'package:flutter/rendering.dart' show RenderParagraph;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
@ -1132,6 +1133,77 @@ void main() {
|
||||||
expect(find.text('Far'), findsOneWidget);
|
expect(find.text('Far'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'a click adds a task at that quarter hour, for half an hour',
|
||||||
|
(tester) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final today = Day.of(DateTime.now());
|
||||||
|
final column = find.byKey(HourGrid.columnKey(today));
|
||||||
|
// 14:15 and a little: an hour is 60 logical pixels.
|
||||||
|
final at = tester.getTopLeft(column) + const Offset(20, 14 * 60 + 20);
|
||||||
|
final mouse = await tester.startGesture(
|
||||||
|
at,
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final date = (await tester.runAsync(() => tasks().all()))!
|
||||||
|
.single
|
||||||
|
.content!
|
||||||
|
.date!;
|
||||||
|
expect(date.day, today);
|
||||||
|
expect(date.time, 14 * 60 + 15);
|
||||||
|
expect(date.duration, const Duration(minutes: 30));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets("a finger's tap adds a task at that quarter hour too", (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final today = Day.of(DateTime.now());
|
||||||
|
final column = find.byKey(HourGrid.columnKey(today));
|
||||||
|
await tester.tapAt(
|
||||||
|
tester.getTopLeft(column) + const Offset(20, 14 * 60 + 35),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final date = (await tester.runAsync(
|
||||||
|
() => tasks().all(),
|
||||||
|
))!.single.content!.date!;
|
||||||
|
expect(date.time, 14 * 60 + 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a tap that stops the day scrolling adds nothing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final column = find.byKey(
|
||||||
|
HourGrid.columnKey(Day.of(DateTime.now())),
|
||||||
|
);
|
||||||
|
final at = tester.getCenter(find.byType(HourGrid));
|
||||||
|
await tester.flingFrom(at, const Offset(0, -300), 2000);
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
||||||
|
await tester.tapAt(at);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(column, findsOneWidget);
|
||||||
|
expect(find.byKey(quickAddTitleKey), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a long press on a half hour adds a task there for half an '
|
testWidgets('a long press on a half hour adds a task there for half an '
|
||||||
'hour', (tester) async {
|
'hour', (tester) async {
|
||||||
await start(tester, usedBefore: true);
|
await start(tester, usedBefore: true);
|
||||||
|
|
|
||||||
113
apps/todo/halkyon_todo_flutter/test/tabs/side_pane_test.dart
Normal file
113
apps/todo/halkyon_todo_flutter/test/tabs/side_pane_test.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The line beside a pane dragged (ADR 0033, D33).
|
||||||
|
void main() {
|
||||||
|
const paneKey = ValueKey('pane');
|
||||||
|
|
||||||
|
Future<void> pump(WidgetTester tester, PaneWidths widths) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 800);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: SidePane(
|
||||||
|
kind: SidePaneKind.task,
|
||||||
|
widths: widths,
|
||||||
|
fallback: 420,
|
||||||
|
main: const SizedBox.expand(),
|
||||||
|
pane: const SizedBox.expand(key: paneKey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double width(WidgetTester tester) =>
|
||||||
|
tester.getSize(find.byKey(paneKey)).width;
|
||||||
|
|
||||||
|
Future<void> drag(WidgetTester tester, double dx) async {
|
||||||
|
await tester.timedDrag(
|
||||||
|
find.byKey(SidePane.gripKey(SidePaneKind.task)),
|
||||||
|
Offset(dx, 0),
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('starts at its width, and the line dragged widens it', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pump(tester, PaneWidths());
|
||||||
|
expect(width(tester), 420);
|
||||||
|
|
||||||
|
await drag(tester, -100);
|
||||||
|
expect(width(tester), closeTo(520, 40));
|
||||||
|
final wider = width(tester);
|
||||||
|
|
||||||
|
await drag(tester, 60);
|
||||||
|
expect(width(tester), lessThan(wider));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('stops short of too narrow a pane, and of too narrow a '
|
||||||
|
'calendar', (tester) async {
|
||||||
|
await pump(tester, PaneWidths());
|
||||||
|
|
||||||
|
await drag(tester, 800);
|
||||||
|
expect(width(tester), SidePane.least);
|
||||||
|
|
||||||
|
await drag(tester, -1100);
|
||||||
|
expect(width(tester), 1200 - SidePane.mainLeast);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a width from a wider window fits this one, and is kept', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final widths = PaneWidths()..drag(SidePaneKind.task, 1000);
|
||||||
|
await pump(tester, widths);
|
||||||
|
|
||||||
|
expect(width(tester), 1200 - SidePane.mainLeast);
|
||||||
|
expect(widths.of(SidePaneKind.task), 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'the width chosen is there at the next start, each pane its own',
|
||||||
|
() async {
|
||||||
|
final folder = await Directory.systemTemp.createTemp('panes');
|
||||||
|
addTearDown(() => folder.delete(recursive: true));
|
||||||
|
final file = File('${folder.path}/panes.json');
|
||||||
|
|
||||||
|
final widths = await PaneWidths.open(file);
|
||||||
|
expect(widths.of(SidePaneKind.plan), isNull);
|
||||||
|
widths
|
||||||
|
..drag(SidePaneKind.plan, 380)
|
||||||
|
..drag(SidePaneKind.task, 500);
|
||||||
|
await widths.save();
|
||||||
|
|
||||||
|
final again = await PaneWidths.open(file);
|
||||||
|
expect(again.of(SidePaneKind.plan), 380);
|
||||||
|
expect(again.of(SidePaneKind.task), 500);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a file that can't be read means no choice yet", () async {
|
||||||
|
final folder = await Directory.systemTemp.createTemp('panes');
|
||||||
|
addTearDown(() => folder.delete(recursive: true));
|
||||||
|
final file = File('${folder.path}/panes.json')
|
||||||
|
..writeAsStringSync(jsonEncode([1]));
|
||||||
|
|
||||||
|
expect((await PaneWidths.open(file)).of(SidePaneKind.task), isNull);
|
||||||
|
file.writeAsStringSync('{');
|
||||||
|
expect((await PaneWidths.open(file)).of(SidePaneKind.task), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -714,6 +714,22 @@ The developer's review of 4 Nov 2026, again beside Todoist's screens.
|
||||||
- Android's backup and transfer to another phone are off, as Train's D53 (agent's
|
- Android's backup and transfer to another phone are off, as Train's D53 (agent's
|
||||||
choice): the reminders scheduled keep their tasks' titles unsealed on the phone.
|
choice): the reminders scheduled keep their tasks' titles unsealed on the phone.
|
||||||
|
|
||||||
|
- **D33 Side panes widened, a click adds a task** (the developer's, 5 Nov 2026, from
|
||||||
|
Todoist's desktop app; the details the agent's):
|
||||||
|
- The line beside the task pane (D2) and Today's Plan (D30) is dragged left and right
|
||||||
|
(`SidePane`), so wherever a pane stands beside a list or a calendar. A pane stays
|
||||||
|
300 px wide at least and leaves 360 px beside it; a window too narrow for the width
|
||||||
|
chosen shows less of it, and the choice stays. Each pane keeps its own width
|
||||||
|
(`PaneWidths`, the file `panes.json`): widths belong to a window, so they are the
|
||||||
|
device's, neither sealed nor synced, as the device's look is. One width for both was
|
||||||
|
the alternative; the Plan starts narrower than a task.
|
||||||
|
- In the hour grid (`HourGrid`, so Today's calendar and Upcoming's 3-day) a tap or a
|
||||||
|
click opens quick add at the quarter hour under it, for half an hour, on a phone
|
||||||
|
too: a list's or board's + is dragged for the place between tasks, while in a
|
||||||
|
calendar where it lands already says when. A long press stays, in quarter hours,
|
||||||
|
no more half hours. A tap that stops the day scrolling doesn't add, as a fling
|
||||||
|
keeps pointers from what it scrolls.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- No email reminder, morning overview or evening review: the server can't read what is
|
- No email reminder, morning overview or evening review: the server can't read what is
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue