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:
Tizian 「ティツィアーン」 2026-11-05 15:23:53 +01:00
commit 77d6a5528c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 2540 additions and 18 deletions

View file

@ -273,5 +273,30 @@
"planGroupShow": "{group} einblenden",
"planAllProjects": "Alle Projekte",
"planProjectsCount": "{count, plural, =1{1 Projekt} other{{count} Projekte}}",
"planFilterTitle": "Nach Projekt filtern"
"planFilterTitle": "Nach Projekt filtern",
"locationTitle": "Ort",
"locationAdd": "Ort hinzufügen",
"locationWhere": "Wo",
"locationAnyOf": "Einer davon",
"locationSearchHint": "Stadt oder Stadtteil, dann der Name",
"locationSearchAny": "Ort suchen",
"locationSearchFor": "„{query}“ suchen",
"locationSearchUnavailable": "Die Suche ist hier nicht verfügbar. Verschiebe die Karte und tippe, um einen Pin zu setzen.",
"locationSearchFailed": "Die Suche hat nicht geklappt. Versuch es noch einmal.",
"locationNoResults": "Nichts gefunden",
"locationRecent": "Zuletzt verwendet",
"locationPin": "Gesetzter Ort",
"locationName": "Name",
"locationMine": "Mein Standort",
"locationRemindNone": "Keine Erinnerung",
"locationRemindArrive": "Beim Ankommen",
"locationRemindLeave": "Beim Verlassen",
"locationRemove": "Entfernen",
"locationAccessTitle": "Standort immer erlauben",
"locationAccessHint": "Damit Halkyon Todo dich beim Ankommen oder Verlassen erinnert, braucht es deinen Standort auch, wenn es geschlossen ist. Er bleibt auf diesem Gerät. Wähle als Nächstes „Immer zulassen“.",
"locationAccessGo": "Weiter",
"locationOffTitle": "Standort ist für Erinnerungen aus",
"locationOffHint": "Erinnerungen an einem Ort brauchen „Immer zulassen“.",
"locationArrived": "Du bist bei {name}",
"locationLeft": "Du hast {name} verlassen"
}

View file

@ -910,5 +910,120 @@
"planFilterTitle": "Filter by project",
"@planFilterTitle": {
"description": "Today's Plan's project filter (ADR 0033, D30): its search field's hint and its name."
},
"locationTitle": "Location",
"@locationTitle": {
"description": "Quick add's + menu and its button (ADR 0033, D32)."
},
"locationAdd": "Add location",
"@locationAdd": {
"description": "A task's Where, and the picker's button."
},
"locationWhere": "Where",
"@locationWhere": {
"description": "The heading of a task's places."
},
"locationAnyOf": "Any of these",
"@locationAnyOf": {
"description": "Under a task's Where, with several places: they are alternatives."
},
"locationSearchHint": "City or district, then the name",
"@locationSearchHint": {
"description": "The location picker's search field."
},
"locationSearchAny": "Search for a place",
"@locationSearchAny": {
"description": "Quick add, as + is typed."
},
"locationSearchFor": "Search for “{query}”",
"@locationSearchFor": {
"description": "Quick add, as +something is typed.",
"placeholders": {
"query": {
"type": "String"
}
}
},
"locationSearchUnavailable": "Search isn't available here. Move the map and tap to place a pin.",
"@locationSearchUnavailable": {
"description": "The location picker without a place search."
},
"locationSearchFailed": "The search didn't work. Try again.",
"@locationSearchFailed": {
"description": "The location picker after a failed search."
},
"locationNoResults": "Nothing found",
"@locationNoResults": {
"description": "The location picker, a search without results."
},
"locationRecent": "Recent",
"@locationRecent": {
"description": "The location picker's places of the person's tasks."
},
"locationPin": "Pinned place",
"@locationPin": {
"description": "The name of a pin placed by hand where nothing names the spot."
},
"locationName": "Name",
"@locationName": {
"description": "The name field of a pin placed by hand."
},
"locationMine": "My position",
"@locationMine": {
"description": "The map's button."
},
"locationRemindNone": "No reminder",
"@locationRemindNone": {
"description": "A location's reminder."
},
"locationRemindArrive": "On arriving",
"@locationRemindArrive": {
"description": "A location's reminder."
},
"locationRemindLeave": "On leaving",
"@locationRemindLeave": {
"description": "A location's reminder."
},
"locationRemove": "Remove",
"@locationRemove": {
"description": "A task's location's menu."
},
"locationAccessTitle": "Allow location all the time",
"@locationAccessTitle": {
"description": "Before asking for the position in the background."
},
"locationAccessHint": "To remind you when you arrive or leave, Halkyon Todo needs your position while it is closed too. It stays on this device. Next, choose “Allow all the time”.",
"@locationAccessHint": {
"description": "Before asking for the position in the background."
},
"locationAccessGo": "Continue",
"@locationAccessGo": {
"description": "Before asking for the position in the background."
},
"locationOffTitle": "Location is off for reminders",
"@locationOffTitle": {
"description": "Settings' Reminders while places are planned and may not be watched."
},
"locationOffHint": "Reminders at a place need “Allow all the time”.",
"@locationOffHint": {
"description": "Settings' Reminders while places are planned and may not be watched."
},
"locationArrived": "You're at {name}",
"@locationArrived": {
"description": "A reminder on arriving, below the task's title.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"locationLeft": "You've left {name}",
"@locationLeft": {
"description": "A reminder on leaving, below the task's title.",
"placeholders": {
"name": {
"type": "String"
}
}
}
}

View file

@ -1743,6 +1743,156 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Filter by project'**
String get planFilterTitle;
/// Quick add's + menu and its button (ADR 0033, D32).
///
/// In en, this message translates to:
/// **'Location'**
String get locationTitle;
/// A task's Where, and the picker's button.
///
/// In en, this message translates to:
/// **'Add location'**
String get locationAdd;
/// The heading of a task's places.
///
/// In en, this message translates to:
/// **'Where'**
String get locationWhere;
/// Under a task's Where, with several places: they are alternatives.
///
/// In en, this message translates to:
/// **'Any of these'**
String get locationAnyOf;
/// The location picker's search field.
///
/// In en, this message translates to:
/// **'City or district, then the name'**
String get locationSearchHint;
/// Quick add, as + is typed.
///
/// In en, this message translates to:
/// **'Search for a place'**
String get locationSearchAny;
/// Quick add, as +something is typed.
///
/// In en, this message translates to:
/// **'Search for “{query}”'**
String locationSearchFor(String query);
/// The location picker without a place search.
///
/// In en, this message translates to:
/// **'Search isn\'t available here. Move the map and tap to place a pin.'**
String get locationSearchUnavailable;
/// The location picker after a failed search.
///
/// In en, this message translates to:
/// **'The search didn\'t work. Try again.'**
String get locationSearchFailed;
/// The location picker, a search without results.
///
/// In en, this message translates to:
/// **'Nothing found'**
String get locationNoResults;
/// The location picker's places of the person's tasks.
///
/// In en, this message translates to:
/// **'Recent'**
String get locationRecent;
/// The name of a pin placed by hand where nothing names the spot.
///
/// In en, this message translates to:
/// **'Pinned place'**
String get locationPin;
/// The name field of a pin placed by hand.
///
/// In en, this message translates to:
/// **'Name'**
String get locationName;
/// The map's button.
///
/// In en, this message translates to:
/// **'My position'**
String get locationMine;
/// A location's reminder.
///
/// In en, this message translates to:
/// **'No reminder'**
String get locationRemindNone;
/// A location's reminder.
///
/// In en, this message translates to:
/// **'On arriving'**
String get locationRemindArrive;
/// A location's reminder.
///
/// In en, this message translates to:
/// **'On leaving'**
String get locationRemindLeave;
/// A task's location's menu.
///
/// In en, this message translates to:
/// **'Remove'**
String get locationRemove;
/// Before asking for the position in the background.
///
/// In en, this message translates to:
/// **'Allow location all the time'**
String get locationAccessTitle;
/// Before asking for the position in the background.
///
/// In en, this message translates to:
/// **'To remind you when you arrive or leave, Halkyon Todo needs your position while it is closed too. It stays on this device. Next, choose “Allow all the time”.'**
String get locationAccessHint;
/// Before asking for the position in the background.
///
/// In en, this message translates to:
/// **'Continue'**
String get locationAccessGo;
/// Settings' Reminders while places are planned and may not be watched.
///
/// In en, this message translates to:
/// **'Location is off for reminders'**
String get locationOffTitle;
/// Settings' Reminders while places are planned and may not be watched.
///
/// In en, this message translates to:
/// **'Reminders at a place need “Allow all the time”.'**
String get locationOffHint;
/// A reminder on arriving, below the task's title.
///
/// In en, this message translates to:
/// **'You\'re at {name}'**
String locationArrived(String name);
/// A reminder on leaving, below the task's title.
///
/// In en, this message translates to:
/// **'You\'ve left {name}'**
String locationLeft(String name);
}
class _AppLocalizationsDelegate

View file

@ -1082,4 +1082,89 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get planFilterTitle => 'Nach Projekt filtern';
@override
String get locationTitle => 'Ort';
@override
String get locationAdd => 'Ort hinzufügen';
@override
String get locationWhere => 'Wo';
@override
String get locationAnyOf => 'Einer davon';
@override
String get locationSearchHint => 'Stadt oder Stadtteil, dann der Name';
@override
String get locationSearchAny => 'Ort suchen';
@override
String locationSearchFor(String query) {
return '„$query“ suchen';
}
@override
String get locationSearchUnavailable =>
'Die Suche ist hier nicht verfügbar. Verschiebe die Karte und tippe, um einen Pin zu setzen.';
@override
String get locationSearchFailed =>
'Die Suche hat nicht geklappt. Versuch es noch einmal.';
@override
String get locationNoResults => 'Nichts gefunden';
@override
String get locationRecent => 'Zuletzt verwendet';
@override
String get locationPin => 'Gesetzter Ort';
@override
String get locationName => 'Name';
@override
String get locationMine => 'Mein Standort';
@override
String get locationRemindNone => 'Keine Erinnerung';
@override
String get locationRemindArrive => 'Beim Ankommen';
@override
String get locationRemindLeave => 'Beim Verlassen';
@override
String get locationRemove => 'Entfernen';
@override
String get locationAccessTitle => 'Standort immer erlauben';
@override
String get locationAccessHint =>
'Damit Halkyon Todo dich beim Ankommen oder Verlassen erinnert, braucht es deinen Standort auch, wenn es geschlossen ist. Er bleibt auf diesem Gerät. Wähle als Nächstes „Immer zulassen“.';
@override
String get locationAccessGo => 'Weiter';
@override
String get locationOffTitle => 'Standort ist für Erinnerungen aus';
@override
String get locationOffHint =>
'Erinnerungen an einem Ort brauchen „Immer zulassen“.';
@override
String locationArrived(String name) {
return 'Du bist bei $name';
}
@override
String locationLeft(String name) {
return 'Du hast $name verlassen';
}
}

View file

@ -1082,6 +1082,90 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get planFilterTitle => 'Filter by project';
@override
String get locationTitle => 'Location';
@override
String get locationAdd => 'Add location';
@override
String get locationWhere => 'Where';
@override
String get locationAnyOf => 'Any of these';
@override
String get locationSearchHint => 'City or district, then the name';
@override
String get locationSearchAny => 'Search for a place';
@override
String locationSearchFor(String query) {
return 'Search for “$query”';
}
@override
String get locationSearchUnavailable =>
'Search isn\'t available here. Move the map and tap to place a pin.';
@override
String get locationSearchFailed => 'The search didn\'t work. Try again.';
@override
String get locationNoResults => 'Nothing found';
@override
String get locationRecent => 'Recent';
@override
String get locationPin => 'Pinned place';
@override
String get locationName => 'Name';
@override
String get locationMine => 'My position';
@override
String get locationRemindNone => 'No reminder';
@override
String get locationRemindArrive => 'On arriving';
@override
String get locationRemindLeave => 'On leaving';
@override
String get locationRemove => 'Remove';
@override
String get locationAccessTitle => 'Allow location all the time';
@override
String get locationAccessHint =>
'To remind you when you arrive or leave, Halkyon Todo needs your position while it is closed too. It stays on this device. Next, choose “Allow all the time”.';
@override
String get locationAccessGo => 'Continue';
@override
String get locationOffTitle => 'Location is off for reminders';
@override
String get locationOffHint =>
'Reminders at a place need “Allow all the time”.';
@override
String locationArrived(String name) {
return 'You\'re at $name';
}
@override
String locationLeft(String name) {
return 'You\'ve left $name';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
@ -12,6 +13,10 @@ import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.d
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/data/sync_label_gateway.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/widget/map_tiles.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
@ -66,8 +71,20 @@ class TodoPerson(
/// Whether the device may show their reminders, asked once an app start
/// (D31): the device's, where it is given; else one of their own.
final NotificationAccess? _notifications,
/// Where the device watches the places their tasks remind at (D32); none
/// in tests that don't say.
final LocationReminderGateway locationReminders = const NoLocationReminders(),
/// Whether the device may watch them: the device's, where it is given;
/// else one of their own.
final LocationReminderAccess? _locationAccess,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Whether the device may watch the places planned (ADR 0033, D32).
late final LocationReminderAccess locationAccess =
_locationAccess ?? LocationReminderAccess(locationReminders);
/// Whether the device may show their reminders (ADR 0033, D31).
late final NotificationAccess notifications =
_notifications ?? NotificationAccess(reminders);
@ -99,6 +116,7 @@ class TodoPerson(
@override
Future<void> dispose() async {
await reminders.replace(const [], channel: '');
await locationReminders.replace(const [], channel: '');
await _onDispose?.call();
}
}
@ -166,6 +184,15 @@ class TodoDependencies({
/// This build's version and number, which Settings shows at its foot so
/// that a report can name the release; none in tests that don't say.
final ({String version, int? build}) appVersion = (version: '', build: null),
/// Finds places for a task's location (ADR 0033, D32). The seam for the
/// instance's own search: [create] names it. Where there is none, the
/// picker says search isn't available and pins are placed by hand.
final PlaceSearchGateway? places,
/// The tile layer of the location picker's map; none in widget tests,
/// which reach no network.
final MapTiles? mapTiles,
final Future<void> Function()? _onDispose,
}) implements AppDependencies<TodoPerson> {
/// The dependencies above [context] ([AppScope]).
@ -263,6 +290,8 @@ class TodoDependencies({
),
speech: DeviceSpeechInput(),
appVersion: (version: device.appVersion, build: device.appBuild),
places: _publicPhoton ? PublicPhotonGateway() : null,
mapTiles: vectorTiles,
sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl,
openPerson: (person) async {
@ -284,6 +313,8 @@ class TodoDependencies({
accountKeys: () => accountKeys.read(person.authUserId),
reminders: device.reminders,
notifications: device.notifications,
locationReminders: device.locationReminders,
locationAccess: device.locationAccess,
onClosed: closed.complete,
);
} on Object {
@ -322,6 +353,8 @@ Future<TodoPerson> _openPersonData(
required Future<UnlockedAccountKeys?> Function() accountKeys,
required ReminderGateway reminders,
required NotificationAccess notifications,
required LocationReminderGateway locationReminders,
required LocationReminderAccess locationAccess,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -380,6 +413,8 @@ Future<TodoPerson> _openPersonData(
settings: settings,
reminders: reminders,
notifications: notifications,
locationReminders: locationReminders,
locationAccess: locationAccess,
onDispose: () async {
try {
await projects.dispose();
@ -416,11 +451,19 @@ class TodoDevice({
/// The system's notifications, where reminders go (ADR 0033, D5).
final ReminderGateway reminders = const NoReminders(),
/// Where the device watches places (D32): the system's on Android.
final LocationReminderGateway locationReminders = const NoLocationReminders(),
}) {
/// Whether the device may show reminders, asked once an app start,
/// whoever is signed in (D31).
late final NotificationAccess notifications = NotificationAccess(reminders);
/// Whether the device may watch places, whoever is signed in (D32).
late final LocationReminderAccess locationAccess = LocationReminderAccess(
locationReminders,
);
/// Makes them, [builtIn] being the instance the build names.
static Future<TodoDevice> create({required Instance builtIn}) async {
final package = await PackageInfo.fromPlatform();
@ -431,10 +474,17 @@ class TodoDevice({
appBuild: int.tryParse(package.buildNumber),
appVersion: package.version,
reminders: await _reminders(),
locationReminders: Platform.isAndroid
? const AndroidLocationReminders()
: const NoLocationReminders(),
);
}
}
/// Whether this build searches on komoot's public Photon (ADR 0033, D32):
/// a debug build asked to alone, until the instance searches.
const _publicPhoton = kDebugMode && bool.fromEnvironment('TODO_PUBLIC_PHOTON');
/// The system's notifications, or none where they don't start (a desktop
/// without a notification service, say): the app works on without
/// reminders.

View file

@ -0,0 +1,75 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_todo_flutter/src/feature/location/data/location_reminder_gateway.dart';
/// Whether the device may watch the places planned (ADR 0033, D32): while
/// places are planned and it may not, Settings says so ([off]) and offers
/// [turnOn], as `NotificationAccess` for notifications. Never asks by itself:
/// the person is told why first, as they choose a reminder.
class LocationReminderAccess(final LocationReminderGateway _gateway)
extends ChangeNotifier {
/// Whether this device can watch places at all: else none is offered.
bool get available => _gateway.available;
/// Whether the device's position may be read, for the map; asks if not
/// yet, the app in use, where it can.
Future<bool> position() async {
final access = await _gateway.access();
return access != LocationAccess.none ||
await _gateway.askWhileInUse() != LocationAccess.none;
}
/// Whether the device may watch places now.
Future<bool> allowed() async {
final access = await _gateway.access();
_set(access);
return access == LocationAccess.always;
}
bool _planned = false;
LocationAccess? _access;
/// Whether places are planned and the device can't watch them.
bool get off =>
_planned && _access != null && _access != LocationAccess.always;
/// The plan holds places ([any]) or none: what the device allows is read
/// again, the person may have changed it in the system's settings.
Future<void> planned({required bool any}) async {
if (any != _planned) {
_planned = any;
notifyListeners();
}
if (any) {
_set(await _gateway.access());
}
}
/// Asks step by step, as Android has it: the position first, then at all
/// times; whether the device may watch now.
Future<bool> ask() async {
var access = await _gateway.access();
if (access == LocationAccess.none) {
access = await _gateway.askWhileInUse();
}
if (access == LocationAccess.whileInUse) {
access = await _gateway.askAlways();
}
_set(access);
return access == LocationAccess.always;
}
/// Settings' Turn on: asks, and where the system asks no more, opens its
/// settings of the app.
Future<void> turnOn() async {
if (!await ask()) {
await _gateway.openSettings();
}
}
void _set(LocationAccess access) {
if (access != _access) {
_access = access;
notifyListeners();
}
}
}

View file

@ -0,0 +1,109 @@
import 'package:flutter/services.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
/// How far the device may know where it is (ADR 0033, D32): [always] is what
/// arriving and leaving need, the app in the background.
enum LocationAccess() {
none,
whileInUse,
always,
}
/// A point on the map.
typedef LatLon = ({double lat, double lon});
/// Where the device watches places: the system's proximity alerts on Android
/// alone, with no Google services, on the device, as the instance reads no
/// task (D32).
abstract interface class LocationReminderGateway() {
/// Whether this device can watch places at all.
bool get available;
/// Makes [watches] the places watched, those watched before and no longer
/// among them stopped; [channel] names the notifications' kind.
Future<void> replace(List<LocationWatch> watches, {required String channel});
/// What the device allows now.
Future<LocationAccess> access();
/// Asks for the device's position while the app is in use.
Future<LocationAccess> askWhileInUse();
/// Asks for it at all times; from Android 11 on the system's settings of
/// the app, the choice *Allow all the time*.
Future<LocationAccess> askAlways();
/// Opens the system's settings of the app.
Future<void> openSettings();
/// The device's last known position, where it may be read; never asks.
Future<LatLon?> lastKnown();
}
/// No places watched: every system but Android, and tests that don't say.
class const NoLocationReminders() implements LocationReminderGateway {
@override
bool get available => false;
@override
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
}) async {}
@override
Future<LocationAccess> access() async => LocationAccess.none;
@override
Future<LocationAccess> askWhileInUse() async => LocationAccess.none;
@override
Future<LocationAccess> askAlways() async => LocationAccess.none;
@override
Future<void> openSettings() async {}
@override
Future<LatLon?> lastKnown() async => null;
}
/// The Kotlin half (`LocationReminders.kt`), through its channel.
class const AndroidLocationReminders() implements LocationReminderGateway {
@override
bool get available => true;
static const _channel = MethodChannel('app.halkyon.todo/location_reminders');
static LocationAccess _access(String? name) =>
LocationAccess.values.asNameMap()[name] ?? LocationAccess.none;
@override
Future<void> replace(
List<LocationWatch> watches, {
required String channel,
}) => _channel.invokeMethod<void>('replace', {
'watches': [for (final watch in watches) watch.toJson()],
'channel': channel,
});
@override
Future<LocationAccess> access() async =>
_access(await _channel.invokeMethod<String>('access'));
@override
Future<LocationAccess> askWhileInUse() async =>
_access(await _channel.invokeMethod<String>('askWhileInUse'));
@override
Future<LocationAccess> askAlways() async =>
_access(await _channel.invokeMethod<String>('askAlways'));
@override
Future<void> openSettings() => _channel.invokeMethod<void>('openSettings');
@override
Future<LatLon?> lastKnown() async {
final at = await _channel.invokeListMethod<double>('lastKnown');
return at == null ? null : (lat: at[0], lon: at[1]);
}
}

View file

@ -0,0 +1,104 @@
import 'dart:convert';
import 'dart:io';
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';
/// Finds places (ADR 0033, D32): by what people know, a city or district and
/// a name (`Lindau EDEKA`), not an address.
abstract interface class PlaceSearchGateway() {
/// The places [query] finds, those [near] first, named in [language].
Future<List<TaskLocation>> search(
String query, {
LatLon? near,
String? language,
});
/// The place at a point tapped on the map; null where nothing names it.
Future<TaskLocation?> at(LatLon point, {String? language});
}
/// What a search leaves out: places of that kind beside the place itself
/// (`EDEKA` the car park, the charging station at `Lidl Lindau`).
const _beside = {'parking', 'parking_space', 'charging_station'};
/// The places in a Photon reply [json], those [_beside] left out; each
/// named by its own name, or for an address by its street.
List<TaskLocation> placesOfPhoton(Map<String, Object?> json) => [
for (final feature in json['features'] as List<Object?>? ?? const [])
if (feature case {
'properties': final Map<String, Object?> properties,
'geometry': {'coordinates': [final num lon, final num lat]},
})
if (!_beside.contains(properties['osm_value']))
?_place(properties, lat.toDouble(), lon.toDouble()),
];
TaskLocation? _place(Map<String, Object?> at, double lat, double lon) {
final street = [
at['street'],
at['housenumber'],
].whereType<String>().join(' ');
final name = at['name'] as String? ?? (street.isEmpty ? null : street);
if (name == null) {
return null;
}
return TaskLocation(
name: name,
lat: lat,
lon: lon,
address: {
if (street != name) street,
?at['district'] as String?,
?at['city'] as String?,
}.where((part) => part.isNotEmpty).join(', '),
osmType: at['osm_type'] as String?,
osmId: at['osm_id'] as int?,
);
}
/// komoot's public Photon, for debug builds that ask for it
/// (`--dart-define=TODO_PUBLIC_PHOTON=true`) until Todo's instance searches:
/// what is typed goes to komoot, with no account and no personal data.
class PublicPhotonGateway() implements PlaceSearchGateway {
final _client = HttpClient();
Future<List<TaskLocation>> _get(Uri uri) async {
final request = await _client.getUrl(uri);
request.headers.set(HttpHeaders.userAgentHeader, 'halkyon-todo-debug/1.0');
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
if (response.statusCode != HttpStatus.ok) {
throw HttpException('Photon: ${response.statusCode}');
}
return placesOfPhoton(jsonDecode(body) as Map<String, Object?>);
}
@override
Future<List<TaskLocation>> search(
String query, {
LatLon? near,
String? language,
}) => _get(
Uri.https('photon.komoot.io', '/api/', {
'q': query,
'limit': '10',
if (_languages.contains(language)) 'lang': language,
if (near case (:final lat, :final lon)) ...{
'lat': '$lat',
'lon': '$lon',
},
}),
);
@override
Future<TaskLocation?> at(LatLon point, {String? language}) async =>
(await _get(
Uri.https('photon.komoot.io', '/reverse', {
'lat': '${point.lat}',
'lon': '${point.lon}',
}),
)).firstOrNull;
static const _languages = {'en', 'de'};
}

View file

@ -0,0 +1,88 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/model/reminder_plan.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A place the device is to watch for the task [taskId] (ADR 0033, D32): on
/// [arrive] or leaving it, it shows [title]. [id] is the same on every plan;
/// [stamp] changes with the task, so a task that has reminded does again
/// once it changes.
final class const LocationWatch({
required final int id,
required final String taskId,
required final int stamp,
required final String title,
required final String name,
required final double lat,
required final double lon,
required final bool arrive,
/// What its notification says below [title], in the app's language.
final String body = '',
}) with Equatable {
/// This watch, its notification saying [body].
LocationWatch saying(String body) => LocationWatch(
id: id,
taskId: taskId,
stamp: stamp,
title: title,
name: name,
lat: lat,
lon: lon,
arrive: arrive,
body: body,
);
/// As the device's half keeps it.
Map<String, Object> toJson() => {
'id': id,
'taskId': taskId,
'stamp': stamp,
'title': title,
'name': name,
'lat': lat,
'lon': lon,
'arrive': arrive,
'body': body,
};
@override
List<Object?> get props => [
id,
taskId,
stamp,
title,
name,
lat,
lon,
arrive,
body,
];
}
/// The places to watch: every location with a reminder of each open task.
List<LocationWatch> planLocationWatches(List<StoredTask> tasks) => [
for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent(completed: false, :final locations))
for (final location in locations)
if (location.trigger != LocationTrigger.none)
LocationWatch(
id: reminderId(
id,
'place ${location.key} ${location.trigger.name}',
),
taskId: id,
stamp: reminderId(id, _stampOf(content)),
title: content.title,
name: location.name,
lat: location.lat,
lon: location.lon,
arrive: location.trigger == LocationTrigger.arrive,
),
];
String _stampOf(TaskContent content) => [
content.title,
content.date?.day,
for (final at in content.locations) '${at.key} ${at.trigger.name}',
].join('/');

View file

@ -0,0 +1,635 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/map_tiles.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:latlong2/latlong.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The key of the picker's search field.
const locationSearchKey = ValueKey('location-search');
/// The key of the picker's map.
const locationMapKey = ValueKey('location-map');
/// The key of the picker's button adding the place chosen.
const locationAddKey = ValueKey('location-add');
/// The places of the person's tasks, the latest tasks' first.
List<TaskLocation> recentsOf(TodoState state) => recentLocations(
state.tasks.reversed.expand((task) => task.content.locations),
);
/// Asks where (ADR 0033, D32): people know a city or district and a name, not
/// an address, so a search of those ([query] to start with), its places as a
/// list that tells two EDEKAs apart and as pins on the map, or a pin placed
/// by hand; with what it reminds of. The place chosen, if any.
Future<TaskLocation?> showLocationPicker(
BuildContext context, {
String? query,
}) {
final dependencies = TodoDependencies.of(context);
final recents = recentsOf(context.read<TodoCubit>().state);
final todo = PersonScope.of<TodoPerson>(context);
return Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => LocationPicker(
places: dependencies.places,
mapTiles: dependencies.mapTiles,
access: todo.locationAccess,
position: todo.locationReminders.lastKnown,
recents: recents,
query: query,
),
),
);
}
/// The location picker's screen.
class const LocationPicker({
required final PlaceSearchGateway? places,
required final MapTiles? mapTiles,
required final LocationReminderAccess access,
required final Future<LatLon?> Function() position,
required final List<TaskLocation> recents,
final String? query,
super.key,
}) extends StatefulWidget {
@override
State<LocationPicker> createState() => _LocationPickerState();
}
class _LocationPickerState() extends State<LocationPicker> {
/// All of Germany until the phone's position says better.
static const _start = LatLng(51.2, 10.4);
late final _search = TextEditingController(text: widget.query);
final _searchFocus = FocusNode();
final _name = TextEditingController();
final _map = MapController();
var _mapReady = false;
Timer? _typing;
/// Answers to anything but the latest search are dropped.
var _asked = 0;
var _searching = false;
var _failed = false;
List<TaskLocation>? _results;
LatLon? _position;
TaskLocation? _picked;
var _byHand = false;
var _trigger = LocationTrigger.none;
@override
void initState() {
super.initState();
_searchFocus.addListener(() => setState(() {}));
unawaited(_locate());
if (widget.query?.trim().isNotEmpty ?? false) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(_find(widget.query!)),
);
}
}
@override
void dispose() {
_typing?.cancel();
_search.dispose();
_searchFocus.dispose();
_name.dispose();
_map.dispose();
super.dispose();
}
/// Centres the map on the phone where its last position may be read.
Future<void> _locate() async {
final at = await widget.position();
if (!mounted || at == null) {
return;
}
setState(() => _position = at);
if (_mapReady && _results == null && _picked == null) {
_map.move(LatLng(at.lat, at.lon), 14);
}
}
Future<void> _locateAsked() async {
if (!await widget.access.position()) {
return;
}
final at = await widget.position();
if (mounted && at != null) {
setState(() => _position = at);
_map.move(LatLng(at.lat, at.lon), 15);
}
}
void _typed(String text) {
_typing?.cancel();
if (text.trim().isEmpty) {
_asked++;
setState(() {
_results = null;
_searching = false;
_failed = false;
});
return;
}
_typing = Timer(
const Duration(milliseconds: 350),
() => unawaited(_find(text)),
);
}
/// Where a search looks first: where the map shows, else the phone.
LatLon? get _near {
if (_mapReady) {
final centre = _map.camera.center;
return (lat: centre.latitude, lon: centre.longitude);
}
return _position;
}
Future<void> _find(String text) async {
final places = widget.places;
if (places == null) {
return;
}
final asked = ++_asked;
final language = Localizations.localeOf(context).languageCode;
setState(() {
_searching = true;
_failed = false;
});
try {
final found = await places.search(
text.trim(),
near: _near,
language: language,
);
if (mounted && asked == _asked) {
setState(() {
_results = found;
_searching = false;
});
_show(found);
}
} on Object {
if (mounted && asked == _asked) {
setState(() {
_searching = false;
_failed = true;
});
}
}
}
/// Fits the map around [places].
void _show(List<TaskLocation> places) {
if (!_mapReady || places.isEmpty) {
return;
}
if (places case [final only]) {
_map.move(LatLng(only.lat, only.lon), 16);
return;
}
_map.fitCamera(
CameraFit.coordinates(
coordinates: [for (final at in places) LatLng(at.lat, at.lon)],
padding: const EdgeInsets.all(48),
maxZoom: 16,
),
);
}
void _pick(TaskLocation place) {
_searchFocus.unfocus();
setState(() {
_picked = place;
_byHand = false;
});
if (_mapReady) {
_map.move(
LatLng(place.lat, place.lon),
_map.camera.zoom < 15 ? 15 : _map.camera.zoom,
);
}
}
/// A pin placed by hand, named by what the search knows of the spot, if
/// anything; its name editable.
Future<void> _placed(LatLng point) async {
final l10n = AppLocalizations.of(context);
final language = Localizations.localeOf(context).languageCode;
_searchFocus.unfocus();
final pin = TaskLocation(
name: l10n.locationPin,
lat: point.latitude,
lon: point.longitude,
);
_name.text = pin.name;
setState(() {
_picked = pin;
_byHand = true;
});
TaskLocation? named;
try {
named = await widget.places?.at(
(lat: point.latitude, lon: point.longitude),
language: language,
);
} on Object {
// The pin stands, unnamed; the person can name it.
}
if (mounted && named != null && identical(_picked, pin)) {
final spot = TaskLocation(
name: named.name,
lat: pin.lat,
lon: pin.lon,
address: named.address,
);
_name.text = spot.name;
setState(() => _picked = spot);
}
}
/// The place as chosen, the person told first why a reminder needs the
/// position at all times, and asked (D32).
Future<void> _add() async {
final picked = _picked;
if (picked == null) {
return;
}
final named = _name.text.trim();
final place = TaskLocation(
name: _byHand && named.isNotEmpty ? named : picked.name,
lat: picked.lat,
lon: picked.lon,
address: picked.address,
osmType: picked.osmType,
osmId: picked.osmId,
trigger: _trigger,
);
if (_trigger != LocationTrigger.none) {
await _askAccess();
}
if (mounted) {
Navigator.of(context).pop(place);
}
}
Future<void> _askAccess() async {
await askLocationAccess(context, widget.access);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final search = widget.places != null;
return Scaffold(
appBar: AppBar(title: Text(l10n.locationAdd)),
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: search
? TextField(
key: locationSearchKey,
controller: _search,
focusNode: _searchFocus,
autofocus: widget.query == null,
textInputAction: TextInputAction.search,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: l10n.locationSearchHint,
prefixIcon: const Icon(LucideIcons.search),
),
onChanged: _typed,
onSubmitted: (text) {
_typing?.cancel();
unawaited(_find(text));
},
)
: Text(
l10n.locationSearchUnavailable,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
Expanded(flex: 3, child: _mapView(context)),
if (_list(context) case final list?) Expanded(flex: 2, child: list),
if (_picked case final picked? when !_searchFocus.hasFocus)
_chosen(context, picked),
],
),
),
);
}
Widget _mapView(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final tiles = widget.mapTiles;
final position = _position;
final picked = _picked;
return Stack(
fit: StackFit.expand,
children: [
FlutterMap(
key: locationMapKey,
mapController: _map,
options: MapOptions(
initialCenter: switch (position) {
(:final lat, :final lon) => LatLng(lat, lon),
null => _start,
},
initialZoom: position == null ? 5.5 : 14,
maxZoom: mapMaxZoom,
backgroundColor: scheme.surfaceContainer,
onMapReady: () {
_mapReady = true;
if (_results case final results?) {
_show(results);
} else if (_position case (:final lat, :final lon)) {
_map.move(LatLng(lat, lon), 14);
}
},
onTap: (_, point) => unawaited(_placed(point)),
),
children: [
?tiles?.call(),
MarkerLayer(
markers: [
if (position case (:final lat, :final lon))
Marker(
point: LatLng(lat, lon),
width: 16,
height: 16,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary,
shape: BoxShape.circle,
border: Border.all(color: scheme.surface, width: 2),
),
),
),
for (final place in _results ?? const <TaskLocation>[])
if (place.key != picked?.key) _pin(context, place),
if (picked != null) _pin(context, picked, chosen: true),
],
),
if (tiles != null)
const SimpleAttributionWidget(source: Text(mapAttribution)),
],
),
Positioned(
right: 8,
top: 8,
child: IconButton.filledTonal(
tooltip: l10n.locationMine,
icon: const Icon(LucideIcons.locateFixed),
onPressed: () => unawaited(_locateAsked()),
),
),
],
);
}
Marker _pin(
BuildContext context,
TaskLocation place, {
bool chosen = false,
}) {
final scheme = Theme.of(context).colorScheme;
return Marker(
point: LatLng(place.lat, place.lon),
width: 44,
height: 44,
alignment: Alignment.topCenter,
child: Semantics(
button: true,
label: place.name,
child: GestureDetector(
onTap: () => _pick(place),
child: Icon(
LucideIcons.mapPin,
size: chosen ? 40 : 30,
color: chosen ? scheme.primary : scheme.tertiary,
),
),
),
);
}
/// What the search found, else the recent places; nothing to show, none.
Widget? _list(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
final results = _results;
final places = results ?? widget.recents;
final message = switch ((_failed, results)) {
(true, _) => l10n.locationSearchFailed,
(_, []) when !_searching => l10n.locationNoResults,
_ => null,
};
if (message == null && places.isEmpty && !_searching) {
return null;
}
return ListView(
padding: EdgeInsets.zero,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
children: [
if (_searching) const LinearProgressIndicator(minHeight: 2),
if (message != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
if (results == null && places.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
l10n.locationRecent,
style: Theme.of(context).textTheme.labelLarge,
),
),
for (final place in places)
ListTile(
dense: true,
selected: place.key == _picked?.key,
leading: const Icon(LucideIcons.mapPin),
title: Text(place.name),
subtitle: place.address.isEmpty ? null : Text(place.address),
onTap: () => _pick(place),
),
],
);
}
/// The place chosen, what it reminds of, and adding it.
Widget _chosen(BuildContext context, TaskLocation picked) {
final l10n = AppLocalizations.of(context);
return Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if (_byHand)
TextField(
controller: _name,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.locationName),
)
else
Text(
picked.name,
style: Theme.of(context).textTheme.titleMedium,
),
if (picked.address.isNotEmpty) Text(picked.address),
if (widget.access.available)
SegmentedButton<LocationTrigger>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: LocationTrigger.none,
label: Text(l10n.locationRemindNone),
),
ButtonSegment(
value: LocationTrigger.arrive,
label: Text(l10n.locationRemindArrive),
),
ButtonSegment(
value: LocationTrigger.leave,
label: Text(l10n.locationRemindLeave),
),
],
selected: {_trigger},
onSelectionChanged: (chosen) =>
setState(() => _trigger = chosen.single),
),
FilledButton(
key: locationAddKey,
onPressed: () => unawaited(_add()),
child: Text(l10n.locationAdd),
),
],
),
),
);
}
}
/// Tells the person why a reminder at a place needs their position at all
/// times, then asks, as Android has it (D32); nothing where the device may
/// already, or can't.
Future<void> askLocationAccess(
BuildContext context,
LocationReminderAccess access,
) async {
if (!access.available || await access.allowed()) {
return;
}
if (!context.mounted) {
return;
}
final l10n = AppLocalizations.of(context);
final go = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.locationAccessTitle),
content: Text(l10n.locationAccessHint),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.locationAccessGo),
),
],
),
);
if (go ?? false) {
await access.turnOn();
}
}
/// A task's places in quick add: each with a way to remove it, and adding
/// more. The list as it then stands.
Future<List<TaskLocation>?> editLocations(
BuildContext context,
List<TaskLocation> locations,
) {
final l10n = AppLocalizations.of(context);
var kept = locations;
return showDialog<List<TaskLocation>>(
context: context,
builder: (dialog) => StatefulBuilder(
builder: (dialog, setState) => AlertDialog(
title: Text(l10n.locationWhere),
contentPadding: const EdgeInsets.only(top: 12, bottom: 8),
content: SizedBox(
width: 400,
child: ListView(
shrinkWrap: true,
children: [
for (final place in kept)
ListTile(
leading: const Icon(LucideIcons.mapPin),
title: Text(place.name),
subtitle: place.address.isEmpty ? null : Text(place.address),
trailing: IconButton(
tooltip: l10n.locationRemove,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(
() => kept = [
for (final other in kept)
if (other != place) other,
],
),
),
),
ListTile(
leading: const Icon(LucideIcons.plus),
title: Text(l10n.locationAdd),
onTap: () async {
final added = await showLocationPicker(context);
if (added != null) {
setState(() => kept = withLocation(kept, added));
}
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialog).pop(kept),
child: Text(MaterialLocalizations.of(dialog).okButtonLabel),
),
],
),
),
);
}

View file

@ -0,0 +1,56 @@
import 'package:material_ui/material_ui.dart';
import 'package:vector_map_tiles/vector_map_tiles.dart';
/// The closest the map zooms: the tile layer, drawing the tiles as pictures
/// (vector_map_tiles' raster mode), draws nothing beyond zoom 18 (as Train's).
const mapMaxZoom = 18.0;
/// OpenFreeMap's credit, shown on the map.
const mapAttribution = 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap';
/// Builds the map's tile layer: [vectorTiles] in the app, none in widget
/// tests, which reach no network.
typedef MapTiles = Widget Function();
/// OpenFreeMap's vector tiles (ADR 0033, D32), which take no key and make no
/// contract: its Liberty style, its Dark one in a dark theme; a small copy of
/// Train's `vectorTiles`, which is the same source without a setting. Nothing
/// while the style loads, nor where it can't be read.
Widget vectorTiles() => const _VectorTiles();
class const _VectorTiles() extends StatefulWidget {
@override
State<_VectorTiles> createState() => _VectorTilesState();
}
class _VectorTilesState() extends State<_VectorTiles> {
Future<Style>? _style;
bool? _dark;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final dark = Theme.of(context).brightness == Brightness.dark;
if (dark != _dark) {
_dark = dark;
_style = StyleReader(
uri:
'https://tiles.openfreemap.org/styles/'
'${dark ? 'dark' : 'liberty'}',
).read();
}
}
@override
Widget build(BuildContext context) => FutureBuilder(
future: _style,
builder: (context, snapshot) => switch (snapshot.data) {
final style? => VectorTileLayer(
theme: style.theme,
sprites: style.sprites,
tileProviders: style.providers,
),
null => const SizedBox.shrink(),
},
);
}

View file

@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/location_plan.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/model/reminder_plan.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
@ -18,7 +19,9 @@ import 'package:material_ui/material_ui.dart';
/// planned weeks move on; handed to the system only where the plan, or how
/// long a reminder is snoozed for (D4), changed. Where the plan holds a
/// reminder, the person is asked to let the app notify (D31,
/// `NotificationAccess`).
/// `NotificationAccess`). The places its tasks remind at go the same way to
/// `LocationReminderGateway` (D32), asking nothing: that is asked as a person
/// chooses such a reminder.
class const ReminderScheduler({required final Widget child, super.key})
extends StatefulWidget {
@override
@ -27,6 +30,7 @@ class const ReminderScheduler({required final Widget child, super.key})
class _ReminderSchedulerState() extends State<ReminderScheduler> {
List<PlannedReminder>? _planned;
List<LocationWatch>? _watched;
int? _snooze;
Locale? _locale;
Timer? _timer;
@ -47,6 +51,7 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
if (locale != _locale) {
_locale = locale;
_planned = null;
_watched = null;
_plan();
}
}
@ -56,9 +61,11 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
if (!state.loaded) {
return;
}
final plan = planReminders([
final tasks = [
for (final task in state.tasks) StoredTask(task.id, task.content),
], now: clock.now());
];
_watch(tasks);
final plan = planReminders(tasks, now: clock.now());
// Asked to notify wherever the reminders came from (D31): typed, added
// by the automatic reminder, chosen in a sheet or synced; and read
// again as the app comes back, from the system's settings perhaps.
@ -94,6 +101,30 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
);
}
/// Hands the places [tasks] remind at to the device, where they changed.
void _watch(List<StoredTask> tasks) {
final l10n = AppLocalizations.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final watches = [
for (final watch in planLocationWatches(tasks))
watch.saying(
watch.arrive
? l10n.locationArrived(watch.name)
: l10n.locationLeft(watch.name),
),
];
unawaited(todo.locationAccess.planned(any: watches.isNotEmpty));
if (!listEquals(watches, _watched)) {
_watched = watches;
unawaited(
todo.locationReminders.replace(
watches,
channel: l10n.remindersChannel,
),
);
}
}
@override
void dispose() {
_timer?.cancel();

View file

@ -9,6 +9,8 @@ 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/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.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/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.dart';
@ -40,9 +42,9 @@ import 'package:material_ui/material_ui.dart';
/// recurrences in the app's language and English, each highlighted and
/// shown on its button; a tap on one, or Backspace right after it, keeps
/// that one as text (D29), and a button's pick takes its token out of the
/// title. As `#` or `@` is typed, the projects
/// or labels it may mean are offered below the title, one tapped
/// completing it.
/// title. As `#`, `@` or `+` is typed, the projects, labels or recent places
/// it may mean are offered below the title, one tapped completing it; below
/// a `+`'s, the way into the location picker with what was typed (D32).
Future<void> showQuickAdd(
BuildContext context, {
String? projectId,
@ -131,6 +133,7 @@ class _QuickAddState() extends State<_QuickAdd> {
late String? _projectId = widget.projectId;
late String? _sectionId = widget.sectionId;
var _labelIds = <String>[];
var _locations = <TaskLocation>[];
Day? _deadline;
var _describing = false;
@ -176,6 +179,7 @@ class _QuickAddState() extends State<_QuickAdd> {
),
projects: _projects(l10n, state),
labels: _labels(state),
locations: _places(state),
ignored: _title.kept,
);
}
@ -196,8 +200,27 @@ class _QuickAddState() extends State<_QuickAdd> {
if (label.content case final content?) (id: label.id, name: content.name),
];
/// The `#` or `@` being typed just before the cursor: the symbol, where
/// it is, and what follows it; none where it stands inside a word.
/// The recent places by the names `+` takes (D32), each by its place.
static List<NamedId> _places(TodoState state) => [
for (final place in recentsOf(state)) (id: place.key, name: place.name),
];
/// The places of [parsed]'s `+` tokens beside those picked.
List<TaskLocation> _placesOf(ParsedTask parsed, TodoState state) {
final recent = {for (final place in recentsOf(state)) place.key: place};
return [
..._locations,
for (final id in parsed.locationIds)
if (recent[id] case final place?
when !_locations.any((other) => other.key == id))
place,
];
}
/// The `#`, `@` or `+` being typed just before the cursor: the symbol,
/// where it is, and what follows it; none where it stands inside a word,
/// or for a `+` that names a place already, or a sum's that a space
/// follows.
({String symbol, int start, String typed})? _typing() {
final selection = _title.selection;
if (!selection.isValid || !selection.isCollapsed) {
@ -207,11 +230,20 @@ class _QuickAddState() extends State<_QuickAdd> {
final at = selection.baseOffset;
for (var i = at - 1; i >= 0 && at - i <= 40; i--) {
final char = text[i];
if (char == '#' || char == '@') {
if (char == '#' || char == '@' || char == '+') {
if (i > 0 && text[i - 1].trim().isNotEmpty) {
return null;
}
return (symbol: char, start: i, typed: text.substring(i + 1, at));
final typed = text.substring(i + 1, at);
if (char == '+' &&
(typed.startsWith(' ') ||
_parse().tokens.any(
(token) =>
token.kind == QuickAddKind.location && token.start == i,
))) {
return null;
}
return (symbol: char, start: i, typed: typed);
}
}
return null;
@ -226,9 +258,11 @@ class _QuickAddState() extends State<_QuickAdd> {
return const [];
}
final typed = typing.typed.toLowerCase();
final named = typing.symbol == '#'
? _projects(l10n, state)
: _labels(state);
final named = switch (typing.symbol) {
'#' => _projects(l10n, state),
'@' => _labels(state),
_ => _places(state),
};
if (named.any((item) => item.name.toLowerCase() == typed)) {
return const [];
}
@ -263,6 +297,47 @@ class _QuickAddState() extends State<_QuickAdd> {
_focus.requestFocus();
}
/// Opens the location picker on what follows the `+` being typed, which
/// leaves the title as a place comes back.
Future<void> _searchPlace() async {
final typing = _typing();
final added = await showLocationPicker(context, query: typing?.typed);
if (!mounted) {
return;
}
if (added != null) {
if (typing != null) {
final end = typing.start + 1 + typing.typed.length;
final text = _title.text
.replaceRange(typing.start, end, '')
.trimRight();
_title.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
}
setState(() => _locations = withLocation(_locations, added));
}
_focus.requestFocus();
}
/// The + menu's Location, and the pin's tap: the picker, or with places
/// chosen already the list of them.
Future<void> _pickLocations(List<TaskLocation> shown) async {
if (shown.isEmpty) {
final added = await showLocationPicker(context);
if (mounted && added != null) {
setState(() => _locations = [added]);
}
return;
}
final kept = await editLocations(context, shown);
if (mounted && kept != null) {
_dropTokens(const {QuickAddKind.location});
setState(() => _locations = kept);
}
}
/// Takes the tokens of [kinds] out of the title, a chip having been
/// picked for them.
void _dropTokens(Set<QuickAddKind> kinds) {
@ -375,6 +450,7 @@ class _QuickAddState() extends State<_QuickAdd> {
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
reminders: parsed.reminders,
reminderTimes: parsed.reminderTimes,
locations: _placesOf(parsed, state),
),
);
} on KeysMissing {
@ -395,6 +471,7 @@ class _QuickAddState() extends State<_QuickAdd> {
setState(() {
_priority = Priority.none;
_labelIds = [];
_locations = [];
_deadline = null;
_title.kept = const [];
if (spot != null) {
@ -426,6 +503,7 @@ class _QuickAddState() extends State<_QuickAdd> {
final deadline = parsed.deadline ?? _deadline;
final labelIds = {..._labelIds, ...parsed.labelIds};
final labelCount = labelIds.length + parsed.newLabels.length;
final places = _placesOf(parsed, state);
final projectId = parsed.projectId ?? _projectId;
final project = switch (projectId) {
final id? => state.projectOf(id),
@ -433,6 +511,10 @@ class _QuickAddState() extends State<_QuickAdd> {
};
final typing = _title.text.trim().isNotEmpty;
final suggestions = _suggestions(l10n, state);
final placing = switch (_typing()) {
(symbol: '+', :final typed, start: _) => typed.trim(),
_ => null,
};
final scheme = theme.colorScheme;
const borderless = InputDecoration(
border: InputBorder.none,
@ -485,7 +567,7 @@ class _QuickAddState() extends State<_QuickAdd> {
onSubmitted: (_) => _add(),
),
),
if (suggestions.isNotEmpty)
if (suggestions.isNotEmpty || placing != null)
Material(
key: quickAddSuggestionsKey,
color: scheme.surfaceContainerHighest,
@ -499,12 +581,24 @@ class _QuickAddState() extends State<_QuickAdd> {
dense: true,
leading: Icon(switch (_typing()?.symbol) {
'@' => LucideIcons.tag,
'+' => LucideIcons.mapPin,
_ when item.id == state.inboxId => LucideIcons.inbox,
_ => LucideIcons.hash,
}),
title: Text(item.name),
onTap: () => _complete(item),
),
if (placing != null)
ListTile(
dense: true,
leading: const Icon(LucideIcons.search),
title: Text(
placing.isEmpty
? l10n.locationSearchAny
: l10n.locationSearchFor(placing),
),
onTap: () => unawaited(_searchPlace()),
),
],
),
),
@ -528,7 +622,7 @@ class _QuickAddState() extends State<_QuickAdd> {
child: Row(
spacing: 8,
children: [
_more(context, deadline: deadline),
_more(context, deadline: deadline, places: places),
QuickAddButton(
icon: date?.repeat == null
? LucideIcons.calendar
@ -594,6 +688,13 @@ class _QuickAddState() extends State<_QuickAdd> {
}
},
),
if (places.isNotEmpty)
QuickAddButton(
icon: LucideIcons.mapPin,
tooltip: l10n.locationTitle,
label: locationsText(places),
onPressed: () => unawaited(_pickLocations(places)),
),
if (widget.parentId == null)
QuickAddButton(
icon: project?.inbox ?? true
@ -696,8 +797,12 @@ class _QuickAddState() extends State<_QuickAdd> {
}
/// The + holding what quick add doesn't show at first: the description,
/// the deadline.
Widget _more(BuildContext context, {required Day? deadline}) {
/// the deadline, a location.
Widget _more(
BuildContext context, {
required Day? deadline,
required List<TaskLocation> places,
}) {
final l10n = AppLocalizations.of(context);
return PopupMenuButton<VoidCallback>(
tooltip: l10n.quickAddMore,
@ -713,6 +818,10 @@ class _QuickAddState() extends State<_QuickAdd> {
value: () => _pickDeadline(null),
child: MenuEntry(LucideIcons.target, l10n.deadlineTitle),
),
PopupMenuItem(
value: () => unawaited(_pickLocations(places)),
child: MenuEntry(LucideIcons.mapPin, l10n.locationTitle),
),
],
icon: const Icon(LucideIcons.plus),
style: IconButton.styleFrom(

View file

@ -9,6 +9,8 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.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/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
@ -298,6 +300,95 @@ class _TaskSheetState() extends State<_TaskSheet> {
TaskContent? get _content => _todo.state.taskOf(widget.id)?.content;
/// Where the task happens (D32): each place with its own ⋮, then a way to
/// add one; several are alternatives.
Widget _where(BuildContext context, TaskContent content) {
final l10n = AppLocalizations.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final several = content.locations.length > 1;
Future<void> remind(TaskLocation at, LocationTrigger trigger) async {
await _change(
(c) => c.copyWith(
locations: [
for (final other in c.locations)
if (other.key == at.key) other.reminding(trigger) else other,
],
),
);
if (trigger != LocationTrigger.none && context.mounted) {
await askLocationAccess(context, todo.locationAccess);
}
}
String remindText(LocationTrigger trigger) => switch (trigger) {
LocationTrigger.none => l10n.locationRemindNone,
LocationTrigger.arrive => l10n.locationRemindArrive,
LocationTrigger.leave => l10n.locationRemindLeave,
};
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final at in content.locations)
ListTile(
leading: const Icon(LucideIcons.mapPin),
title: Text(at.name),
subtitle: Text(
[
if (at.address.isNotEmpty) at.address,
remindText(at.trigger),
].join(' · '),
),
trailing: PopupMenuButton<Future<void> Function()>(
tooltip: l10n.locationTitle,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) => unawaited(action()),
itemBuilder: (context) => [
if (todo.locationAccess.available)
for (final trigger in LocationTrigger.values)
if (trigger != at.trigger)
PopupMenuItem(
value: () => remind(at, trigger),
child: MenuEntry(
switch (trigger) {
LocationTrigger.none => LucideIcons.bellOff,
_ => LucideIcons.bell,
},
remindText(trigger),
),
),
PopupMenuItem(
value: () => _change(
(c) => c.copyWith(
locations: [
for (final other in c.locations)
if (other.key != at.key) other,
],
),
),
child: MenuEntry(LucideIcons.trash2, l10n.locationRemove),
),
],
),
),
ListTile(
leading: Icon(
content.locations.isEmpty ? LucideIcons.mapPin : LucideIcons.plus,
),
title: Text(l10n.locationAdd),
subtitle: several ? Text(l10n.locationAnyOf) : null,
onTap: () async {
final added = await showLocationPicker(context);
if (added != null) {
await _change(
(c) => c.copyWith(locations: withLocation(c.locations, added)),
);
}
},
),
],
);
}
Future<void> _change(TaskContent Function(TaskContent content) change) async {
final content = _content;
if (content == null) {
@ -696,6 +787,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
onTap: () => _pickReminders(context, content),
),
),
_Box(child: _where(context, content)),
_Box(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,

View file

@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
@ -98,6 +99,8 @@ class const TaskTile({
),
if (content.deadline case final deadline?)
meta(LucideIcons.target, dayText(context, deadline, today), muted),
if (locationsText(content.locations) case final place?)
meta(LucideIcons.mapPin, place, muted),
if (subTasks.isNotEmpty)
meta(
LucideIcons.listTree,

View file

@ -97,6 +97,7 @@ class const ReminderSettingsCard({super.key}) extends StatelessWidget {
title: l10n.remindersTitle,
children: [
const _NotificationsOff(),
const _LocationOff(),
ListTile(
leading: const Icon(LucideIcons.alarmClockPlus),
title: ExplainedTitle(
@ -179,6 +180,34 @@ class const _NotificationsOff() extends StatelessWidget {
}
}
/// Places planned that the device may not watch (ADR 0033, D32), as
/// [_NotificationsOff].
class const _LocationOff() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final access = PersonScope.of<TodoPerson>(context).locationAccess;
return ListenableBuilder(
listenable: access,
builder: (context, _) {
if (!access.off) {
return const SizedBox.shrink();
}
final colors = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(LucideIcons.mapPinOff, color: colors.error),
title: Text(l10n.locationOffTitle),
subtitle: Text(l10n.locationOffHint),
trailing: FilledButton.tonal(
onPressed: access.turnOn,
child: Text(l10n.notificationsTurnOn),
),
);
},
);
}
}
/// A group of Settings under its [title].
class const _Card({
required final String title,

View file

@ -21,6 +21,9 @@ dependencies:
# Tasks' reminders, scheduled with the system on the device (ADR 0033, D5):
# the instance reads no task, so it can't remind of one.
flutter_local_notifications: ^22.3.3
# A task's location picked on a map (ADR 0033, D32), as Train's maps: the
# map widget, OpenFreeMap's vector tiles and their coordinates.
flutter_map: ^8.3.2
# The tabs, each with its own stack of screens, and Settings over them
# (ADR 0033, D1), as Learn's and Train's.
go_router: ^18.0.2
@ -45,6 +48,7 @@ dependencies:
path: ../../../packages/halkyon_ui
# gen-l10n's output imports it.
intl: ^0.20.3
latlong2: ^0.10.1
lucide_icons_flutter: ^3.1.20
material_ui: ^1.5.0
# This build's version and number, for Settings' foot and what's new.
@ -57,6 +61,8 @@ dependencies:
# The instant a reminder is scheduled at, as flutter_local_notifications
# takes it (in UTC).
timezone: ^0.11.2
# Pinned as Train's: the 9 line is beta, the only one for flutter_map 8.
vector_map_tiles: 9.0.0-beta.13
dev_dependencies:
flutter_driver:

View file

@ -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);

View file

@ -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);
});
}

Some files were not shown because too many files have changed in this diff Show more