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
24 changed files with 2540 additions and 18 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'};
|
||||
}
|
||||
|
|
@ -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('/');
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue