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
4 changed files with 2184 additions and 7 deletions

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