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:
Tizian 「ティツィアーン」 2026-10-29 16:20:36 +01:00
commit edd2df79bd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
41 changed files with 2184 additions and 7 deletions

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.",

View file

@ -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.",

View file

@ -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:

View file

@ -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 …';

View file

@ -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…';

View file

@ -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,

View file

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

View file

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

View file

@ -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 {

View file

@ -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,

View file

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

View file

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

View file

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