feat(train): search for places in the route planner, through Photon (ADR 0030, D50)
A `search` endpoint asks the instance's Photon (compose.search.yaml, Europe's database from GraphHopper, renewed monthly) for people signed in, limited and unlogged. The planner gets a field over its map: the places found nearest the map's centre first, in the app's language; one chosen is the next waypoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
190c3872f4
commit
edd2df79bd
41 changed files with 2184 additions and 7 deletions
|
|
@ -8,9 +8,11 @@
|
|||
# Dictionary files: tens of megabytes, mounted on an instance, never built in
|
||||
apps/learn/halkyon_learn_server/dictionaries/
|
||||
deploy/dictionaries/
|
||||
# BRouter's segments and the map's tiles: gigabytes, mounted on an instance, never built in
|
||||
# BRouter's segments, the map's tiles, Photon's database: gigabytes, mounted on an
|
||||
# instance, never built in
|
||||
deploy/brouter/segments/
|
||||
deploy/maps/tiles/
|
||||
deploy/photon/data/
|
||||
# Published studysets' repositories of a development server
|
||||
apps/learn/halkyon_learn_server/repositories/
|
||||
apps/learn/halkyon_learn_server/.serverpod/
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -29,10 +29,12 @@ deploy/assistants.env
|
|||
# A sidecar's files on the server, such as Jellyfin's configuration and media
|
||||
deploy/jellyfin/
|
||||
deploy/languagetool/
|
||||
# BRouter's segments and the map's tiles (Train's ADR 0030, D44, D50), downloaded on
|
||||
# BRouter's segments, the map's tiles and Photon's database (Train's ADR 0030, D44,
|
||||
# D50), downloaded on
|
||||
# an instance
|
||||
deploy/brouter/segments/
|
||||
deploy/maps/tiles/
|
||||
deploy/photon/data/
|
||||
# Dictionary files an instance loads: made by halkyon_learn_curator, not kept in git.
|
||||
deploy/dictionaries/*.jsonl
|
||||
deploy/dictionaries/*.jsonl.gz
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -24,6 +24,8 @@ import 'package:halkyon_train_client/src/protocol/routing/route_point.dart'
|
|||
as _iq1flhug;
|
||||
import 'package:halkyon_train_client/src/protocol/routing/route_profile.dart'
|
||||
as _isvxstnj;
|
||||
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
||||
as _inm997sc;
|
||||
import 'package:http/http.dart' as _i85jenna;
|
||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||
as _iacc;
|
||||
|
|
@ -139,6 +141,44 @@ class EndpointRouting extends _isc.EndpointRef {
|
|||
);
|
||||
}
|
||||
|
||||
/// Places found by the instance's Photon (ADR 0030, D50), for those signed
|
||||
/// in: Photon has no sign-in of its own, so the server alone reaches it.
|
||||
/// Neither the query nor the places are kept or logged. Refusals are
|
||||
/// `SearchRefused`.
|
||||
/// {@category Endpoint}
|
||||
class EndpointSearch extends _isc.EndpointRef {
|
||||
EndpointSearch(_isc.EndpointCaller caller) : super(caller);
|
||||
|
||||
@override
|
||||
String get name => 'search';
|
||||
|
||||
/// Whether the instance searches for places.
|
||||
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
|
||||
'search',
|
||||
'offered',
|
||||
{},
|
||||
);
|
||||
|
||||
/// The places [query] finds, nearest [near] first (the map's centre),
|
||||
/// named in [language] (`en`, `de`; else each its own name). Refused where
|
||||
/// the instance has no Photon or it fails, for a query of fewer than
|
||||
/// `PlaceSearch.minQuery` or more than `PlaceSearch.maxQuery` characters,
|
||||
/// and beyond `PlaceSearch.perMinute` searches a minute.
|
||||
_ida.Future<List<_inm997sc.Place>> places(
|
||||
String query, {
|
||||
_iq1flhug.RoutePoint? near,
|
||||
String? language,
|
||||
}) => caller.callServerEndpoint<List<_inm997sc.Place>>(
|
||||
'search',
|
||||
'places',
|
||||
{
|
||||
'query': query,
|
||||
'near': near,
|
||||
'language': language,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class Modules {
|
||||
Modules(Client client) {
|
||||
base = _ihk4p5t7.Caller(client);
|
||||
|
|
@ -189,6 +229,7 @@ class Client extends _isc.ServerpodClientShared {
|
|||
firmware = EndpointFirmware(this);
|
||||
map = EndpointMap(this);
|
||||
routing = EndpointRouting(this);
|
||||
search = EndpointSearch(this);
|
||||
modules = Modules(this);
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +239,8 @@ class Client extends _isc.ServerpodClientShared {
|
|||
|
||||
late final EndpointRouting routing;
|
||||
|
||||
late final EndpointSearch search;
|
||||
|
||||
late final Modules modules;
|
||||
|
||||
@override
|
||||
|
|
@ -205,6 +248,7 @@ class Client extends _isc.ServerpodClientShared {
|
|||
'firmware': firmware,
|
||||
'map': map,
|
||||
'routing': routing,
|
||||
'search': search,
|
||||
};
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
|
|||
import 'package:halkyon_oidc_client/halkyon_oidc_client.dart' as _ibkpwzgl;
|
||||
import 'package:halkyon_train_client/src/protocol/routing/route_point.dart'
|
||||
as _iq1flhug;
|
||||
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
||||
as _inm997sc;
|
||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||
as _iacc;
|
||||
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
||||
|
|
@ -39,6 +41,8 @@ import 'routing/route_point.dart' as _isqtfus6;
|
|||
import 'routing/route_profile.dart' as _ifl4j5ep;
|
||||
import 'routing/route_refused.dart' as _ix4aepwk;
|
||||
import 'routing/route_stretch.dart' as _ic7f85bc;
|
||||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
import 'workout/workout.dart' as _i5b9b17z;
|
||||
import 'workout/workout_series.dart' as _i92mefto;
|
||||
export 'day/day.dart';
|
||||
|
|
@ -57,6 +61,8 @@ export 'routing/route_point.dart';
|
|||
export 'routing/route_profile.dart';
|
||||
export 'routing/route_refused.dart';
|
||||
export 'routing/route_stretch.dart';
|
||||
export 'search/place.dart';
|
||||
export 'search/search_refused.dart';
|
||||
export 'workout/workout.dart';
|
||||
export 'workout/workout_series.dart';
|
||||
export 'client.dart';
|
||||
|
|
@ -824,6 +830,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _ic7f85bc.RouteStretch) {
|
||||
return _ic7f85bc.RouteStretch.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iadh29yw.Place) {
|
||||
return _iadh29yw.Place.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ihbxkwke.SearchRefused) {
|
||||
return _ihbxkwke.SearchRefused.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i5b9b17z.Workout) {
|
||||
return _i5b9b17z.Workout.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -881,6 +893,13 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (t == _isc.getType<_ic7f85bc.RouteStretch?>()) {
|
||||
return (data != null ? _ic7f85bc.RouteStretch.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_iadh29yw.Place?>()) {
|
||||
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _isc.getType<_ihbxkwke.SearchRefused?>()) {
|
||||
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _isc.getType<_i5b9b17z.Workout?>()) {
|
||||
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -915,6 +934,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
.toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_inm997sc.Place>) {
|
||||
return (data as List).map((e) => deserialize<_inm997sc.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
try {
|
||||
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
|
||||
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
||||
|
|
@ -951,6 +974,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
_ifl4j5ep.RouteProfile => 'RouteProfile',
|
||||
_ix4aepwk.RouteRefused => 'RouteRefused',
|
||||
_ic7f85bc.RouteStretch => 'RouteStretch',
|
||||
_iadh29yw.Place => 'Place',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
_i5b9b17z.Workout => 'Workout',
|
||||
_i92mefto.WorkoutSeries => 'WorkoutSeries',
|
||||
_ => null,
|
||||
|
|
@ -1002,6 +1027,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
return 'RouteRefused';
|
||||
case _ic7f85bc.RouteStretch():
|
||||
return 'RouteStretch';
|
||||
case _iadh29yw.Place():
|
||||
return 'Place';
|
||||
case _ihbxkwke.SearchRefused():
|
||||
return 'SearchRefused';
|
||||
case _i5b9b17z.Workout():
|
||||
return 'Workout';
|
||||
case _i92mefto.WorkoutSeries():
|
||||
|
|
@ -1090,6 +1119,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
if (dataClassName == 'RouteStretch') {
|
||||
return deserialize<_ic7f85bc.RouteStretch>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Place') {
|
||||
return deserialize<_iadh29yw.Place>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'SearchRefused') {
|
||||
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Workout') {
|
||||
return deserialize<_i5b9b17z.Workout>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
|
||||
/// A place found by the instance's search (ADR 0030, D50): what the planner
|
||||
/// lists and makes a waypoint of.
|
||||
abstract class Place
|
||||
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||
Place._({
|
||||
required this.name,
|
||||
this.detail,
|
||||
required this.kind,
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
});
|
||||
|
||||
factory Place({
|
||||
required String name,
|
||||
String? detail,
|
||||
required String kind,
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) = _PlaceImpl;
|
||||
|
||||
factory Place.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return Place(
|
||||
name: jsonSerialization['name'] as String,
|
||||
detail: jsonSerialization['detail'] as String?,
|
||||
kind: jsonSerialization['kind'] as String,
|
||||
lat: (jsonSerialization['lat'] as num).toDouble(),
|
||||
lon: (jsonSerialization['lon'] as num).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Its name, or its street and number for an address.
|
||||
String name;
|
||||
|
||||
/// Where it is, as the list shows under the name: its street, its town and
|
||||
/// its country, those the name doesn't say already; null where none.
|
||||
String? detail;
|
||||
|
||||
/// What kind of place, as Photon names it (`city`, `street`, `house`,
|
||||
/// `locality`, `other`, …).
|
||||
String kind;
|
||||
|
||||
double lat;
|
||||
|
||||
double lon;
|
||||
|
||||
/// Returns a shallow copy of this [Place]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
Place copyWith({
|
||||
String? name,
|
||||
String? detail,
|
||||
String? kind,
|
||||
double? lat,
|
||||
double? lon,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Place',
|
||||
'name': name,
|
||||
if (detail != null) 'detail': detail,
|
||||
'kind': kind,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Place',
|
||||
'name': name,
|
||||
if (detail != null) 'detail': detail,
|
||||
'kind': kind,
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _isc.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _PlaceImpl extends Place {
|
||||
_PlaceImpl({
|
||||
required String name,
|
||||
String? detail,
|
||||
required String kind,
|
||||
required double lat,
|
||||
required double lon,
|
||||
}) : super._(
|
||||
name: name,
|
||||
detail: detail,
|
||||
kind: kind,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [Place]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
Place copyWith({
|
||||
String? name,
|
||||
Object? detail = _Undefined,
|
||||
String? kind,
|
||||
double? lat,
|
||||
double? lon,
|
||||
}) {
|
||||
return Place(
|
||||
name: name ?? this.name,
|
||||
detail: detail is String? ? detail : this.detail,
|
||||
kind: kind ?? this.kind,
|
||||
lat: lat ?? this.lat,
|
||||
lon: lon ?? this.lon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||
|
||||
/// Why the instance searches nothing (ADR 0030, D50): `off` where it has no
|
||||
/// Photon, `invalid` for a query shorter than `PlaceSearch.minQuery` or
|
||||
/// longer than `PlaceSearch.maxQuery` characters or a place off the earth,
|
||||
/// `tooOften` beyond `PlaceSearch.perMinute` searches a minute,
|
||||
/// `unreachable` where Photon didn't answer.
|
||||
abstract class SearchRefused
|
||||
implements
|
||||
_isc.SerializableException,
|
||||
_isc.SerializableModel,
|
||||
_isc.ProtocolSerialization {
|
||||
SearchRefused._({required this.reason});
|
||||
|
||||
factory SearchRefused({required String reason}) = _SearchRefusedImpl;
|
||||
|
||||
factory SearchRefused.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return SearchRefused(reason: jsonSerialization['reason'] as String);
|
||||
}
|
||||
|
||||
String reason;
|
||||
|
||||
/// Returns a shallow copy of this [SearchRefused]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
SearchRefused copyWith({String? reason});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'SearchRefused',
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'SearchRefused',
|
||||
'reason': reason,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchRefused(reason: $reason)';
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchRefusedImpl extends SearchRefused {
|
||||
_SearchRefusedImpl({required String reason}) : super._(reason: reason);
|
||||
|
||||
/// Returns a shallow copy of this [SearchRefused]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
@override
|
||||
SearchRefused copyWith({String? reason}) {
|
||||
return SearchRefused(reason: reason ?? this.reason);
|
||||
}
|
||||
}
|
||||
|
|
@ -397,6 +397,11 @@
|
|||
"routeProfileMtb": "Mountainbike",
|
||||
"routeTapHint": "Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.",
|
||||
"routeUndo": "Letzten Wegpunkt entfernen",
|
||||
"routeSearchHint": "Nach einem Ort suchen",
|
||||
"routeSearchClear": "Suche leeren",
|
||||
"routeSearchNothing": "Nichts gefunden.",
|
||||
"routeSearchTooOften": "Gerade zu viele Suchen; versuch es in einer Minute noch einmal.",
|
||||
"routeSearchUnreachable": "Die Suche ist gerade nicht erreichbar.",
|
||||
"routePlanning": "Wird geplant …",
|
||||
"routeRefusedInvalid": "Ein Wegpunkt zu viel, oder einer außerhalb der Karte.",
|
||||
"routeRefusedTooOften": "Zu oft geplant: versuch es in einer Minute wieder.",
|
||||
|
|
|
|||
|
|
@ -503,6 +503,13 @@
|
|||
"routeProfileMtb": "Mountain bike",
|
||||
"routeTapHint": "Tap the map to add a waypoint; hold one and drag it to move it.",
|
||||
"routeUndo": "Take the last waypoint off",
|
||||
"routeSearchHint": "Search for a place",
|
||||
"@routeSearchHint": {"description": "The planner's search field over the map (D50)."},
|
||||
"routeSearchClear": "Clear the search",
|
||||
"routeSearchNothing": "Nothing found.",
|
||||
"@routeSearchNothing": {"description": "Under the planner's search: the instance found no place."},
|
||||
"routeSearchTooOften": "Too many searches for now; try again in a minute.",
|
||||
"routeSearchUnreachable": "The search can't be reached now.",
|
||||
"routePlanning": "Planning…",
|
||||
"routeRefusedInvalid": "A waypoint too many, or one off the map.",
|
||||
"routeRefusedTooOften": "Planned too often: try again in a minute.",
|
||||
|
|
|
|||
|
|
@ -2404,6 +2404,36 @@ abstract class AppLocalizations {
|
|||
/// **'Take the last waypoint off'**
|
||||
String get routeUndo;
|
||||
|
||||
/// The planner's search field over the map (D50).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search for a place'**
|
||||
String get routeSearchHint;
|
||||
|
||||
/// No description provided for @routeSearchClear.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear the search'**
|
||||
String get routeSearchClear;
|
||||
|
||||
/// Under the planner's search: the instance found no place.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing found.'**
|
||||
String get routeSearchNothing;
|
||||
|
||||
/// No description provided for @routeSearchTooOften.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Too many searches for now; try again in a minute.'**
|
||||
String get routeSearchTooOften;
|
||||
|
||||
/// No description provided for @routeSearchUnreachable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The search can\'t be reached now.'**
|
||||
String get routeSearchUnreachable;
|
||||
|
||||
/// No description provided for @routePlanning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1432,6 +1432,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get routeUndo => 'Letzten Wegpunkt entfernen';
|
||||
|
||||
@override
|
||||
String get routeSearchHint => 'Nach einem Ort suchen';
|
||||
|
||||
@override
|
||||
String get routeSearchClear => 'Suche leeren';
|
||||
|
||||
@override
|
||||
String get routeSearchNothing => 'Nichts gefunden.';
|
||||
|
||||
@override
|
||||
String get routeSearchTooOften =>
|
||||
'Gerade zu viele Suchen; versuch es in einer Minute noch einmal.';
|
||||
|
||||
@override
|
||||
String get routeSearchUnreachable => 'Die Suche ist gerade nicht erreichbar.';
|
||||
|
||||
@override
|
||||
String get routePlanning => 'Wird geplant …';
|
||||
|
||||
|
|
|
|||
|
|
@ -1425,6 +1425,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get routeUndo => 'Take the last waypoint off';
|
||||
|
||||
@override
|
||||
String get routeSearchHint => 'Search for a place';
|
||||
|
||||
@override
|
||||
String get routeSearchClear => 'Clear the search';
|
||||
|
||||
@override
|
||||
String get routeSearchNothing => 'Nothing found.';
|
||||
|
||||
@override
|
||||
String get routeSearchTooOften =>
|
||||
'Too many searches for now; try again in a minute.';
|
||||
|
||||
@override
|
||||
String get routeSearchUnreachable => 'The search can\'t be reached now.';
|
||||
|
||||
@override
|
||||
String get routePlanning => 'Planning…';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorde
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/data/announce_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
|
||||
|
|
@ -84,6 +85,9 @@ class TrainPerson(
|
|||
/// The instance's planning of routes (D44).
|
||||
required final RoutingGateway routing,
|
||||
|
||||
/// The instance's search for places, for the planner (D50).
|
||||
required final PlaceSearchGateway placeSearch,
|
||||
|
||||
/// The instance's map source (D22).
|
||||
required final MapSourceRepository maps,
|
||||
|
||||
|
|
@ -444,6 +448,7 @@ Future<TrainPerson> _openPersonData(
|
|||
plans: plans,
|
||||
routes: routes,
|
||||
routing: ServerpodRoutingGateway(client),
|
||||
placeSearch: ServerpodPlaceSearchGateway(client),
|
||||
maps: maps,
|
||||
mapTiles: keptVectorTiles(offlineMaps),
|
||||
offlineMaps: offlineMaps,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// The planner's search for places (ADR 0030, D50): asked once the person
|
||||
/// pauses typing ([pause]), for queries of two characters or more, nearest
|
||||
/// the map's centre first, in the app's language; an answer to an earlier
|
||||
/// query dropped once a later one is asked.
|
||||
class PlaceSearchCubit(
|
||||
final PlaceSearchGateway _search, {
|
||||
final Duration pause = const Duration(milliseconds: 400),
|
||||
}) extends Cubit<PlaceSearchState> {
|
||||
this : super(const PlaceSearchState());
|
||||
|
||||
/// The fewest characters searched for, as the instance takes.
|
||||
static const minQuery = 2;
|
||||
|
||||
Timer? _waiting;
|
||||
|
||||
/// Counts the searches asked, so that only the latest's answer counts.
|
||||
var _asked = 0;
|
||||
|
||||
/// Asks whether the instance searches.
|
||||
Future<void> open() async {
|
||||
final offered = await _search.offered();
|
||||
if (!isClosed) {
|
||||
emit(state._with(offered: offered));
|
||||
}
|
||||
}
|
||||
|
||||
/// The person typed [query] with the map at [near]: searched after
|
||||
/// [pause], or cleared where it is too short.
|
||||
void typed(String query, {LatLng? near, String? language}) {
|
||||
_waiting?.cancel();
|
||||
final trimmed = query.trim();
|
||||
if (trimmed.length < minQuery) {
|
||||
_asked++;
|
||||
emit(PlaceSearchState(offered: state.offered));
|
||||
return;
|
||||
}
|
||||
emit(state._with(query: trimmed, searching: true));
|
||||
_waiting = Timer(
|
||||
pause,
|
||||
() => unawaited(_find(trimmed, near: near, language: language)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clears the query and the places found.
|
||||
void clear() => typed('');
|
||||
|
||||
Future<void> _find(String query, {LatLng? near, String? language}) async {
|
||||
final asked = ++_asked;
|
||||
List<FoundPlace>? places;
|
||||
SearchRefusal? refusal;
|
||||
try {
|
||||
places = await _search.places(query, near: near, language: language);
|
||||
} on SearchRefused catch (refused) {
|
||||
refusal = refused.reason;
|
||||
}
|
||||
if (asked != _asked || isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
PlaceSearchState(
|
||||
offered: state.offered,
|
||||
query: query,
|
||||
places: places ?? const [],
|
||||
refusal: refusal,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_waiting?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
final class const PlaceSearchState({
|
||||
/// Whether the instance searches; null until asked.
|
||||
final bool? offered,
|
||||
|
||||
/// What was searched for; empty for nothing.
|
||||
final String query = '',
|
||||
|
||||
/// What it found, nearest the map first.
|
||||
final List<FoundPlace> places = const [],
|
||||
|
||||
/// Whether it is waiting for the person to pause, or for the answer.
|
||||
final bool searching = false,
|
||||
|
||||
/// Why it found nothing; null: it did, or nothing was asked.
|
||||
final SearchRefusal? refusal,
|
||||
}) with Equatable {
|
||||
PlaceSearchState _with({bool? offered, String? query, bool? searching}) =>
|
||||
PlaceSearchState(
|
||||
offered: offered ?? this.offered,
|
||||
query: query ?? this.query,
|
||||
places: places,
|
||||
searching: searching ?? this.searching,
|
||||
refusal: refusal,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [offered, query, places, searching, refusal];
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// A place the instance's search found (ADR 0030, D50): its name, where it
|
||||
/// is (street, town, country; null where nothing more is known), what kind
|
||||
/// of place, and its point.
|
||||
final class const FoundPlace({
|
||||
required final String name,
|
||||
final String? detail,
|
||||
required final String kind,
|
||||
required final LatLng point,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [name, detail, kind, point];
|
||||
}
|
||||
|
||||
/// Why the instance found nothing.
|
||||
enum SearchRefusal() {
|
||||
/// The instance searches nothing: no Photon beside it.
|
||||
off,
|
||||
|
||||
/// Too short or too long a query.
|
||||
invalid,
|
||||
|
||||
/// More searches a minute than the instance allows.
|
||||
tooOften,
|
||||
|
||||
/// The instance, or its Photon, can't be reached now.
|
||||
unreachable,
|
||||
}
|
||||
|
||||
/// The instance refused to search, or couldn't.
|
||||
final class const SearchRefused(final SearchRefusal reason)
|
||||
implements Exception;
|
||||
|
||||
/// Searches for places through the instance's Photon (D50).
|
||||
abstract interface class PlaceSearchGateway() {
|
||||
/// Whether the instance searches; false where it can't be asked.
|
||||
Future<bool> offered();
|
||||
|
||||
/// The places [query] finds, nearest [near] first, named in [language];
|
||||
/// [SearchRefused] where it can't.
|
||||
Future<List<FoundPlace>> places(
|
||||
String query, {
|
||||
LatLng? near,
|
||||
String? language,
|
||||
});
|
||||
}
|
||||
|
||||
/// [PlaceSearchGateway] on Train's server (`search`).
|
||||
class ServerpodPlaceSearchGateway(final protocol.Client _client)
|
||||
implements PlaceSearchGateway {
|
||||
@override
|
||||
Future<bool> offered() async {
|
||||
try {
|
||||
return await _client.search.offered();
|
||||
} on Object catch (error) {
|
||||
log('Search not asked: ${error.runtimeType}', name: 'Routes');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FoundPlace>> places(
|
||||
String query, {
|
||||
LatLng? near,
|
||||
String? language,
|
||||
}) async {
|
||||
try {
|
||||
return [
|
||||
for (final place in await _client.search.places(
|
||||
query,
|
||||
near: near == null
|
||||
? null
|
||||
: protocol.RoutePoint(lat: near.latitude, lon: near.longitude),
|
||||
language: language,
|
||||
))
|
||||
FoundPlace(
|
||||
name: place.name,
|
||||
detail: place.detail,
|
||||
kind: place.kind,
|
||||
point: LatLng(place.lat, place.lon),
|
||||
),
|
||||
];
|
||||
} on protocol.SearchRefused catch (refused) {
|
||||
throw SearchRefused(
|
||||
SearchRefusal.values.asNameMap()[refused.reason] ??
|
||||
SearchRefusal.unreachable,
|
||||
);
|
||||
} on Exception catch (error) {
|
||||
log('Nothing searched: ${error.runtimeType}', name: 'Routes');
|
||||
throw const SearchRefused(SearchRefusal.unreachable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,10 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/place_search_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/route_editor_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_import.dart';
|
||||
|
|
@ -56,6 +58,9 @@ Future<void> openRouteScreen(
|
|||
here: platform == null ? null : () => _here(platform),
|
||||
)..open(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) => PlaceSearchCubit(train.placeSearch)..open(),
|
||||
),
|
||||
],
|
||||
child: RouteScreen(maps: train.maps.source()),
|
||||
),
|
||||
|
|
@ -185,7 +190,8 @@ class const RouteScreen({required final Future<MapSource?> maps, super.key})
|
|||
/// The map: the way planned as a line (straight lines between the
|
||||
/// waypoints until it is), the waypoints numbered, the first green and the
|
||||
/// last red; a tap adds one, one held and dragged moves it, the button
|
||||
/// above takes the last off. The camera fits the route as it opens, and
|
||||
/// above takes the last off, and a place searched for and chosen is the
|
||||
/// next (D50). The camera fits the route as it opens, and
|
||||
/// goes to a new route's first waypoint once it is known.
|
||||
class const _RouteMap({required final MapSource source})
|
||||
extends StatefulWidget {
|
||||
|
|
@ -233,6 +239,9 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
},
|
||||
builder: (context, state) {
|
||||
final route = state.route;
|
||||
final searches = context.select<PlaceSearchCubit, bool>(
|
||||
(search) => search.state.offered ?? false,
|
||||
);
|
||||
final waypoints = [
|
||||
for (final (index, point) in route.waypoints.indexed)
|
||||
if (_dragged case (final dragged, final at) when dragged == index)
|
||||
|
|
@ -321,7 +330,7 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
Positioned(
|
||||
left: 16,
|
||||
right: 64,
|
||||
top: 12,
|
||||
top: searches ? 72 : 12,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
|
|
@ -329,6 +338,13 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
),
|
||||
),
|
||||
),
|
||||
// Last, so that the places found lie over the hint.
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 64,
|
||||
top: 8,
|
||||
child: _PlaceSearch(map: _map),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
|
@ -388,6 +404,117 @@ class _RouteMapState() extends State<_RouteMap> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The search over the map (D50): a field, and under it while there is a
|
||||
/// query the places found, nearest the map's centre first; one chosen is
|
||||
/// the next waypoint, and the map goes to it. Nothing where the instance
|
||||
/// doesn't search.
|
||||
class const _PlaceSearch({required final MapController map})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_PlaceSearch> createState() => _PlaceSearchState();
|
||||
}
|
||||
|
||||
class _PlaceSearchState() extends State<_PlaceSearch> {
|
||||
final _text = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_text.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _choose(FoundPlace place) {
|
||||
final camera = widget.map.camera;
|
||||
context.read<RouteEditorCubit>().add(place.point);
|
||||
widget.map.move(place.point, camera.zoom < 14 ? 14 : camera.zoom);
|
||||
_text.clear();
|
||||
context.read<PlaceSearchCubit>().clear();
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final language = Localizations.localeOf(context).languageCode;
|
||||
return BlocBuilder<PlaceSearchCubit, PlaceSearchState>(
|
||||
builder: (context, state) {
|
||||
if (state.offered != true) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final why = switch (state.refusal) {
|
||||
SearchRefusal.tooOften => l10n.routeSearchTooOften,
|
||||
SearchRefusal.unreachable ||
|
||||
SearchRefusal.off => l10n.routeSearchUnreachable,
|
||||
SearchRefusal.invalid || null =>
|
||||
state.places.isEmpty && !state.searching && state.query.isNotEmpty
|
||||
? l10n.routeSearchNothing
|
||||
: null,
|
||||
};
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Card(
|
||||
child: TextField(
|
||||
controller: _text,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.routeSearchHint,
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
suffixIcon: state.query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: l10n.routeSearchClear,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () {
|
||||
_text.clear();
|
||||
context.read<PlaceSearchCubit>().clear();
|
||||
},
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (query) => context.read<PlaceSearchCubit>().typed(
|
||||
query,
|
||||
near: widget.map.camera.center,
|
||||
language: language,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.query.isNotEmpty)
|
||||
Card(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: switch ((state.places, why)) {
|
||||
(_, final why?) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(why),
|
||||
),
|
||||
([], null) => const LinearProgressIndicator(),
|
||||
(final places, null) => ListView(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
for (final place in places)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.mapPin),
|
||||
title: Text(place.name),
|
||||
subtitle: switch (place.detail) {
|
||||
final detail? => Text(detail),
|
||||
null => null,
|
||||
},
|
||||
onTap: () => _choose(place),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Under the map: why no way was planned, the way's figures, the route's
|
||||
/// name, activity and profile, its elevation and its surfaces.
|
||||
class const _Details() extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
|
||||
|
|
@ -48,6 +49,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
import '../support/fake_firmware_relay.dart';
|
||||
import '../support/fake_health_platform.dart';
|
||||
import '../support/fake_loop_platform.dart';
|
||||
import '../support/fake_place_search_gateway.dart';
|
||||
import '../support/fake_recorder_platform.dart';
|
||||
import '../support/fake_route_files.dart';
|
||||
import '../support/fake_routing_gateway.dart';
|
||||
|
|
@ -92,6 +94,7 @@ void main() {
|
|||
PlanRepository? plans,
|
||||
RouteRepository? routes,
|
||||
RoutingGateway? routing,
|
||||
PlaceSearchGateway? placeSearch,
|
||||
OfflineMaps? offlineMaps,
|
||||
RouteFiles? routeFiles,
|
||||
}) async {
|
||||
|
|
@ -205,6 +208,8 @@ void main() {
|
|||
),
|
||||
),
|
||||
routing: routing ?? FakeRoutingGateway(),
|
||||
placeSearch:
|
||||
placeSearch ?? FakePlaceSearchGateway(isOffered: false),
|
||||
recording: recording,
|
||||
health: health,
|
||||
loop: loop,
|
||||
|
|
@ -2076,12 +2081,14 @@ void main() {
|
|||
({double lat, double lon})? at,
|
||||
OfflineMaps? offlineMaps,
|
||||
RouteFiles? routeFiles,
|
||||
PlaceSearchGateway? placeSearch,
|
||||
}) async {
|
||||
await start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
routes: routes,
|
||||
routing: routing,
|
||||
placeSearch: placeSearch,
|
||||
offlineMaps: offlineMaps,
|
||||
routeFiles: routeFiles,
|
||||
recording: at == null
|
||||
|
|
@ -2110,6 +2117,60 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a place searched for and chosen is the next waypoint, '
|
||||
"the map's centre asked, in the app's language (D50)", (tester) async {
|
||||
final search = FakePlaceSearchGateway(
|
||||
found: const [
|
||||
FoundPlace(
|
||||
name: 'Tempelhofer Feld',
|
||||
detail: 'Berlin, Germany',
|
||||
kind: 'other',
|
||||
point: LatLng(52.47, 13.4),
|
||||
),
|
||||
],
|
||||
);
|
||||
await openRoutes(
|
||||
tester,
|
||||
at: (lat: 52.52, lon: 13.405),
|
||||
placeSearch: search,
|
||||
);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'Tap the map to add a waypoint; hold one and drag it to '
|
||||
'move it.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'Tempelhof');
|
||||
// Past the pause after typing.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pumpAndSettle();
|
||||
expect(search.asked.single.query, 'Tempelhof');
|
||||
expect(search.asked.single.language, 'en');
|
||||
expect(search.asked.single.near, isNotNull);
|
||||
expect(find.text('Berlin, Germany'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Tempelhofer Feld'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(routing.asked.single.waypoints, const [
|
||||
LatLng(52.52, 13.405),
|
||||
LatLng(52.47, 13.4),
|
||||
]);
|
||||
expect(find.text('Berlin, Germany'), findsNothing);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('where the instance searches nothing, the planner has no '
|
||||
'search field', (tester) async {
|
||||
await openRoutes(tester, at: (lat: 52.52, lon: 13.405));
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Search for a place'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a route starts where the phone is, is planned by tapping the '
|
||||
'map, saved once named and listed with its distance and climb', (
|
||||
tester,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/bloc/place_search_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../support/fake_place_search_gateway.dart';
|
||||
|
||||
/// The planner's search (ADR 0030, D50): asked once typing pauses, nearest
|
||||
/// the map first, in the app's language; a later query's answer the one
|
||||
/// that counts.
|
||||
void main() {
|
||||
const feld = FoundPlace(
|
||||
name: 'Tempelhofer Feld',
|
||||
detail: 'Berlin, Germany',
|
||||
kind: 'other',
|
||||
point: LatLng(52.47, 13.4),
|
||||
);
|
||||
const centre = LatLng(52.5, 13.4);
|
||||
const pause = Duration(milliseconds: 20);
|
||||
|
||||
Future<void> paused() => Future<void>.delayed(pause * 3);
|
||||
|
||||
test('asks once typing pauses, with the map centre and the language, '
|
||||
'and lists what it found', () async {
|
||||
final search = FakePlaceSearchGateway(found: const [feld]);
|
||||
final cubit = PlaceSearchCubit(search, pause: pause);
|
||||
addTearDown(cubit.close);
|
||||
await cubit.open();
|
||||
expect(cubit.state.offered, isTrue);
|
||||
|
||||
cubit
|
||||
..typed('Tem', near: centre, language: 'de')
|
||||
..typed('Tempel', near: centre, language: 'de');
|
||||
expect(cubit.state.searching, isTrue);
|
||||
await paused();
|
||||
|
||||
expect(search.asked, hasLength(1));
|
||||
expect(search.asked.single.query, 'Tempel');
|
||||
expect(search.asked.single.near, centre);
|
||||
expect(search.asked.single.language, 'de');
|
||||
expect(cubit.state.places, const [feld]);
|
||||
expect(cubit.state.searching, isFalse);
|
||||
});
|
||||
|
||||
test('asks nothing for fewer than two characters, and clears', () async {
|
||||
final search = FakePlaceSearchGateway(found: const [feld]);
|
||||
final cubit = PlaceSearchCubit(search, pause: pause);
|
||||
addTearDown(cubit.close);
|
||||
cubit.typed('Tempel');
|
||||
await paused();
|
||||
expect(cubit.state.places, isNotEmpty);
|
||||
|
||||
cubit.typed(' T ');
|
||||
await paused();
|
||||
expect(search.asked, hasLength(1));
|
||||
expect(cubit.state.query, isEmpty);
|
||||
expect(cubit.state.places, isEmpty);
|
||||
});
|
||||
|
||||
test("an answer to a query cleared since doesn't come back", () async {
|
||||
final answer = Completer<void>();
|
||||
final search = _HeldSearch(answer.future);
|
||||
final cubit = PlaceSearchCubit(search, pause: pause);
|
||||
addTearDown(cubit.close);
|
||||
cubit.typed('Tempel');
|
||||
await paused();
|
||||
cubit.clear();
|
||||
answer.complete();
|
||||
await paused();
|
||||
|
||||
expect(cubit.state.places, isEmpty);
|
||||
expect(cubit.state.query, isEmpty);
|
||||
});
|
||||
|
||||
test('says why where the instance refused', () async {
|
||||
final search = FakePlaceSearchGateway(refusal: SearchRefusal.tooOften);
|
||||
final cubit = PlaceSearchCubit(search, pause: pause);
|
||||
addTearDown(cubit.close);
|
||||
cubit.typed('Berlin');
|
||||
await paused();
|
||||
|
||||
expect(cubit.state.refusal, SearchRefusal.tooOften);
|
||||
expect(cubit.state.places, isEmpty);
|
||||
});
|
||||
|
||||
test("where the instance doesn't search, it says so", () async {
|
||||
final cubit = PlaceSearchCubit(
|
||||
FakePlaceSearchGateway(isOffered: false),
|
||||
pause: pause,
|
||||
);
|
||||
addTearDown(cubit.close);
|
||||
await cubit.open();
|
||||
expect(cubit.state.offered, isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
/// Answers once [_answer] completes.
|
||||
class _HeldSearch(final Future<void> _answer) implements PlaceSearchGateway {
|
||||
@override
|
||||
Future<bool> offered() async => true;
|
||||
|
||||
@override
|
||||
Future<List<FoundPlace>> places(
|
||||
String query, {
|
||||
LatLng? near,
|
||||
String? language,
|
||||
}) async {
|
||||
await _answer;
|
||||
return const [
|
||||
FoundPlace(name: 'Late', kind: 'other', point: LatLng(0, 0)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/route/data/place_search_gateway.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// [PlaceSearchGateway] finding [found] for every query, or refusing with
|
||||
/// [refusal]; what was asked listed.
|
||||
class FakePlaceSearchGateway({
|
||||
var bool isOffered = true,
|
||||
var List<FoundPlace> found = const [],
|
||||
var SearchRefusal? refusal,
|
||||
}) implements PlaceSearchGateway {
|
||||
final asked = <({String query, LatLng? near, String? language})>[];
|
||||
|
||||
@override
|
||||
Future<bool> offered() async => isOffered;
|
||||
|
||||
@override
|
||||
Future<List<FoundPlace>> places(
|
||||
String query, {
|
||||
LatLng? near,
|
||||
String? language,
|
||||
}) async {
|
||||
asked.add((query: query, near: near, language: language));
|
||||
if (refusal case final refusal?) {
|
||||
throw SearchRefused(refusal);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart
|
|||
import '../firmware/firmware_endpoint.dart' as _id2y0j3h;
|
||||
import '../map/map_endpoint.dart' as _i35zr6r3;
|
||||
import '../routing/routing_endpoint.dart' as _ivjydyye;
|
||||
import '../search/search_endpoint.dart' as _i2f0v2ey;
|
||||
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
|
|
@ -52,6 +53,12 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
'routing',
|
||||
null,
|
||||
),
|
||||
'search': _i2f0v2ey.SearchEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'search',
|
||||
null,
|
||||
),
|
||||
};
|
||||
connectors['firmware'] = _is.EndpointConnector(
|
||||
name: 'firmware',
|
||||
|
|
@ -182,6 +189,53 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
),
|
||||
},
|
||||
);
|
||||
connectors['search'] = _is.EndpointConnector(
|
||||
name: 'search',
|
||||
endpoint: endpoints['search']!,
|
||||
methodConnectors: {
|
||||
'offered': _is.MethodConnector(
|
||||
name: 'offered',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['search'] as _i2f0v2ey.SearchEndpoint)
|
||||
.offered(session),
|
||||
),
|
||||
'places': _is.MethodConnector(
|
||||
name: 'places',
|
||||
params: {
|
||||
'query': _is.ParameterDescription(
|
||||
name: 'query',
|
||||
type: _is.getType<String>(),
|
||||
nullable: false,
|
||||
),
|
||||
'near': _is.ParameterDescription(
|
||||
name: 'near',
|
||||
type: _is.getType<_is4pcfub.RoutePoint?>(),
|
||||
nullable: true,
|
||||
),
|
||||
'language': _is.ParameterDescription(
|
||||
name: 'language',
|
||||
type: _is.getType<String?>(),
|
||||
nullable: true,
|
||||
),
|
||||
},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async =>
|
||||
(endpoints['search'] as _i2f0v2ey.SearchEndpoint).places(
|
||||
session,
|
||||
params['query'],
|
||||
near: params['near'],
|
||||
language: params['language'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_base'] = _ik8molhd.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
modules['serverpod_auth_idp'] = _iais.Endpoints()
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue