feat(todo): a pin in the map's middle, and what is around it (ADR 0033, D32)
The location picker fills the screen with its map, the search floating over it. A pin stays in the middle, lifting while the map moves, as Molly's; where it lands the instance names the address and lists the places around, as Threema's. That is `nearby` on the search endpoint, for those signed in, the point neither kept nor logged, counted among the searches: two lookups, as a plain one finds house numbers alone. Fixes the map's credit, which overflowed a phone and was pale on white. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5d9c640e5a
commit
ce07fab193
8 changed files with 1254 additions and 113 deletions
|
|
@ -82,6 +82,31 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
language: params['language'],
|
||||
),
|
||||
),
|
||||
'nearby': _is.MethodConnector(
|
||||
name: 'nearby',
|
||||
params: {
|
||||
'at': _is.ParameterDescription(
|
||||
name: 'at',
|
||||
type: _is.getType<_iywv9say.PlacePoint>(),
|
||||
nullable: false,
|
||||
),
|
||||
'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).nearby(
|
||||
session,
|
||||
params['at'],
|
||||
language: params['language'],
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
modules['halkyon_base'] = _ik8molhd.Endpoints()
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'label/label.dart' as _ivak08z4;
|
|||
import 'privacy/content_key.dart' as _imed3x5d;
|
||||
import 'project/project.dart' as _i9c44spp;
|
||||
import 'project/section.dart' as _imadkvi9;
|
||||
import 'search/nearby.dart' as _ispk4ita;
|
||||
import 'search/place.dart' as _iadh29yw;
|
||||
import 'search/place_point.dart' as _itf0ur2u;
|
||||
import 'search/search_refused.dart' as _ihbxkwke;
|
||||
|
|
@ -36,6 +37,7 @@ export 'label/label.dart';
|
|||
export 'privacy/content_key.dart';
|
||||
export 'project/project.dart';
|
||||
export 'project/section.dart';
|
||||
export 'search/nearby.dart';
|
||||
export 'search/place.dart';
|
||||
export 'search/place_point.dart';
|
||||
export 'search/search_refused.dart';
|
||||
|
|
@ -494,6 +496,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _imadkvi9.Section) {
|
||||
return _imadkvi9.Section.fromJson(data) as T;
|
||||
}
|
||||
if (t == _ispk4ita.Nearby) {
|
||||
return _ispk4ita.Nearby.fromJson(data) as T;
|
||||
}
|
||||
if (t == _iadh29yw.Place) {
|
||||
return _iadh29yw.Place.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -521,6 +526,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_imadkvi9.Section?>()) {
|
||||
return (data != null ? _imadkvi9.Section.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_ispk4ita.Nearby?>()) {
|
||||
return (data != null ? _ispk4ita.Nearby.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_iadh29yw.Place?>()) {
|
||||
return (data != null ? _iadh29yw.Place.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -537,6 +545,10 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_ib4f8uyw.Task?>()) {
|
||||
return (data != null ? _ib4f8uyw.Task.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == List<_iadh29yw.Place>) {
|
||||
return (data as List).map((e) => deserialize<_iadh29yw.Place>(e)).toList()
|
||||
as T;
|
||||
}
|
||||
if (t == List<_ih6de0l6.Place>) {
|
||||
return (data as List).map((e) => deserialize<_ih6de0l6.Place>(e)).toList()
|
||||
as T;
|
||||
|
|
@ -568,6 +580,7 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
_imed3x5d.ContentKey => 'ContentKey',
|
||||
_i9c44spp.Project => 'Project',
|
||||
_imadkvi9.Section => 'Section',
|
||||
_ispk4ita.Nearby => 'Nearby',
|
||||
_iadh29yw.Place => 'Place',
|
||||
_itf0ur2u.PlacePoint => 'PlacePoint',
|
||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||
|
|
@ -598,6 +611,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
return 'Project';
|
||||
case _imadkvi9.Section():
|
||||
return 'Section';
|
||||
case _ispk4ita.Nearby():
|
||||
return 'Nearby';
|
||||
case _iadh29yw.Place():
|
||||
return 'Place';
|
||||
case _itf0ur2u.PlacePoint():
|
||||
|
|
@ -660,6 +675,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (dataClassName == 'Section') {
|
||||
return deserialize<_imadkvi9.Section>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Nearby') {
|
||||
return deserialize<_ispk4ita.Nearby>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Place') {
|
||||
return deserialize<_iadh29yw.Place>(data['data']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
search:
|
||||
- offered:
|
||||
- places:
|
||||
- nearby:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/* 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:halkyon_todo_server/src/generated/protocol.dart' as _i6bvr7df;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../search/place.dart' as _iz6lll64;
|
||||
|
||||
/// What is around a point on the map (ADR 0033, D32): never stored by the
|
||||
/// server, as a found place isn't.
|
||||
abstract class Nearby
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
Nearby._({
|
||||
this.spot,
|
||||
required this.places,
|
||||
});
|
||||
|
||||
factory Nearby({
|
||||
_iz6lll64.Place? spot,
|
||||
required List<_iz6lll64.Place> places,
|
||||
}) = _NearbyImpl;
|
||||
|
||||
factory Nearby.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return Nearby(
|
||||
spot: jsonSerialization['spot'] == null
|
||||
? null
|
||||
: _i6bvr7df.Protocol().deserialize<_iz6lll64.Place>(
|
||||
jsonSerialization['spot'],
|
||||
),
|
||||
places: _i6bvr7df.Protocol().deserialize<List<_iz6lll64.Place>>(
|
||||
jsonSerialization['places'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The address nearest the point, what names a pin; null where none is.
|
||||
_iz6lll64.Place? spot;
|
||||
|
||||
/// The places to go to around it, nearest first: shops, restaurants and
|
||||
/// the like, what a person more likely means than a house number.
|
||||
List<_iz6lll64.Place> places;
|
||||
|
||||
/// Returns a shallow copy of this [Nearby]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
Nearby copyWith({
|
||||
_iz6lll64.Place? spot,
|
||||
List<_iz6lll64.Place>? places,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'Nearby',
|
||||
if (spot != null) 'spot': spot?.toJson(),
|
||||
'places': places.toJson(valueToJson: (v) => v.toJson()),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'Nearby',
|
||||
if (spot != null) 'spot': spot?.toJsonForProtocol(),
|
||||
'places': places.toJson(valueToJson: (v) => v.toJsonForProtocol()),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _NearbyImpl extends Nearby {
|
||||
_NearbyImpl({
|
||||
_iz6lll64.Place? spot,
|
||||
required List<_iz6lll64.Place> places,
|
||||
}) : super._(
|
||||
spot: spot,
|
||||
places: places,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [Nearby]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
@override
|
||||
Nearby copyWith({
|
||||
Object? spot = _Undefined,
|
||||
List<_iz6lll64.Place>? places,
|
||||
}) {
|
||||
return Nearby(
|
||||
spot: spot is _iz6lll64.Place? ? spot : this.spot?.copyWith(),
|
||||
places: places ?? this.places.map((e0) => e0.copyWith()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
### What is around a point on the map (ADR 0033, D32): never stored by the
|
||||
### server, as a found place isn't.
|
||||
class: Nearby
|
||||
fields:
|
||||
### The address nearest the point, what names a pin; null where none is.
|
||||
spot: Place?
|
||||
### The places to go to around it, nearest first: shops, restaurants and
|
||||
### the like, what a person more likely means than a house number.
|
||||
places: List<Place>
|
||||
|
|
@ -12,6 +12,16 @@ abstract interface class PlaceFinder {
|
|||
String? language,
|
||||
required int limit,
|
||||
});
|
||||
|
||||
/// What is around [at], within [radiusKm], nearest first; of the
|
||||
/// OpenStreetMap keys [tags] alone, if given.
|
||||
Future<Map<String, Object?>> around(
|
||||
PlacePoint at, {
|
||||
required double radiusKm,
|
||||
Set<String> tags = const {},
|
||||
String? language,
|
||||
required int limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// Photon didn't answer, or not with GeoJSON.
|
||||
|
|
@ -22,7 +32,8 @@ final class const PhotonFailed(final String reason) implements Exception {
|
|||
|
||||
/// Photon (v1.3.0) at [address], asked over plain HTTP on the network it
|
||||
/// shares with Todo's server alone: `/api` with the query, the point its
|
||||
/// results are nearest to first, the language of their names.
|
||||
/// results are nearest to first, the language of their names; `/reverse`
|
||||
/// for what is around a point.
|
||||
class HttpPhoton(final Uri address) implements PlaceFinder {
|
||||
static const _timeout = Duration(seconds: 10);
|
||||
|
||||
|
|
@ -32,22 +43,36 @@ class HttpPhoton(final Uri address) implements PlaceFinder {
|
|||
PlacePoint? near,
|
||||
String? language,
|
||||
required int limit,
|
||||
}) async {
|
||||
}) => _get('api', {
|
||||
'q': query,
|
||||
'limit': '$limit',
|
||||
if (near != null) ...{'lat': '${near.lat}', 'lon': '${near.lon}'},
|
||||
'lang': ?language,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> around(
|
||||
PlacePoint at, {
|
||||
required double radiusKm,
|
||||
Set<String> tags = const {},
|
||||
String? language,
|
||||
required int limit,
|
||||
}) => _get('reverse', {
|
||||
'lat': '${at.lat}',
|
||||
'lon': '${at.lon}',
|
||||
'radius': '$radiusKm',
|
||||
'limit': '$limit',
|
||||
if (tags.isNotEmpty) 'osm_tag': tags.toList(),
|
||||
'lang': ?language,
|
||||
});
|
||||
|
||||
Future<Map<String, Object?>> _get(
|
||||
String path,
|
||||
Map<String, Object?> parameters,
|
||||
) async {
|
||||
final client = HttpClient()..connectionTimeout = _timeout;
|
||||
try {
|
||||
final uri = address
|
||||
.resolve('api')
|
||||
.replace(
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'limit': '$limit',
|
||||
if (near != null) ...{
|
||||
'lat': '${near.lat}',
|
||||
'lon': '${near.lon}',
|
||||
},
|
||||
'lang': ?language,
|
||||
},
|
||||
);
|
||||
final uri = address.resolve(path).replace(queryParameters: parameters);
|
||||
final HttpClientResponse response;
|
||||
final String body;
|
||||
try {
|
||||
|
|
@ -79,6 +104,30 @@ const _noDestination = {
|
|||
'parking_space',
|
||||
};
|
||||
|
||||
/// What a list of the places around a point leaves out besides: found by
|
||||
/// name it may be meant, but nobody goes there as a place around them.
|
||||
const _noNeighbour = {
|
||||
'atm',
|
||||
'bench',
|
||||
'bicycle_parking',
|
||||
'post_box',
|
||||
'recycling',
|
||||
'toilets',
|
||||
'vending_machine',
|
||||
'waste_basket',
|
||||
};
|
||||
|
||||
/// The places around a point in Photon's [geoJson]: each name once, what
|
||||
/// nobody goes to left out.
|
||||
List<Place> neighboursOf(Map<String, Object?> geoJson) {
|
||||
final names = <String>{};
|
||||
return [
|
||||
for (final place in placesOf(geoJson))
|
||||
if (!_noNeighbour.contains(place.osmValue) && names.add(place.name))
|
||||
place,
|
||||
];
|
||||
}
|
||||
|
||||
/// The places in Photon's [geoJson], in its order: each feature a point
|
||||
/// with a name, or a street (with its number) for an address; what is no
|
||||
/// place to go and the rest left out.
|
||||
|
|
|
|||
|
|
@ -39,8 +39,20 @@ class PlaceSearch {
|
|||
/// The most asked of Photon, as what is no place to go is left out of it.
|
||||
static const _askedOfPhoton = 12;
|
||||
|
||||
/// How far around a point a person looks for a place to go, and for the
|
||||
/// address naming it.
|
||||
static const nearbyKm = 0.5;
|
||||
static const addressKm = 0.1;
|
||||
|
||||
/// What a list of places around a point is made of: a plain lookup finds
|
||||
/// the nearest house numbers alone (komoot's Photon, 5 Nov 2026).
|
||||
static const _placesToGo = {'shop', 'amenity', 'tourism', 'leisure'};
|
||||
|
||||
/// The most asked of Photon around a point, as more is left out of it.
|
||||
static const _askedAround = 20;
|
||||
|
||||
/// The most searches a person may make in a minute: the location sheet asks
|
||||
/// as they type, a pause after each word.
|
||||
/// as they type, a pause after each word, and as the map stops moving.
|
||||
static const perMinute = 60;
|
||||
|
||||
/// The languages Photon names places in; any other gets its own names.
|
||||
|
|
@ -83,6 +95,43 @@ class PlaceSearch {
|
|||
}
|
||||
}
|
||||
|
||||
/// What is around [at] for [person], named in [language]: the address
|
||||
/// that names a pin, and the places to go to. Refused as [places] is, and
|
||||
/// counted as one search.
|
||||
Future<Nearby> nearby(
|
||||
UuidValue person,
|
||||
PlacePoint at, {
|
||||
String? language,
|
||||
}) async {
|
||||
final finder = this.finder;
|
||||
if (finder == null) {
|
||||
throw SearchRefused(reason: 'off');
|
||||
}
|
||||
if (!_onEarth(at)) {
|
||||
throw SearchRefused(reason: 'invalid');
|
||||
}
|
||||
_count(person);
|
||||
final named = languages.contains(language) ? language : null;
|
||||
try {
|
||||
final [address, around] = await Future.wait([
|
||||
finder.around(at, radiusKm: addressKm, language: named, limit: 1),
|
||||
finder.around(
|
||||
at,
|
||||
radiusKm: nearbyKm,
|
||||
tags: _placesToGo,
|
||||
language: named,
|
||||
limit: _askedAround,
|
||||
),
|
||||
]);
|
||||
return Nearby(
|
||||
spot: placesOf(address).firstOrNull,
|
||||
places: neighboursOf(around).take(limit).toList(),
|
||||
);
|
||||
} on PhotonFailed {
|
||||
throw SearchRefused(reason: 'unreachable');
|
||||
}
|
||||
}
|
||||
|
||||
static bool _onEarth(PlacePoint point) =>
|
||||
point.lat.isFinite &&
|
||||
point.lon.isFinite &&
|
||||
|
|
|
|||
|
|
@ -31,4 +31,16 @@ class SearchEndpoint extends Endpoint {
|
|||
near: near,
|
||||
language: language,
|
||||
);
|
||||
|
||||
/// What is around [at], a point on the map, named in [language]: the
|
||||
/// address that names a pin and the places to go to. Refused as [places]
|
||||
/// is, and counted among its searches; the point is neither kept nor
|
||||
/// logged.
|
||||
Future<Nearby> nearby(
|
||||
Session session,
|
||||
PlacePoint at, {
|
||||
String? language,
|
||||
}) => PlaceSearch.of(
|
||||
session.serverpod,
|
||||
).nearby(session.authenticated!.authUserId, at, language: language);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue