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",
|
"planGroupShow": "{group} einblenden",
|
||||||
"planAllProjects": "Alle Projekte",
|
"planAllProjects": "Alle Projekte",
|
||||||
"planProjectsCount": "{count, plural, =1{1 Projekt} other{{count} 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": "Filter by project",
|
||||||
"@planFilterTitle": {
|
"@planFilterTitle": {
|
||||||
"description": "Today's Plan's project filter (ADR 0033, D30): its search field's hint and its name."
|
"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:
|
/// In en, this message translates to:
|
||||||
/// **'Filter by project'**
|
/// **'Filter by project'**
|
||||||
String get planFilterTitle;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1082,4 +1082,89 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get planFilterTitle => 'Nach Projekt filtern';
|
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
|
@override
|
||||||
String get planFilterTitle => 'Filter by project';
|
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`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.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/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/label_repository.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.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/project_repository.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.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';
|
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
|
/// 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.
|
/// (D31): the device's, where it is given; else one of their own.
|
||||||
final NotificationAccess? _notifications,
|
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,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) 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).
|
/// Whether the device may show their reminders (ADR 0033, D31).
|
||||||
late final NotificationAccess notifications =
|
late final NotificationAccess notifications =
|
||||||
_notifications ?? NotificationAccess(reminders);
|
_notifications ?? NotificationAccess(reminders);
|
||||||
|
|
@ -99,6 +116,7 @@ class TodoPerson(
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await reminders.replace(const [], channel: '');
|
await reminders.replace(const [], channel: '');
|
||||||
|
await locationReminders.replace(const [], channel: '');
|
||||||
await _onDispose?.call();
|
await _onDispose?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +184,15 @@ class TodoDependencies({
|
||||||
/// This build's version and number, which Settings shows at its foot so
|
/// 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.
|
/// that a report can name the release; none in tests that don't say.
|
||||||
final ({String version, int? build}) appVersion = (version: '', build: null),
|
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,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements AppDependencies<TodoPerson> {
|
}) implements AppDependencies<TodoPerson> {
|
||||||
/// The dependencies above [context] ([AppScope]).
|
/// The dependencies above [context] ([AppScope]).
|
||||||
|
|
@ -263,6 +290,8 @@ class TodoDependencies({
|
||||||
),
|
),
|
||||||
speech: DeviceSpeechInput(),
|
speech: DeviceSpeechInput(),
|
||||||
appVersion: (version: device.appVersion, build: device.appBuild),
|
appVersion: (version: device.appVersion, build: device.appBuild),
|
||||||
|
places: _publicPhoton ? PublicPhotonGateway() : null,
|
||||||
|
mapTiles: vectorTiles,
|
||||||
sourceCodeUrl: () async =>
|
sourceCodeUrl: () async =>
|
||||||
(await client.modules.base.instance.basics()).sourceCodeUrl,
|
(await client.modules.base.instance.basics()).sourceCodeUrl,
|
||||||
openPerson: (person) async {
|
openPerson: (person) async {
|
||||||
|
|
@ -284,6 +313,8 @@ class TodoDependencies({
|
||||||
accountKeys: () => accountKeys.read(person.authUserId),
|
accountKeys: () => accountKeys.read(person.authUserId),
|
||||||
reminders: device.reminders,
|
reminders: device.reminders,
|
||||||
notifications: device.notifications,
|
notifications: device.notifications,
|
||||||
|
locationReminders: device.locationReminders,
|
||||||
|
locationAccess: device.locationAccess,
|
||||||
onClosed: closed.complete,
|
onClosed: closed.complete,
|
||||||
);
|
);
|
||||||
} on Object {
|
} on Object {
|
||||||
|
|
@ -322,6 +353,8 @@ Future<TodoPerson> _openPersonData(
|
||||||
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
||||||
required ReminderGateway reminders,
|
required ReminderGateway reminders,
|
||||||
required NotificationAccess notifications,
|
required NotificationAccess notifications,
|
||||||
|
required LocationReminderGateway locationReminders,
|
||||||
|
required LocationReminderAccess locationAccess,
|
||||||
required void Function() onClosed,
|
required void Function() onClosed,
|
||||||
}) async {
|
}) async {
|
||||||
final db = await client.createSyncSession(
|
final db = await client.createSyncSession(
|
||||||
|
|
@ -380,6 +413,8 @@ Future<TodoPerson> _openPersonData(
|
||||||
settings: settings,
|
settings: settings,
|
||||||
reminders: reminders,
|
reminders: reminders,
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
|
locationReminders: locationReminders,
|
||||||
|
locationAccess: locationAccess,
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
try {
|
try {
|
||||||
await projects.dispose();
|
await projects.dispose();
|
||||||
|
|
@ -416,11 +451,19 @@ class TodoDevice({
|
||||||
|
|
||||||
/// The system's notifications, where reminders go (ADR 0033, D5).
|
/// The system's notifications, where reminders go (ADR 0033, D5).
|
||||||
final ReminderGateway reminders = const NoReminders(),
|
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,
|
/// Whether the device may show reminders, asked once an app start,
|
||||||
/// whoever is signed in (D31).
|
/// whoever is signed in (D31).
|
||||||
late final NotificationAccess notifications = NotificationAccess(reminders);
|
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.
|
/// Makes them, [builtIn] being the instance the build names.
|
||||||
static Future<TodoDevice> create({required Instance builtIn}) async {
|
static Future<TodoDevice> create({required Instance builtIn}) async {
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
|
|
@ -431,10 +474,17 @@ class TodoDevice({
|
||||||
appBuild: int.tryParse(package.buildNumber),
|
appBuild: int.tryParse(package.buildNumber),
|
||||||
appVersion: package.version,
|
appVersion: package.version,
|
||||||
reminders: await _reminders(),
|
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
|
/// The system's notifications, or none where they don't start (a desktop
|
||||||
/// without a notification service, say): the app works on without
|
/// without a notification service, say): the app works on without
|
||||||
/// reminders.
|
/// 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_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/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/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.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
|
/// 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
|
/// long a reminder is snoozed for (D4), changed. Where the plan holds a
|
||||||
/// reminder, the person is asked to let the app notify (D31,
|
/// 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})
|
class const ReminderScheduler({required final Widget child, super.key})
|
||||||
extends StatefulWidget {
|
extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -27,6 +30,7 @@ class const ReminderScheduler({required final Widget child, super.key})
|
||||||
|
|
||||||
class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
||||||
List<PlannedReminder>? _planned;
|
List<PlannedReminder>? _planned;
|
||||||
|
List<LocationWatch>? _watched;
|
||||||
int? _snooze;
|
int? _snooze;
|
||||||
Locale? _locale;
|
Locale? _locale;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
@ -47,6 +51,7 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
||||||
if (locale != _locale) {
|
if (locale != _locale) {
|
||||||
_locale = locale;
|
_locale = locale;
|
||||||
_planned = null;
|
_planned = null;
|
||||||
|
_watched = null;
|
||||||
_plan();
|
_plan();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,9 +61,11 @@ class _ReminderSchedulerState() extends State<ReminderScheduler> {
|
||||||
if (!state.loaded) {
|
if (!state.loaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final plan = planReminders([
|
final tasks = [
|
||||||
for (final task in state.tasks) StoredTask(task.id, task.content),
|
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
|
// Asked to notify wherever the reminders came from (D31): typed, added
|
||||||
// by the automatic reminder, chosen in a sheet or synced; and read
|
// by the automatic reminder, chosen in a sheet or synced; and read
|
||||||
// again as the app comes back, from the system's settings perhaps.
|
// 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_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/privacy/todo_keys.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.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/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/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_parser.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.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
|
/// 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
|
/// 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
|
/// that one as text (D29), and a button's pick takes its token out of the
|
||||||
/// title. As `#` or `@` is typed, the projects
|
/// title. As `#`, `@` or `+` is typed, the projects, labels or recent places
|
||||||
/// or labels it may mean are offered below the title, one tapped
|
/// it may mean are offered below the title, one tapped completing it; below
|
||||||
/// completing it.
|
/// a `+`'s, the way into the location picker with what was typed (D32).
|
||||||
Future<void> showQuickAdd(
|
Future<void> showQuickAdd(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
String? projectId,
|
String? projectId,
|
||||||
|
|
@ -131,6 +133,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
late String? _projectId = widget.projectId;
|
late String? _projectId = widget.projectId;
|
||||||
late String? _sectionId = widget.sectionId;
|
late String? _sectionId = widget.sectionId;
|
||||||
var _labelIds = <String>[];
|
var _labelIds = <String>[];
|
||||||
|
var _locations = <TaskLocation>[];
|
||||||
Day? _deadline;
|
Day? _deadline;
|
||||||
var _describing = false;
|
var _describing = false;
|
||||||
|
|
||||||
|
|
@ -176,6 +179,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
),
|
),
|
||||||
projects: _projects(l10n, state),
|
projects: _projects(l10n, state),
|
||||||
labels: _labels(state),
|
labels: _labels(state),
|
||||||
|
locations: _places(state),
|
||||||
ignored: _title.kept,
|
ignored: _title.kept,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -196,8 +200,27 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
if (label.content case final content?) (id: label.id, name: content.name),
|
if (label.content case final content?) (id: label.id, name: content.name),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The `#` or `@` being typed just before the cursor: the symbol, where
|
/// The recent places by the names `+` takes (D32), each by its place.
|
||||||
/// it is, and what follows it; none where it stands inside a word.
|
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() {
|
({String symbol, int start, String typed})? _typing() {
|
||||||
final selection = _title.selection;
|
final selection = _title.selection;
|
||||||
if (!selection.isValid || !selection.isCollapsed) {
|
if (!selection.isValid || !selection.isCollapsed) {
|
||||||
|
|
@ -207,11 +230,20 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
final at = selection.baseOffset;
|
final at = selection.baseOffset;
|
||||||
for (var i = at - 1; i >= 0 && at - i <= 40; i--) {
|
for (var i = at - 1; i >= 0 && at - i <= 40; i--) {
|
||||||
final char = text[i];
|
final char = text[i];
|
||||||
if (char == '#' || char == '@') {
|
if (char == '#' || char == '@' || char == '+') {
|
||||||
if (i > 0 && text[i - 1].trim().isNotEmpty) {
|
if (i > 0 && text[i - 1].trim().isNotEmpty) {
|
||||||
return null;
|
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;
|
return null;
|
||||||
|
|
@ -226,9 +258,11 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
final typed = typing.typed.toLowerCase();
|
final typed = typing.typed.toLowerCase();
|
||||||
final named = typing.symbol == '#'
|
final named = switch (typing.symbol) {
|
||||||
? _projects(l10n, state)
|
'#' => _projects(l10n, state),
|
||||||
: _labels(state);
|
'@' => _labels(state),
|
||||||
|
_ => _places(state),
|
||||||
|
};
|
||||||
if (named.any((item) => item.name.toLowerCase() == typed)) {
|
if (named.any((item) => item.name.toLowerCase() == typed)) {
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +297,47 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
_focus.requestFocus();
|
_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
|
/// Takes the tokens of [kinds] out of the title, a chip having been
|
||||||
/// picked for them.
|
/// picked for them.
|
||||||
void _dropTokens(Set<QuickAddKind> kinds) {
|
void _dropTokens(Set<QuickAddKind> kinds) {
|
||||||
|
|
@ -375,6 +450,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
|
labelIds: {..._labelIds, ...parsed.labelIds, ...made}.toList(),
|
||||||
reminders: parsed.reminders,
|
reminders: parsed.reminders,
|
||||||
reminderTimes: parsed.reminderTimes,
|
reminderTimes: parsed.reminderTimes,
|
||||||
|
locations: _placesOf(parsed, state),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on KeysMissing {
|
} on KeysMissing {
|
||||||
|
|
@ -395,6 +471,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_priority = Priority.none;
|
_priority = Priority.none;
|
||||||
_labelIds = [];
|
_labelIds = [];
|
||||||
|
_locations = [];
|
||||||
_deadline = null;
|
_deadline = null;
|
||||||
_title.kept = const [];
|
_title.kept = const [];
|
||||||
if (spot != null) {
|
if (spot != null) {
|
||||||
|
|
@ -426,6 +503,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
final deadline = parsed.deadline ?? _deadline;
|
final deadline = parsed.deadline ?? _deadline;
|
||||||
final labelIds = {..._labelIds, ...parsed.labelIds};
|
final labelIds = {..._labelIds, ...parsed.labelIds};
|
||||||
final labelCount = labelIds.length + parsed.newLabels.length;
|
final labelCount = labelIds.length + parsed.newLabels.length;
|
||||||
|
final places = _placesOf(parsed, state);
|
||||||
final projectId = parsed.projectId ?? _projectId;
|
final projectId = parsed.projectId ?? _projectId;
|
||||||
final project = switch (projectId) {
|
final project = switch (projectId) {
|
||||||
final id? => state.projectOf(id),
|
final id? => state.projectOf(id),
|
||||||
|
|
@ -433,6 +511,10 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
};
|
};
|
||||||
final typing = _title.text.trim().isNotEmpty;
|
final typing = _title.text.trim().isNotEmpty;
|
||||||
final suggestions = _suggestions(l10n, state);
|
final suggestions = _suggestions(l10n, state);
|
||||||
|
final placing = switch (_typing()) {
|
||||||
|
(symbol: '+', :final typed, start: _) => typed.trim(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
final scheme = theme.colorScheme;
|
final scheme = theme.colorScheme;
|
||||||
const borderless = InputDecoration(
|
const borderless = InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
|
|
@ -485,7 +567,7 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
onSubmitted: (_) => _add(),
|
onSubmitted: (_) => _add(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (suggestions.isNotEmpty)
|
if (suggestions.isNotEmpty || placing != null)
|
||||||
Material(
|
Material(
|
||||||
key: quickAddSuggestionsKey,
|
key: quickAddSuggestionsKey,
|
||||||
color: scheme.surfaceContainerHighest,
|
color: scheme.surfaceContainerHighest,
|
||||||
|
|
@ -499,12 +581,24 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
dense: true,
|
dense: true,
|
||||||
leading: Icon(switch (_typing()?.symbol) {
|
leading: Icon(switch (_typing()?.symbol) {
|
||||||
'@' => LucideIcons.tag,
|
'@' => LucideIcons.tag,
|
||||||
|
'+' => LucideIcons.mapPin,
|
||||||
_ when item.id == state.inboxId => LucideIcons.inbox,
|
_ when item.id == state.inboxId => LucideIcons.inbox,
|
||||||
_ => LucideIcons.hash,
|
_ => LucideIcons.hash,
|
||||||
}),
|
}),
|
||||||
title: Text(item.name),
|
title: Text(item.name),
|
||||||
onTap: () => _complete(item),
|
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(
|
child: Row(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_more(context, deadline: deadline),
|
_more(context, deadline: deadline, places: places),
|
||||||
QuickAddButton(
|
QuickAddButton(
|
||||||
icon: date?.repeat == null
|
icon: date?.repeat == null
|
||||||
? LucideIcons.calendar
|
? 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)
|
if (widget.parentId == null)
|
||||||
QuickAddButton(
|
QuickAddButton(
|
||||||
icon: project?.inbox ?? true
|
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 + holding what quick add doesn't show at first: the description,
|
||||||
/// the deadline.
|
/// the deadline, a location.
|
||||||
Widget _more(BuildContext context, {required Day? deadline}) {
|
Widget _more(
|
||||||
|
BuildContext context, {
|
||||||
|
required Day? deadline,
|
||||||
|
required List<TaskLocation> places,
|
||||||
|
}) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return PopupMenuButton<VoidCallback>(
|
return PopupMenuButton<VoidCallback>(
|
||||||
tooltip: l10n.quickAddMore,
|
tooltip: l10n.quickAddMore,
|
||||||
|
|
@ -713,6 +818,10 @@ class _QuickAddState() extends State<_QuickAdd> {
|
||||||
value: () => _pickDeadline(null),
|
value: () => _pickDeadline(null),
|
||||||
child: MenuEntry(LucideIcons.target, l10n.deadlineTitle),
|
child: MenuEntry(LucideIcons.target, l10n.deadlineTitle),
|
||||||
),
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: () => unawaited(_pickLocations(places)),
|
||||||
|
child: MenuEntry(LucideIcons.mapPin, l10n.locationTitle),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
icon: const Icon(LucideIcons.plus),
|
icon: const Icon(LucideIcons.plus),
|
||||||
style: IconButton.styleFrom(
|
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/app/todo_paths.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.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/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/project/model/stored_project.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.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';
|
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;
|
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 {
|
Future<void> _change(TaskContent Function(TaskContent content) change) async {
|
||||||
final content = _content;
|
final content = _content;
|
||||||
if (content == null) {
|
if (content == null) {
|
||||||
|
|
@ -696,6 +787,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
||||||
onTap: () => _pickReminders(context, content),
|
onTap: () => _pickReminders(context, content),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_Box(child: _where(context, content)),
|
||||||
_Box(
|
_Box(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.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/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.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.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?)
|
if (content.deadline case final deadline?)
|
||||||
meta(LucideIcons.target, dayText(context, deadline, today), muted),
|
meta(LucideIcons.target, dayText(context, deadline, today), muted),
|
||||||
|
if (locationsText(content.locations) case final place?)
|
||||||
|
meta(LucideIcons.mapPin, place, muted),
|
||||||
if (subTasks.isNotEmpty)
|
if (subTasks.isNotEmpty)
|
||||||
meta(
|
meta(
|
||||||
LucideIcons.listTree,
|
LucideIcons.listTree,
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ class const ReminderSettingsCard({super.key}) extends StatelessWidget {
|
||||||
title: l10n.remindersTitle,
|
title: l10n.remindersTitle,
|
||||||
children: [
|
children: [
|
||||||
const _NotificationsOff(),
|
const _NotificationsOff(),
|
||||||
|
const _LocationOff(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(LucideIcons.alarmClockPlus),
|
leading: const Icon(LucideIcons.alarmClockPlus),
|
||||||
title: ExplainedTitle(
|
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].
|
/// A group of Settings under its [title].
|
||||||
class const _Card({
|
class const _Card({
|
||||||
required final String title,
|
required final String title,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ dependencies:
|
||||||
# Tasks' reminders, scheduled with the system on the device (ADR 0033, D5):
|
# Tasks' reminders, scheduled with the system on the device (ADR 0033, D5):
|
||||||
# the instance reads no task, so it can't remind of one.
|
# the instance reads no task, so it can't remind of one.
|
||||||
flutter_local_notifications: ^22.3.3
|
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
|
# The tabs, each with its own stack of screens, and Settings over them
|
||||||
# (ADR 0033, D1), as Learn's and Train's.
|
# (ADR 0033, D1), as Learn's and Train's.
|
||||||
go_router: ^18.0.2
|
go_router: ^18.0.2
|
||||||
|
|
@ -45,6 +48,7 @@ dependencies:
|
||||||
path: ../../../packages/halkyon_ui
|
path: ../../../packages/halkyon_ui
|
||||||
# gen-l10n's output imports it.
|
# gen-l10n's output imports it.
|
||||||
intl: ^0.20.3
|
intl: ^0.20.3
|
||||||
|
latlong2: ^0.10.1
|
||||||
lucide_icons_flutter: ^3.1.20
|
lucide_icons_flutter: ^3.1.20
|
||||||
material_ui: ^1.5.0
|
material_ui: ^1.5.0
|
||||||
# This build's version and number, for Settings' foot and what's new.
|
# 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
|
# The instant a reminder is scheduled at, as flutter_local_notifications
|
||||||
# takes it (in UTC).
|
# takes it (in UTC).
|
||||||
timezone: ^0.11.2
|
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:
|
dev_dependencies:
|
||||||
flutter_driver:
|
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/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/label_repository.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.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/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.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.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 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
import '../support/memory_content_key_gateway.dart';
|
import '../support/memory_content_key_gateway.dart';
|
||||||
|
import '../support/memory_location_gateway.dart';
|
||||||
import '../support/memory_reminder_gateway.dart';
|
import '../support/memory_reminder_gateway.dart';
|
||||||
import '../support/memory_sealed_row_gateway.dart';
|
import '../support/memory_sealed_row_gateway.dart';
|
||||||
|
|
||||||
|
|
@ -59,6 +63,7 @@ void main() {
|
||||||
late MemorySealedRowGateway labelRows;
|
late MemorySealedRowGateway labelRows;
|
||||||
late MemorySealedRowGateway settingsRows;
|
late MemorySealedRowGateway settingsRows;
|
||||||
late MemoryReminderGateway reminders;
|
late MemoryReminderGateway reminders;
|
||||||
|
late MemoryLocationGateway locationGateway;
|
||||||
late FakeSpeechInput speech;
|
late FakeSpeechInput speech;
|
||||||
|
|
||||||
/// How many syncs ran.
|
/// How many syncs ran.
|
||||||
|
|
@ -88,6 +93,7 @@ void main() {
|
||||||
labelRows = MemorySealedRowGateway();
|
labelRows = MemorySealedRowGateway();
|
||||||
settingsRows = MemorySealedRowGateway();
|
settingsRows = MemorySealedRowGateway();
|
||||||
reminders = MemoryReminderGateway();
|
reminders = MemoryReminderGateway();
|
||||||
|
locationGateway = MemoryLocationGateway();
|
||||||
speech = FakeSpeechInput();
|
speech = FakeSpeechInput();
|
||||||
store = MemoryInstanceStore();
|
store = MemoryInstanceStore();
|
||||||
looks = MemoryLookStore();
|
looks = MemoryLookStore();
|
||||||
|
|
@ -189,6 +195,7 @@ void main() {
|
||||||
labels: LabelRepository(labelRows, keys),
|
labels: LabelRepository(labelRows, keys),
|
||||||
settings: settings,
|
settings: settings,
|
||||||
reminders: reminders,
|
reminders: reminders,
|
||||||
|
locationReminders: locationGateway,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -1895,6 +1902,139 @@ void main() {
|
||||||
expect(content.labelIds, [errand]);
|
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 '
|
testWidgets('nothing is suggested for a # inside a word, nor where no '
|
||||||
'name matches', (tester) async {
|
'name matches', (tester) async {
|
||||||
await start(tester, usedBefore: true);
|
await start(tester, usedBefore: true);
|
||||||
|
|
@ -2269,6 +2409,88 @@ void main() {
|
||||||
expect(stored.content?.completed, isFalse);
|
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 "
|
testWidgets("a task's reminder is scheduled on the device before its "
|
||||||
'time, and goes once it is done', (tester) async {
|
'time, and goes once it is done', (tester) async {
|
||||||
final tomorrow = Day.of(DateTime.now()).plus(1);
|
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