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:
parent
c5eceda383
commit
8b862b2c1b
3 changed files with 353 additions and 95 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,8 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:flutter/gestures.dart'
|
||||||
|
show DragStartBehavior, PointerDeviceKind;
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.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.
|
/// The key of the line at the time it is now.
|
||||||
static const nowKey = ValueKey('grid-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
|
@override
|
||||||
State<HourGrid> createState() => _HourGridState();
|
State<HourGrid> createState() => _HourGridState();
|
||||||
}
|
}
|
||||||
|
|
@ -447,18 +454,74 @@ class const _DayColumn({
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DayColumnState() extends State<_DayColumn> {
|
class _DayColumnState() extends State<_DayColumn> {
|
||||||
/// The quarter hour held or clicked, in minutes after midnight, while
|
/// The stretch chosen, while it is dragged and while quick add is open
|
||||||
/// quick add is open.
|
/// for it.
|
||||||
int? _held;
|
({int start, int minutes})? _held;
|
||||||
|
|
||||||
Future<void> _add(double dy) async {
|
/// The quarter hour pressed, from 0 to 95, while a stretch is dragged.
|
||||||
final minutes = (dy / (_hourHeight / 4)).floor().clamp(0, 95) * 15;
|
int? _anchor;
|
||||||
setState(() => _held = minutes);
|
|
||||||
|
/// 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(
|
await showQuickAdd(
|
||||||
context,
|
context,
|
||||||
day: widget.day,
|
day: widget.day,
|
||||||
time: minutes,
|
time: stretch.start,
|
||||||
duration: const Duration(minutes: minBlockMinutes),
|
duration: Duration(minutes: stretch.minutes),
|
||||||
);
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _held = null);
|
setState(() => _held = null);
|
||||||
|
|
@ -502,111 +565,141 @@ class _DayColumnState() extends State<_DayColumn> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _hours(ThemeData theme, Color line) {
|
Widget _hours(ThemeData theme, Color line) {
|
||||||
final scheme = theme.colorScheme;
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
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
|
// 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.
|
// tap that stops the day scrolling doesn't reach here.
|
||||||
onTapUp: (details) => unawaited(_add(details.localPosition.dy)),
|
onTapUp: (details) {
|
||||||
child: DecoratedBox(
|
final quarter = _quarterAt(details.localPosition.dy);
|
||||||
decoration: BoxDecoration(
|
unawaited(_add(_stretch(quarter, quarter)));
|
||||||
border: Border(left: BorderSide(color: line)),
|
},
|
||||||
),
|
child: GestureDetector(
|
||||||
child: LayoutBuilder(
|
supportedDevices: const {PointerDeviceKind.mouse},
|
||||||
builder: (context, constraints) {
|
dragStartBehavior: DragStartBehavior.down,
|
||||||
final width = constraints.maxWidth;
|
onVerticalDragStart: (details) => _press(details.localPosition.dy),
|
||||||
double top(int minutes) => minutes / 60 * _hourHeight;
|
onVerticalDragUpdate: (details) => _drag(details.localPosition.dy),
|
||||||
return Stack(
|
onVerticalDragEnd: (_) => _letGo(),
|
||||||
children: [
|
onVerticalDragCancel: _cancel,
|
||||||
for (var hour = 1; hour < 24; hour++)
|
child: _column(theme, line),
|
||||||
Positioned(
|
),
|
||||||
top: top(hour * 60),
|
);
|
||||||
left: 0,
|
}
|
||||||
right: 0,
|
|
||||||
child: Divider(height: 1, color: 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(
|
if (_hover case final hover?)
|
||||||
top: top(held),
|
Positioned(
|
||||||
height: _hourHeight / 2,
|
top: top(hover),
|
||||||
left: 2,
|
height: _hourHeight / 2,
|
||||||
right: 2,
|
left: 2,
|
||||||
|
right: HourGrid.freeStrip,
|
||||||
|
child: IgnorePointer(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: scheme.surfaceContainerHighest,
|
color: scheme.primary.withValues(alpha: 0.12),
|
||||||
|
border: Border.all(color: scheme.primary),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
if (_hover case final hover?)
|
child: Text(
|
||||||
Positioned(
|
timeText(context, hover),
|
||||||
top: top(hover),
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
height: _hourHeight / 2,
|
color: scheme.primary,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final block in widget.blocks)
|
),
|
||||||
Positioned(
|
for (final block in widget.blocks)
|
||||||
top: top(block.start) + 1,
|
Positioned(
|
||||||
height: top(block.end) - top(block.start) - 2,
|
top: top(block.start) + 1,
|
||||||
left: block.lane * width / block.lanes + 1,
|
height: top(block.end) - top(block.start) - 2,
|
||||||
width: width / block.lanes - 2,
|
left: block.lane * blocksWidth / block.lanes + 1,
|
||||||
child: _TaskBlock(
|
width: blocksWidth / block.lanes - 2,
|
||||||
key: HourGrid.blockKey(
|
child: _TaskBlock(
|
||||||
block.entry.task.id,
|
key: HourGrid.blockKey(
|
||||||
widget.day,
|
block.entry.task.id,
|
||||||
),
|
widget.day,
|
||||||
entry: block.entry,
|
),
|
||||||
coloring: widget.coloring,
|
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2031,6 +2031,171 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('a block leaves a strip of its day free on the right, '
|
||||||
|
"where a tap still adds a task, as Proton Calendar's (the "
|
||||||
|
"developer's review, 10 Nov 2026)", (tester) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
final today = Day.of(DateTime.now());
|
||||||
|
await seed(
|
||||||
|
tester,
|
||||||
|
TaskContent(
|
||||||
|
title: 'Dentist',
|
||||||
|
projectId: inbox(),
|
||||||
|
date: TaskDate(today, time: 9 * 60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await threeDay(tester);
|
||||||
|
final stored = await tester.runAsync(() => tasks().all());
|
||||||
|
final block = find.byKey(
|
||||||
|
HourGrid.blockKey(stored!.single.id, today),
|
||||||
|
);
|
||||||
|
final column = find.byKey(HourGrid.columnKey(today));
|
||||||
|
|
||||||
|
final free =
|
||||||
|
tester.getTopRight(column).dx - tester.getTopRight(block).dx;
|
||||||
|
expect(free, closeTo(HourGrid.freeStrip, 1));
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(block).dx - tester.getTopLeft(column).dx,
|
||||||
|
lessThan(4),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tapAt(
|
||||||
|
Offset(
|
||||||
|
tester.getTopRight(column).dx - HourGrid.freeStrip / 2,
|
||||||
|
tester.getCenter(block).dy,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(quickAddTitleKey), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('a stretch of time chosen by holding and dragging, as Proton '
|
||||||
|
"Calendar's (the developer's review, 10 Nov 2026)", () {
|
||||||
|
/// Presses at [from] down the [day]'s column with [kind], holds
|
||||||
|
/// while a finger must, moves to [to] and lets go; then adds
|
||||||
|
/// "Call".
|
||||||
|
Future<TaskDate> stretch(
|
||||||
|
WidgetTester tester,
|
||||||
|
Day day, {
|
||||||
|
required double from,
|
||||||
|
required double to,
|
||||||
|
PointerDeviceKind kind = PointerDeviceKind.touch,
|
||||||
|
bool add = true,
|
||||||
|
}) async {
|
||||||
|
final top = tester.getTopLeft(find.byKey(HourGrid.columnKey(day)));
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
top + Offset(20, from),
|
||||||
|
kind: kind,
|
||||||
|
);
|
||||||
|
if (kind == PointerDeviceKind.touch) {
|
||||||
|
await tester.pump(
|
||||||
|
kLongPressTimeout + const Duration(milliseconds: 50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (var step = 1; step <= 10; step++) {
|
||||||
|
await gesture.moveTo(
|
||||||
|
top + Offset(20, from + (to - from) * step / 10),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
}
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Add'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return (await tester.runAsync(
|
||||||
|
() => tasks().all(),
|
||||||
|
))!.single.content!.date!;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('a mouse dragged down from 10:05 to 11:40 adds a task '
|
||||||
|
'from 10:00 to 11:45', (tester) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final today = Day.of(DateTime.now());
|
||||||
|
|
||||||
|
final date = await stretch(
|
||||||
|
tester,
|
||||||
|
today,
|
||||||
|
from: 10 * 60 + 5,
|
||||||
|
to: 11 * 60 + 40,
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
expect(date.day, today);
|
||||||
|
expect(date.time, 10 * 60);
|
||||||
|
expect(date.duration, const Duration(minutes: 105));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dragged up, from where it was let go to the end of the '
|
||||||
|
'quarter pressed', (tester) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final today = Day.of(DateTime.now());
|
||||||
|
|
||||||
|
final date = await stretch(
|
||||||
|
tester,
|
||||||
|
today,
|
||||||
|
from: 12 * 60 + 5,
|
||||||
|
to: 10 * 60 + 50,
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
expect(date.time, 10 * 60 + 45);
|
||||||
|
expect(date.duration, const Duration(minutes: 90));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a finger held, then dragged, does the same', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final today = Day.of(DateTime.now());
|
||||||
|
|
||||||
|
final date = await stretch(
|
||||||
|
tester,
|
||||||
|
today,
|
||||||
|
from: 10 * 60 + 5,
|
||||||
|
to: 10 * 60 + 50,
|
||||||
|
);
|
||||||
|
expect(date.time, 10 * 60);
|
||||||
|
expect(date.duration, const Duration(minutes: 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('while dragged, the stretch shows with its times', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final top = tester.getTopLeft(
|
||||||
|
find.byKey(HourGrid.columnKey(Day.of(DateTime.now()))),
|
||||||
|
);
|
||||||
|
final mouse = await tester.startGesture(
|
||||||
|
top + const Offset(20, 10 * 60 + 5),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await mouse.moveTo(top + const Offset(20, 10 * 60 + 40));
|
||||||
|
await tester.pump();
|
||||||
|
await mouse.moveTo(top + const Offset(20, 11 * 60 + 10));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('10:00 – 11:15'), findsOneWidget);
|
||||||
|
await mouse.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a scroll of the day by a finger chooses nothing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await start(tester, usedBefore: true);
|
||||||
|
await threeDay(tester);
|
||||||
|
final at = tester.getCenter(find.byType(HourGrid));
|
||||||
|
await tester.dragFrom(at, const Offset(0, -200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(quickAddTitleKey), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets("a finger's tap adds a task at that quarter hour too", (
|
testWidgets("a finger's tap adds a task at that quarter hour too", (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue