feat(todo): pick a place on a map, for quick add and a task's Where (ADR 0033, D32)
The location picker searches by city or district and name through PlaceSearchGateway, its results a list and pins on Train's OpenFreeMap map, or takes a pin placed by hand; recent places, and None, Arrive or Leave. TodoDependencies.places is the seam for the instance's search; until then komoot's public Photon, in a debug build asked for it alone. Quick add has `+` suggestions, an entry in its + menu and a pin button; the task sheet a Where box; a task's row the place's name. ReminderScheduler plans the places to watch into LocationReminderGateway, a no-op off Android, which TodoPerson.dispose clears, and Settings' Reminders says while the device may not watch them. Whats-New: Tasks can have locations: type + or use the + menu, search by city and name or pick on the map, and be reminded on arriving or leaving (Android). Whats-New-De: Aufgaben können Orte haben: Tippe + oder nutze das +-Menü, suche nach Stadt und Name oder wähle auf der Karte, und lass dich beim Ankommen oder Verlassen erinnern (Android). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1db888a679
commit
77d6a5528c
6 changed files with 2540 additions and 18 deletions
|
|
@ -10,6 +10,9 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
|||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
|
||||
|
|
@ -39,6 +42,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_location_gateway.dart';
|
||||
import '../support/memory_reminder_gateway.dart';
|
||||
import '../support/memory_sealed_row_gateway.dart';
|
||||
|
||||
|
|
@ -59,6 +63,7 @@ void main() {
|
|||
late MemorySealedRowGateway labelRows;
|
||||
late MemorySealedRowGateway settingsRows;
|
||||
late MemoryReminderGateway reminders;
|
||||
late MemoryLocationGateway locationGateway;
|
||||
late FakeSpeechInput speech;
|
||||
|
||||
/// How many syncs ran.
|
||||
|
|
@ -88,6 +93,7 @@ void main() {
|
|||
labelRows = MemorySealedRowGateway();
|
||||
settingsRows = MemorySealedRowGateway();
|
||||
reminders = MemoryReminderGateway();
|
||||
locationGateway = MemoryLocationGateway();
|
||||
speech = FakeSpeechInput();
|
||||
store = MemoryInstanceStore();
|
||||
looks = MemoryLookStore();
|
||||
|
|
@ -189,6 +195,7 @@ void main() {
|
|||
labels: LabelRepository(labelRows, keys),
|
||||
settings: settings,
|
||||
reminders: reminders,
|
||||
locationReminders: locationGateway,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -1895,6 +1902,139 @@ void main() {
|
|||
expect(content.labelIds, [errand]);
|
||||
});
|
||||
|
||||
group('+location (D32)', () {
|
||||
const edeka = TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
address: 'Aeschach, Lindau',
|
||||
);
|
||||
|
||||
/// A task at [edeka], so that it is a recent place.
|
||||
Future<void> doneBefore(WidgetTester tester) async {
|
||||
await tester.runAsync(() async {
|
||||
final inbox = await ProjectRepository(
|
||||
projectRows,
|
||||
keys,
|
||||
).ensureInbox();
|
||||
await tasks().add(
|
||||
TaskContent(
|
||||
title: 'Before',
|
||||
projectId: inbox,
|
||||
locations: const [edeka],
|
||||
),
|
||||
);
|
||||
});
|
||||
taskRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a recent place is offered; tapped, it goes with the '
|
||||
'task, its word out of the title', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneBefore(tester);
|
||||
await goTab(tester, 'Inbox');
|
||||
expect(find.textContaining('EDEKA'), findsOneWidget, reason: 'row');
|
||||
await open(tester, 'Shop +ede');
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byKey(quickAddSuggestionsKey),
|
||||
matching: find.text('EDEKA'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await add(tester);
|
||||
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
final added = stored!.singleWhere(
|
||||
(task) => task.content!.title == 'Shop',
|
||||
);
|
||||
expect(added.content!.locations, [edeka]);
|
||||
});
|
||||
|
||||
testWidgets('Backspace after it keeps it as text', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneBefore(tester);
|
||||
await goTab(tester, 'Inbox');
|
||||
await open(tester, 'Shop +EDEKA');
|
||||
final title = tester
|
||||
.widget<TextField>(find.byKey(quickAddTitleKey))
|
||||
.controller!;
|
||||
expect(find.byTooltip('Location'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byKey(quickAddTitleKey), 'Shop +EDEK');
|
||||
await tester.pumpAndSettle();
|
||||
expect(title.text, 'Shop +EDEKA', reason: 'kept, not deleted');
|
||||
expect(find.byTooltip('Location'), findsNothing);
|
||||
await add(tester);
|
||||
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
final added = stored!.singleWhere(
|
||||
(task) => task.content!.title == 'Shop +EDEKA',
|
||||
);
|
||||
expect(added.content!.locations, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a place not named before opens the search, which says '
|
||||
'where there is none', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Inbox');
|
||||
await open(tester, 'Shop +Lindau EDEKA');
|
||||
|
||||
await tester.tap(
|
||||
find.widgetWithText(ListTile, 'Search for “Lindau EDEKA”'),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.byKey(locationMapKey), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining("Search isn't available"),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a sum is no place', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Inbox');
|
||||
await open(tester, '2 + 2');
|
||||
expect(find.byKey(quickAddSuggestionsKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("the + menu's Location opens the picker", (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Inbox');
|
||||
await open(tester, 'Shop');
|
||||
await tester.tap(find.byTooltip('More').last);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Location'));
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.byKey(locationMapKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a task's Where shows its places, each with its ⋮", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await doneBefore(tester);
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Before'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Add location'),
|
||||
100,
|
||||
scrollable: find.byType(Scrollable).last,
|
||||
);
|
||||
expect(find.textContaining('Aeschach, Lindau'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Location'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Remove'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
expect(stored!.single.content!.locations, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('nothing is suggested for a # inside a word, nor where no '
|
||||
'name matches', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
|
|
@ -2269,6 +2409,88 @@ void main() {
|
|||
expect(stored.content?.completed, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a place with a reminder is watched, in the words of the '
|
||||
"app's language, until the task is done, not one without", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Shop',
|
||||
projectId: inbox(),
|
||||
locations: const [
|
||||
TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
trigger: LocationTrigger.arrive,
|
||||
),
|
||||
TaskLocation(name: 'Lidl', lat: 47.56, lon: 9.71),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final watch = locationGateway.watched.single;
|
||||
expect(watch.title, 'Shop');
|
||||
expect(watch.body, "You're at EDEKA");
|
||||
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(locationGateway.watched, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('an unchanged plan is not handed over again, nor is a person '
|
||||
'asked anything for it', (tester) async {
|
||||
locationGateway.allowed = LocationAccess.whileInUse;
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Shop',
|
||||
projectId: inbox(),
|
||||
locations: const [
|
||||
TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
trigger: LocationTrigger.leave,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final replaced = locationGateway.replaced;
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(title: 'Other', projectId: inbox()),
|
||||
);
|
||||
expect(locationGateway.replaced, replaced);
|
||||
});
|
||||
|
||||
testWidgets("a person gone, their places aren't watched", (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Shop',
|
||||
projectId: inbox(),
|
||||
locations: const [
|
||||
TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
trigger: LocationTrigger.arrive,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(locationGateway.watched, hasLength(1));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pumpAndSettle();
|
||||
expect(locationGateway.watched, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("a task's reminder is scheduled on the device before its "
|
||||
'time, and goes once it is done', (tester) async {
|
||||
final tomorrow = Day.of(DateTime.now()).plus(1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_access.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class _Gateway(var LocationAccess now) implements LocationReminderGateway {
|
||||
LocationAccess onAsk = LocationAccess.whileInUse;
|
||||
LocationAccess onAskAlways = LocationAccess.always;
|
||||
final asked = <String>[];
|
||||
int settings = 0;
|
||||
|
||||
@override
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<LocationAccess> access() async => now;
|
||||
|
||||
@override
|
||||
Future<LocationAccess> askWhileInUse() async {
|
||||
asked.add('in use');
|
||||
return now = onAsk;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocationAccess> askAlways() async {
|
||||
asked.add('always');
|
||||
return now = onAskAlways;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async => settings++;
|
||||
|
||||
@override
|
||||
Future<LatLon?> lastKnown() async => null;
|
||||
|
||||
@override
|
||||
Future<void> replace(
|
||||
List<LocationWatch> watches, {
|
||||
required String channel,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
/// Whether places can be watched (ADR 0033, D32).
|
||||
void main() {
|
||||
test('off only while places are planned and it may not watch', () async {
|
||||
final gateway = _Gateway(LocationAccess.whileInUse);
|
||||
final access = LocationReminderAccess(gateway);
|
||||
expect(access.off, isFalse);
|
||||
await access.planned(any: true);
|
||||
expect(access.off, isTrue);
|
||||
await access.planned(any: false);
|
||||
expect(access.off, isFalse);
|
||||
});
|
||||
|
||||
test('planning never asks: the person is told why first', () async {
|
||||
final gateway = _Gateway(LocationAccess.none);
|
||||
await LocationReminderAccess(gateway).planned(any: true);
|
||||
expect(gateway.asked, isEmpty);
|
||||
});
|
||||
|
||||
test('asks for the position first, then at all times', () async {
|
||||
final gateway = _Gateway(LocationAccess.none);
|
||||
expect(await LocationReminderAccess(gateway).ask(), isTrue);
|
||||
expect(gateway.asked, ['in use', 'always']);
|
||||
});
|
||||
|
||||
test('refused the position, it stops there', () async {
|
||||
final gateway = _Gateway(LocationAccess.none)..onAsk = LocationAccess.none;
|
||||
expect(await LocationReminderAccess(gateway).ask(), isFalse);
|
||||
expect(gateway.asked, ['in use']);
|
||||
});
|
||||
|
||||
test('Turn on opens the settings where the system asks no more', () async {
|
||||
final gateway = _Gateway(LocationAccess.whileInUse)
|
||||
..onAskAlways = LocationAccess.whileInUse;
|
||||
await LocationReminderAccess(gateway).turnOn();
|
||||
expect(gateway.settings, 1);
|
||||
});
|
||||
|
||||
test('Turn on leaves the settings closed once allowed', () async {
|
||||
final gateway = _Gateway(LocationAccess.whileInUse);
|
||||
final access = LocationReminderAccess(gateway);
|
||||
await access.planned(any: true);
|
||||
await access.turnOn();
|
||||
expect(gateway.settings, 0);
|
||||
expect(access.off, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_access.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
class _Places(final List<TaskLocation> found) implements PlaceSearchGateway {
|
||||
final asked = <String>[];
|
||||
LatLon? near;
|
||||
|
||||
@override
|
||||
Future<List<TaskLocation>> search(
|
||||
String query, {
|
||||
LatLon? near,
|
||||
String? language,
|
||||
}) async {
|
||||
asked.add(query);
|
||||
this.near = near;
|
||||
return found;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TaskLocation?> at(LatLon point, {String? language}) async => null;
|
||||
}
|
||||
|
||||
/// The location picker (ADR 0033, D32), without tiles.
|
||||
void main() {
|
||||
const aeschach = TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
address: 'Ludwig-Kick-Straße 1, Aeschach, Lindau',
|
||||
);
|
||||
const reutin = TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.56,
|
||||
lon: 9.71,
|
||||
address: 'Bregenzer Straße 2, Reutin, Lindau',
|
||||
);
|
||||
|
||||
Future<List<TaskLocation?>> open(
|
||||
WidgetTester tester, {
|
||||
PlaceSearchGateway? places,
|
||||
List<TaskLocation> recents = const [],
|
||||
String? query,
|
||||
}) async {
|
||||
final picked = <TaskLocation?>[];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: todoLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () async {
|
||||
picked.add(
|
||||
await Navigator.of(context).push<TaskLocation>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => LocationPicker(
|
||||
places: places,
|
||||
mapTiles: null,
|
||||
access: LocationReminderAccess(
|
||||
const NoLocationReminders(),
|
||||
),
|
||||
position: () async => null,
|
||||
recents: recents,
|
||||
query: query,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
return picked;
|
||||
}
|
||||
|
||||
testWidgets('results tell two of a name apart by their addresses', (
|
||||
tester,
|
||||
) async {
|
||||
final places = _Places(const [aeschach, reutin]);
|
||||
await open(tester, places: places);
|
||||
await tester.enterText(find.byKey(locationSearchKey), 'Lindau EDEKA');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.pump();
|
||||
expect(places.asked, ['Lindau EDEKA']);
|
||||
expect(find.text(aeschach.address), findsOneWidget);
|
||||
expect(find.text(reutin.address), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a row tapped is the place added', (tester) async {
|
||||
final picked = await open(
|
||||
tester,
|
||||
places: _Places(const [aeschach, reutin]),
|
||||
query: 'Lindau EDEKA',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(reutin.address));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(locationAddKey));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(picked, [reutin]);
|
||||
});
|
||||
|
||||
testWidgets('a search answers what was asked last, and says what failed', (
|
||||
tester,
|
||||
) async {
|
||||
await open(tester, places: _Places(const []), query: 'Nowhere');
|
||||
await tester.pump();
|
||||
expect(find.text('Nothing found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('without a search it says so, the map stays', (tester) async {
|
||||
await open(tester);
|
||||
expect(
|
||||
find.text(
|
||||
"Search isn't available here. Move the map and tap to place a pin.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(locationSearchKey), findsNothing);
|
||||
expect(find.byKey(locationMapKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('recent places show before a search', (tester) async {
|
||||
await open(tester, places: _Places(const []), recents: const [aeschach]);
|
||||
expect(find.text('Recent'), findsOneWidget);
|
||||
expect(find.text(aeschach.address), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no reminder is offered on a device that watches nothing', (
|
||||
tester,
|
||||
) async {
|
||||
await open(
|
||||
tester,
|
||||
places: _Places(const [aeschach]),
|
||||
query: 'EDEKA',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(aeschach.address));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.text('On arriving'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Which places the device watches (ADR 0033, D32).
|
||||
void main() {
|
||||
const arrive = TaskLocation(
|
||||
name: 'EDEKA',
|
||||
lat: 47.55,
|
||||
lon: 9.70,
|
||||
trigger: LocationTrigger.arrive,
|
||||
);
|
||||
const leave = TaskLocation(
|
||||
name: 'Flat',
|
||||
lat: 47.54,
|
||||
lon: 9.69,
|
||||
trigger: LocationTrigger.leave,
|
||||
);
|
||||
const quiet = TaskLocation(name: 'Lidl', lat: 47.56, lon: 9.71);
|
||||
|
||||
StoredTask task(
|
||||
String id, {
|
||||
List<TaskLocation> locations = const [arrive],
|
||||
DateTime? completedAt,
|
||||
String title = 'Shop',
|
||||
TaskDate? date,
|
||||
}) => StoredTask(
|
||||
id,
|
||||
TaskContent(
|
||||
title: title,
|
||||
projectId: 'p',
|
||||
locations: locations,
|
||||
completedAt: completedAt,
|
||||
date: date,
|
||||
),
|
||||
);
|
||||
|
||||
test('a place reminding, and none of those that do not', () {
|
||||
final plan = planLocationWatches([
|
||||
task('a', locations: const [quiet, arrive, leave]),
|
||||
]);
|
||||
expect(plan.map((watch) => watch.name), ['EDEKA', 'Flat']);
|
||||
expect(plan.first.arrive, isTrue);
|
||||
expect(plan.last.arrive, isFalse);
|
||||
});
|
||||
|
||||
test('nothing of a task done, or one that does not open', () {
|
||||
expect(
|
||||
planLocationWatches([
|
||||
task('a', completedAt: DateTime(2026)),
|
||||
const StoredTask('b', null),
|
||||
]),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test("a task's alternatives share its task", () {
|
||||
final plan = planLocationWatches([
|
||||
task('a', locations: const [arrive, leave]),
|
||||
]);
|
||||
expect(plan.map((watch) => watch.taskId).toSet(), {'a'});
|
||||
expect(plan.map((watch) => watch.id).toSet(), hasLength(2));
|
||||
});
|
||||
|
||||
test('an ID the same on every plan', () {
|
||||
expect(
|
||||
planLocationWatches([task('a')]).single.id,
|
||||
planLocationWatches([task('a')]).single.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('a changed task reminds anew: its stamp changes with what it is', () {
|
||||
final stamp = planLocationWatches([task('a')]).single.stamp;
|
||||
expect(planLocationWatches([task('a')]).single.stamp, stamp);
|
||||
expect(
|
||||
planLocationWatches([task('a', title: 'Shop more')]).single.stamp,
|
||||
isNot(stamp),
|
||||
);
|
||||
expect(
|
||||
planLocationWatches([
|
||||
task('a', date: const TaskDate(Day(2026, 11, 6))),
|
||||
]).single.stamp,
|
||||
isNot(stamp),
|
||||
);
|
||||
expect(
|
||||
planLocationWatches([
|
||||
task('a', locations: const [arrive, quiet]),
|
||||
]).single.stamp,
|
||||
isNot(stamp),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/data/place_search_gateway.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Replies as Photon's (ADR 0033, D32).
|
||||
void main() {
|
||||
Map<String, Object?> feature(
|
||||
Map<String, Object?> properties, {
|
||||
List<num> at = const [9.70, 47.55],
|
||||
}) => {
|
||||
'geometry': {'coordinates': at},
|
||||
'properties': properties,
|
||||
};
|
||||
|
||||
test('a place: its name, then street, district and city', () {
|
||||
final [place] = placesOfPhoton({
|
||||
'features': [
|
||||
feature({
|
||||
'name': 'EDEKA',
|
||||
'street': 'Ludwig-Kick-Straße',
|
||||
'housenumber': '1',
|
||||
'district': 'Aeschach',
|
||||
'city': 'Lindau (Bodensee)',
|
||||
'osm_type': 'W',
|
||||
'osm_id': 42,
|
||||
'osm_value': 'supermarket',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(place.name, 'EDEKA');
|
||||
expect(place.address, 'Ludwig-Kick-Straße 1, Aeschach, Lindau (Bodensee)');
|
||||
expect(place.lat, 47.55);
|
||||
expect(place.lon, 9.70);
|
||||
expect(place.osmType, 'W');
|
||||
expect(place.osmId, 42);
|
||||
});
|
||||
|
||||
test('a car park and a charging station named after it are left out', () {
|
||||
expect(
|
||||
placesOfPhoton({
|
||||
'features': [
|
||||
feature({'name': 'EDEKA', 'osm_value': 'parking'}),
|
||||
feature({'name': 'Lidl Lindau', 'osm_value': 'charging_station'}),
|
||||
feature({'name': 'EDEKA', 'osm_value': 'supermarket'}),
|
||||
],
|
||||
}),
|
||||
hasLength(1),
|
||||
);
|
||||
});
|
||||
|
||||
test('an address is named by its street, not said twice', () {
|
||||
final [place] = placesOfPhoton({
|
||||
'features': [
|
||||
feature({
|
||||
'street': 'Orlandostraße',
|
||||
'housenumber': '5',
|
||||
'city': 'München',
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(place.name, 'Orlandostraße 5');
|
||||
expect(place.address, 'München');
|
||||
});
|
||||
|
||||
test('a feature without a name or a street is left out', () {
|
||||
expect(
|
||||
placesOfPhoton({
|
||||
'features': [
|
||||
feature({'city': 'München'}),
|
||||
],
|
||||
}),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('no features: nothing', () {
|
||||
expect(placesOfPhoton(const {}), isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
|
||||
|
||||
/// The device's watching of places, in memory, as a test can read it.
|
||||
class MemoryLocationGateway() implements LocationReminderGateway {
|
||||
/// The places watched now.
|
||||
List<LocationWatch> watched = const [];
|
||||
|
||||
/// What the device allows.
|
||||
LocationAccess allowed = LocationAccess.always;
|
||||
|
||||
/// How often the places were handed over.
|
||||
int replaced = 0;
|
||||
|
||||
@override
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<void> replace(
|
||||
List<LocationWatch> watches, {
|
||||
required String channel,
|
||||
}) async {
|
||||
watched = watches;
|
||||
replaced++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocationAccess> access() async => allowed;
|
||||
|
||||
@override
|
||||
Future<LocationAccess> askWhileInUse() async => allowed;
|
||||
|
||||
@override
|
||||
Future<LocationAccess> askAlways() async => allowed;
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {}
|
||||
|
||||
@override
|
||||
Future<LatLon?> lastKnown() async => null;
|
||||
}
|
||||
Loading…
Reference in a new issue