feat(todo): a task's locations, in its payload and as quick add's + (ADR 0033, D32)
TaskLocation (name, address, position, optional OSM ID, reminder) goes in the sealed payload as `locations`, schema still 1; recent places are read from the tasks. parseQuickAdd takes `+name` of a place named before, the longest name first, kept as text as any other token (D29); an unknown `+word` stays text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
011b144ba1
commit
1db888a679
6 changed files with 300 additions and 8 deletions
|
|
@ -0,0 +1,77 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// When a location reminds of its task (ADR 0033, D32): not at all, on
|
||||||
|
/// arriving there, or on leaving it.
|
||||||
|
enum LocationTrigger() {
|
||||||
|
none,
|
||||||
|
arrive,
|
||||||
|
leave,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A place a task happens at (ADR 0033, D32): what people call it, where it
|
||||||
|
/// lies, and whether it reminds of the task; several on a task are
|
||||||
|
/// alternatives (either EDEKA). Kept in the task's sealed payload, so the
|
||||||
|
/// instance sees none of it.
|
||||||
|
final class const TaskLocation({
|
||||||
|
/// What people call it: `EDEKA`.
|
||||||
|
required final String name,
|
||||||
|
required final double lat,
|
||||||
|
required final double lon,
|
||||||
|
|
||||||
|
/// Its street, district and city, telling two of a name apart.
|
||||||
|
final String address = '',
|
||||||
|
|
||||||
|
/// Its object in OpenStreetMap (`W`, `N` or `R`) where it came from there,
|
||||||
|
/// for its opening hours and a link to correct them.
|
||||||
|
final String? osmType,
|
||||||
|
final int? osmId,
|
||||||
|
final LocationTrigger trigger = LocationTrigger.none,
|
||||||
|
}) with Equatable {
|
||||||
|
/// What tells it from any other place, whatever its reminder.
|
||||||
|
String get key => '${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}';
|
||||||
|
|
||||||
|
/// The same place, reminding as [trigger] says.
|
||||||
|
TaskLocation reminding(LocationTrigger trigger) => TaskLocation(
|
||||||
|
name: name,
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
address: address,
|
||||||
|
osmType: osmType,
|
||||||
|
osmId: osmId,
|
||||||
|
trigger: trigger,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [name, lat, lon, address, osmType, osmId, trigger];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [locations] of tasks as the places to offer again, the first named being
|
||||||
|
/// the first, each once and reminding of nothing, at most [limit]
|
||||||
|
/// (Todoist's Recent locations): nothing of them is kept but what tasks hold.
|
||||||
|
List<TaskLocation> recentLocations(
|
||||||
|
Iterable<TaskLocation> locations, {
|
||||||
|
int limit = 8,
|
||||||
|
}) {
|
||||||
|
final seen = <String>{};
|
||||||
|
return [
|
||||||
|
for (final location in locations)
|
||||||
|
if (seen.add(location.key)) location.reminding(LocationTrigger.none),
|
||||||
|
].take(limit).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a task's row names its [locations]: the first, and how many more.
|
||||||
|
/// [locations] with [added], in place of the same place reminding otherwise.
|
||||||
|
List<TaskLocation> withLocation(
|
||||||
|
List<TaskLocation> locations,
|
||||||
|
TaskLocation added,
|
||||||
|
) => [
|
||||||
|
for (final location in locations)
|
||||||
|
if (location.key != added.key) location,
|
||||||
|
added,
|
||||||
|
];
|
||||||
|
|
||||||
|
String? locationsText(List<TaskLocation> locations) => switch (locations) {
|
||||||
|
[] => null,
|
||||||
|
[final only] => only.name,
|
||||||
|
[final first, ...final rest] => '${first.name} +${rest.length}',
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ const _known = {
|
||||||
'labelIds',
|
'labelIds',
|
||||||
'reminders',
|
'reminders',
|
||||||
'reminderTimes',
|
'reminderTimes',
|
||||||
|
'locations',
|
||||||
'completedAt',
|
'completedAt',
|
||||||
'completions',
|
'completions',
|
||||||
'hideDoneSubTasks',
|
'hideDoneSubTasks',
|
||||||
|
|
@ -36,6 +38,7 @@ const _known = {
|
||||||
/// the priority as its number, when it was done in UTC; a recurrence (D7)
|
/// the priority as its number, when it was done in UTC; a recurrence (D7)
|
||||||
/// within its date, by its unit's name and only what differs from its
|
/// within its date, by its unit's name and only what differs from its
|
||||||
/// defaults; reminders at a time of their own as a day and minutes (D18);
|
/// defaults; reminders at a time of their own as a day and minutes (D18);
|
||||||
|
/// locations (D32) with what differs from their defaults;
|
||||||
/// what a newer version wrote and this one doesn't know as it was, beneath
|
/// what a newer version wrote and this one doesn't know as it was, beneath
|
||||||
/// what this one writes.
|
/// what this one writes.
|
||||||
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||||
|
|
@ -62,6 +65,8 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||||
for (final at in content.reminderTimes)
|
for (final at in content.reminderTimes)
|
||||||
{'day': '${at.day}', 'time': at.time},
|
{'day': '${at.day}', 'time': at.time},
|
||||||
],
|
],
|
||||||
|
if (content.locations.isNotEmpty)
|
||||||
|
'locations': [for (final at in content.locations) _encodeLocation(at)],
|
||||||
if (content.completedAt case final at?)
|
if (content.completedAt case final at?)
|
||||||
'completedAt': at.toUtc().toIso8601String(),
|
'completedAt': at.toUtc().toIso8601String(),
|
||||||
if (content.completions.isNotEmpty)
|
if (content.completions.isNotEmpty)
|
||||||
|
|
@ -71,6 +76,17 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||||
if (content.hideDoneSubTasks) 'hideDoneSubTasks': true,
|
if (content.hideDoneSubTasks) 'hideDoneSubTasks': true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Map<String, Object?> _encodeLocation(TaskLocation location) => {
|
||||||
|
'name': location.name,
|
||||||
|
'lat': location.lat,
|
||||||
|
'lon': location.lon,
|
||||||
|
if (location.address.isNotEmpty) 'address': location.address,
|
||||||
|
'osmType': ?location.osmType,
|
||||||
|
'osmId': ?location.osmId,
|
||||||
|
if (location.trigger != LocationTrigger.none)
|
||||||
|
'trigger': location.trigger.name,
|
||||||
|
};
|
||||||
|
|
||||||
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
|
Map<String, Object?> _encodeRepeat(Repeat repeat) => {
|
||||||
'unit': repeat.unit.name,
|
'unit': repeat.unit.name,
|
||||||
if (repeat.every != 1) 'every': repeat.every,
|
if (repeat.every != 1) 'every': repeat.every,
|
||||||
|
|
@ -126,6 +142,10 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
|
||||||
for (final at in json['reminderTimes'] as List<Object?>? ?? const [])
|
for (final at in json['reminderTimes'] as List<Object?>? ?? const [])
|
||||||
_reminderTime(at),
|
_reminderTime(at),
|
||||||
],
|
],
|
||||||
|
locations: [
|
||||||
|
for (final at in json['locations'] as List<Object?>? ?? const [])
|
||||||
|
_location(at),
|
||||||
|
],
|
||||||
completions: [
|
completions: [
|
||||||
for (final at in json['completions'] as List<Object?>? ?? const [])
|
for (final at in json['completions'] as List<Object?>? ?? const [])
|
||||||
DateTime.parse(at! as String).toLocal(),
|
DateTime.parse(at! as String).toLocal(),
|
||||||
|
|
@ -150,6 +170,36 @@ ReminderTime _reminderTime(Object? json) {
|
||||||
throw const FormatException('reminderTimes');
|
throw const FormatException('reminderTimes');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A location as [_encodeLocation] wrote it (D32); throws for one that
|
||||||
|
/// isn't, so that its task isn't read half.
|
||||||
|
TaskLocation _location(Object? json) {
|
||||||
|
if (json
|
||||||
|
case {
|
||||||
|
'name': final String name,
|
||||||
|
'lat': final num lat,
|
||||||
|
'lon': final num lon,
|
||||||
|
}
|
||||||
|
when lat.abs() <= 90 && lon.abs() <= 180) {
|
||||||
|
final at = json as Map<String, Object?>;
|
||||||
|
return TaskLocation(
|
||||||
|
name: name,
|
||||||
|
lat: lat.toDouble(),
|
||||||
|
lon: lon.toDouble(),
|
||||||
|
address: at['address'] as String? ?? '',
|
||||||
|
osmType: at['osmType'] as String?,
|
||||||
|
osmId: at['osmId'] as int?,
|
||||||
|
trigger: switch (at['trigger']) {
|
||||||
|
null => LocationTrigger.none,
|
||||||
|
final String name =>
|
||||||
|
LocationTrigger.values.asNameMap()[name] ??
|
||||||
|
(throw const FormatException('trigger')),
|
||||||
|
_ => throw const FormatException('trigger'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw const FormatException('locations');
|
||||||
|
}
|
||||||
|
|
||||||
TaskDate _date(Map<String, Object?> json) {
|
TaskDate _date(Map<String, Object?> json) {
|
||||||
final day = Day.tryParse(json['day']! as String);
|
final day = Day.tryParse(json['day']! as String);
|
||||||
final time = json['time'] as int?;
|
final time = json['time'] as int?;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||||
enum QuickAddKind() {
|
enum QuickAddKind() {
|
||||||
project,
|
project,
|
||||||
label,
|
label,
|
||||||
|
|
||||||
|
/// `+EDEKA`, a location named before (D32).
|
||||||
|
location,
|
||||||
deadline,
|
deadline,
|
||||||
priority,
|
priority,
|
||||||
|
|
||||||
|
|
@ -43,6 +46,9 @@ final class const ParsedTask({
|
||||||
|
|
||||||
/// Labels named after `@` that don't exist yet, to be made.
|
/// Labels named after `@` that don't exist yet, to be made.
|
||||||
final List<String> newLabels = const [],
|
final List<String> newLabels = const [],
|
||||||
|
|
||||||
|
/// The locations named after `+` (D32), by the IDs they were given by.
|
||||||
|
final List<String> locationIds = const [],
|
||||||
final Priority? priority,
|
final Priority? priority,
|
||||||
final Day? deadline,
|
final Day? deadline,
|
||||||
final TaskDate? date,
|
final TaskDate? date,
|
||||||
|
|
@ -59,7 +65,8 @@ typedef NamedId = ({String id, String name});
|
||||||
|
|
||||||
/// Reads quick add's [text] at [now] (ADR 0033, D15): `#project` and
|
/// Reads quick add's [text] at [now] (ADR 0033, D15): `#project` and
|
||||||
/// `@label` by their names (the longest matching, the case aside; an
|
/// `@label` by their names (the longest matching, the case aside; an
|
||||||
/// unknown label to be made, an unknown project left as text), `{deadline}`,
|
/// unknown label to be made, an unknown project left as text), `+location`
|
||||||
|
/// as those, each once, one not named before left as text (D32), `{deadline}`,
|
||||||
/// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that
|
/// `!!1` and `p1` to `p4`, `!30m` (due that long from now, or reminded that
|
||||||
/// long before its time), `!1pm` and `!tomorrow 9am` (reminded then, D18),
|
/// long before its time), `!1pm` and `!tomorrow 9am` (reminded then, D18),
|
||||||
/// and dates (`on` before one taken with it), times, durations (`for 2h`,
|
/// and dates (`on` before one taken with it), times, durations (`for 2h`,
|
||||||
|
|
@ -73,8 +80,9 @@ ParsedTask parseQuickAdd(
|
||||||
required List<QuickAddWords> words,
|
required List<QuickAddWords> words,
|
||||||
List<NamedId> projects = const [],
|
List<NamedId> projects = const [],
|
||||||
List<NamedId> labels = const [],
|
List<NamedId> labels = const [],
|
||||||
|
List<NamedId> locations = const [],
|
||||||
List<QuickAddRange> ignored = const [],
|
List<QuickAddRange> ignored = const [],
|
||||||
}) => _Parser(text, now, words, ignored).parse(projects, labels);
|
}) => _Parser(text, now, words, ignored).parse(projects, labels, locations);
|
||||||
|
|
||||||
/// A stretch of quick add's text, from `start` to `end`.
|
/// A stretch of quick add's text, from `start` to `end`.
|
||||||
typedef QuickAddRange = ({int start, int end});
|
typedef QuickAddRange = ({int start, int end});
|
||||||
|
|
@ -128,6 +136,7 @@ class _Parser(
|
||||||
String? _projectId;
|
String? _projectId;
|
||||||
final _labelIds = <String>[];
|
final _labelIds = <String>[];
|
||||||
final _newLabels = <String>[];
|
final _newLabels = <String>[];
|
||||||
|
final _locationIds = <String>[];
|
||||||
Priority? _priority;
|
Priority? _priority;
|
||||||
Day? _deadline;
|
Day? _deadline;
|
||||||
Day? _day;
|
Day? _day;
|
||||||
|
|
@ -157,8 +166,12 @@ class _Parser(
|
||||||
static bool _boundary(String text, int at) =>
|
static bool _boundary(String text, int at) =>
|
||||||
at < 0 || at >= text.length || text[at].trim().isEmpty;
|
at < 0 || at >= text.length || text[at].trim().isEmpty;
|
||||||
|
|
||||||
ParsedTask parse(List<NamedId> projects, List<NamedId> labels) {
|
ParsedTask parse(
|
||||||
_symbols(projects, labels);
|
List<NamedId> projects,
|
||||||
|
List<NamedId> labels,
|
||||||
|
List<NamedId> locations,
|
||||||
|
) {
|
||||||
|
_symbols(projects, labels, locations);
|
||||||
_reminderTimesIn();
|
_reminderTimesIn();
|
||||||
_durationIn();
|
_durationIn();
|
||||||
_phrases();
|
_phrases();
|
||||||
|
|
@ -167,7 +180,11 @@ class _Parser(
|
||||||
|
|
||||||
// The symbols, in every language.
|
// The symbols, in every language.
|
||||||
|
|
||||||
void _symbols(List<NamedId> projects, List<NamedId> labels) {
|
void _symbols(
|
||||||
|
List<NamedId> projects,
|
||||||
|
List<NamedId> labels,
|
||||||
|
List<NamedId> locations,
|
||||||
|
) {
|
||||||
final lowered = _text.toLowerCase();
|
final lowered = _text.toLowerCase();
|
||||||
final byLength = [
|
final byLength = [
|
||||||
...projects,
|
...projects,
|
||||||
|
|
@ -175,12 +192,20 @@ class _Parser(
|
||||||
final labelsByLength = [
|
final labelsByLength = [
|
||||||
...labels,
|
...labels,
|
||||||
]..sort((a, b) => b.name.length.compareTo(a.name.length));
|
]..sort((a, b) => b.name.length.compareTo(a.name.length));
|
||||||
|
final locationsByLength = [
|
||||||
|
...locations,
|
||||||
|
]..sort((a, b) => b.name.length.compareTo(a.name.length));
|
||||||
for (var at = 0; at < _text.length; at++) {
|
for (var at = 0; at < _text.length; at++) {
|
||||||
final char = _text[at];
|
final char = _text[at];
|
||||||
if ((char != '#' && char != '@') || !_boundary(_text, at - 1)) {
|
if ((char != '#' && char != '@' && char != '+') ||
|
||||||
|
!_boundary(_text, at - 1)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final named = char == '#' ? byLength : labelsByLength;
|
final named = switch (char) {
|
||||||
|
'#' => byLength,
|
||||||
|
'@' => labelsByLength,
|
||||||
|
_ => locationsByLength,
|
||||||
|
};
|
||||||
NamedId? found;
|
NamedId? found;
|
||||||
for (final candidate in named) {
|
for (final candidate in named) {
|
||||||
final name = candidate.name.toLowerCase();
|
final name = candidate.name.toLowerCase();
|
||||||
|
|
@ -193,7 +218,14 @@ class _Parser(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (char == '#') {
|
if (char == '+') {
|
||||||
|
if (found != null) {
|
||||||
|
if (!_locationIds.contains(found.id)) {
|
||||||
|
_locationIds.add(found.id);
|
||||||
|
}
|
||||||
|
_take(QuickAddKind.location, at, at + 1 + found.name.length);
|
||||||
|
}
|
||||||
|
} else if (char == '#') {
|
||||||
if (found != null && _projectId == null) {
|
if (found != null && _projectId == null) {
|
||||||
_projectId = found.id;
|
_projectId = found.id;
|
||||||
_take(QuickAddKind.project, at, at + 1 + found.name.length);
|
_take(QuickAddKind.project, at, at + 1 + found.name.length);
|
||||||
|
|
@ -746,6 +778,7 @@ class _Parser(
|
||||||
projectId: _projectId,
|
projectId: _projectId,
|
||||||
labelIds: List.unmodifiable(_labelIds),
|
labelIds: List.unmodifiable(_labelIds),
|
||||||
newLabels: List.unmodifiable(_newLabels),
|
newLabels: List.unmodifiable(_newLabels),
|
||||||
|
locationIds: List.unmodifiable(_locationIds),
|
||||||
priority: _priority,
|
priority: _priority,
|
||||||
deadline: _deadline,
|
deadline: _deadline,
|
||||||
date: day == null
|
date: day == null
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||||
|
|
||||||
/// A day of the calendar, as a task's date and deadline name it (ADR 0033,
|
/// A day of the calendar, as a task's date and deadline name it (ADR 0033,
|
||||||
|
|
@ -167,6 +168,10 @@ final class const TaskContent({
|
||||||
/// whatever its date.
|
/// whatever its date.
|
||||||
final List<ReminderTime> reminderTimes = const [],
|
final List<ReminderTime> reminderTimes = const [],
|
||||||
|
|
||||||
|
/// Where it happens (D32): several are alternatives, each reminding of
|
||||||
|
/// it on arriving or leaving where it says so.
|
||||||
|
final List<TaskLocation> locations = const [],
|
||||||
|
|
||||||
/// When it was done; none: not yet.
|
/// When it was done; none: not yet.
|
||||||
final DateTime? completedAt,
|
final DateTime? completedAt,
|
||||||
|
|
||||||
|
|
@ -200,6 +205,7 @@ final class const TaskContent({
|
||||||
List<String>? labelIds,
|
List<String>? labelIds,
|
||||||
List<int>? reminders,
|
List<int>? reminders,
|
||||||
List<ReminderTime>? reminderTimes,
|
List<ReminderTime>? reminderTimes,
|
||||||
|
List<TaskLocation>? locations,
|
||||||
DateTime? Function()? completedAt,
|
DateTime? Function()? completedAt,
|
||||||
List<DateTime>? completions,
|
List<DateTime>? completions,
|
||||||
bool? hideDoneSubTasks,
|
bool? hideDoneSubTasks,
|
||||||
|
|
@ -216,6 +222,7 @@ final class const TaskContent({
|
||||||
labelIds: labelIds ?? this.labelIds,
|
labelIds: labelIds ?? this.labelIds,
|
||||||
reminders: reminders ?? this.reminders,
|
reminders: reminders ?? this.reminders,
|
||||||
reminderTimes: reminderTimes ?? this.reminderTimes,
|
reminderTimes: reminderTimes ?? this.reminderTimes,
|
||||||
|
locations: locations ?? this.locations,
|
||||||
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
||||||
completions: completions ?? this.completions,
|
completions: completions ?? this.completions,
|
||||||
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
|
hideDoneSubTasks: hideDoneSubTasks ?? this.hideDoneSubTasks,
|
||||||
|
|
@ -236,6 +243,7 @@ final class const TaskContent({
|
||||||
labelIds,
|
labelIds,
|
||||||
reminders,
|
reminders,
|
||||||
reminderTimes,
|
reminderTimes,
|
||||||
|
locations,
|
||||||
completedAt,
|
completedAt,
|
||||||
completions,
|
completions,
|
||||||
hideDoneSubTasks,
|
hideDoneSubTasks,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A task's locations (ADR 0033, D32).
|
||||||
|
void main() {
|
||||||
|
const edeka = TaskLocation(
|
||||||
|
name: 'EDEKA',
|
||||||
|
lat: 47.55,
|
||||||
|
lon: 9.70,
|
||||||
|
address: 'Ludwig-Kick-Straße 1, Aeschach, Lindau',
|
||||||
|
osmType: 'W',
|
||||||
|
osmId: 42,
|
||||||
|
trigger: LocationTrigger.arrive,
|
||||||
|
);
|
||||||
|
const lidl = TaskLocation(name: 'Lidl', lat: 47.56, lon: 9.71);
|
||||||
|
|
||||||
|
group('in the sealed payload', () {
|
||||||
|
test('every field comes back, a location without a reminder too', () {
|
||||||
|
const task = TaskContent(
|
||||||
|
title: 'Shop',
|
||||||
|
projectId: 'p',
|
||||||
|
locations: [edeka, lidl],
|
||||||
|
);
|
||||||
|
expect(decodeTask(encodeTask(task)), task);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('its schema stays 1', () {
|
||||||
|
const task = TaskContent(
|
||||||
|
title: 'Shop',
|
||||||
|
projectId: 'p',
|
||||||
|
locations: [edeka],
|
||||||
|
);
|
||||||
|
expect(decodeTask(encodeTask(task))!.unknown, isEmpty);
|
||||||
|
expect(taskSchemaVersion, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other fields changing keep them', () {
|
||||||
|
const task = TaskContent(
|
||||||
|
title: 'Shop',
|
||||||
|
projectId: 'p',
|
||||||
|
locations: [edeka],
|
||||||
|
);
|
||||||
|
expect(task.copyWith(title: 'B').locations, [edeka]);
|
||||||
|
expect(task.copyWith(locations: const []).locations, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('recent locations', () {
|
||||||
|
test('each place once, reminding of nothing, the first first', () {
|
||||||
|
expect(
|
||||||
|
recentLocations([edeka, lidl, edeka.reminding(LocationTrigger.leave)]),
|
||||||
|
[edeka.reminding(LocationTrigger.none), lidl],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no more than the limit', () {
|
||||||
|
expect(recentLocations([edeka, lidl], limit: 1), hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two of a name at different places stay two', () {
|
||||||
|
const other = TaskLocation(name: 'EDEKA', lat: 47.60, lon: 9.65);
|
||||||
|
expect(recentLocations([edeka, other]), hasLength(2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding a place again replaces it', () {
|
||||||
|
expect(
|
||||||
|
withLocation(const [
|
||||||
|
edeka,
|
||||||
|
lidl,
|
||||||
|
], edeka.reminding(LocationTrigger.leave)),
|
||||||
|
[lidl, edeka.reminding(LocationTrigger.leave)],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a task's row names the first and how many more", () {
|
||||||
|
expect(locationsText(const []), isNull);
|
||||||
|
expect(locationsText(const [lidl]), 'Lidl');
|
||||||
|
expect(locationsText(const [edeka, lidl]), 'EDEKA +1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,11 @@ void main() {
|
||||||
];
|
];
|
||||||
const labels = [(id: 'l1', name: 'HalkyonToDo'), (id: 'l2', name: 'errand')];
|
const labels = [(id: 'l1', name: 'HalkyonToDo'), (id: 'l2', name: 'errand')];
|
||||||
|
|
||||||
|
const locations = [
|
||||||
|
(id: 'edeka', name: 'EDEKA'),
|
||||||
|
(id: 'koros', name: 'EDEKA Koros'),
|
||||||
|
];
|
||||||
|
|
||||||
ParsedTask parse(
|
ParsedTask parse(
|
||||||
String text, {
|
String text, {
|
||||||
List<QuickAddWords> words = const [QuickAddWords.en],
|
List<QuickAddWords> words = const [QuickAddWords.en],
|
||||||
|
|
@ -26,9 +31,45 @@ void main() {
|
||||||
words: words,
|
words: words,
|
||||||
projects: projects,
|
projects: projects,
|
||||||
labels: labels,
|
labels: labels,
|
||||||
|
locations: locations,
|
||||||
ignored: ignored,
|
ignored: ignored,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
group('+location (D32)', () {
|
||||||
|
test('a place named before, the longest name, the case aside', () {
|
||||||
|
final added = parse('Shop +edeka koros tomorrow');
|
||||||
|
expect(added.title, 'Shop');
|
||||||
|
expect(added.locationIds, ['koros']);
|
||||||
|
expect(added.tokens.first.kind, QuickAddKind.location);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('several, each once', () {
|
||||||
|
expect(parse('Shop +EDEKA +edeka +EDEKA Koros').locationIds, [
|
||||||
|
'edeka',
|
||||||
|
'koros',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one not named before stays text: there's nothing to take", () {
|
||||||
|
final added = parse('Shop +Rewe');
|
||||||
|
expect(added.title, 'Shop +Rewe');
|
||||||
|
expect(added.locationIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a + inside a word is none', () {
|
||||||
|
expect(parse('a+EDEKA').locationIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kept as text where the person said so (D29), there alone', () {
|
||||||
|
final added = parse(
|
||||||
|
'Shop +EDEKA and +EDEKA',
|
||||||
|
ignored: const [(start: 5, end: 11)],
|
||||||
|
);
|
||||||
|
expect(added.title, 'Shop +EDEKA and');
|
||||||
|
expect(added.locationIds, ['edeka']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('symbols, in every language', () {
|
group('symbols, in every language', () {
|
||||||
test('#project, the longest name matching, the case aside', () {
|
test('#project, the longest name matching, the case aside', () {
|
||||||
final added = parse('Clean Gutters #inbox');
|
final added = parse('Clean Gutters #inbox');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue