2026-07-09 20:10:10 +00:00
|
|
|
import logging
|
|
|
|
|
|
2026-07-09 18:31:29 +00:00
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from app.api.deps import lang_param, length_param
|
|
|
|
|
from app.db.session import get_db
|
|
|
|
|
from app.schemas.common import LatLon
|
|
|
|
|
from app.schemas.nearby import NearbyPlace, NearbyResponse
|
|
|
|
|
from app.schemas.place import ContentOut
|
|
|
|
|
from app.services.nearby_search import find_nearby
|
|
|
|
|
|
2026-07-09 20:10:10 +00:00
|
|
|
logger = logging.getLogger("guidecity.nearby")
|
|
|
|
|
|
2026-07-09 18:31:29 +00:00
|
|
|
router = APIRouter(tags=["nearby"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/nearby", response_model=NearbyResponse)
|
|
|
|
|
def nearby(
|
|
|
|
|
lat: float = Query(),
|
|
|
|
|
lon: float = Query(),
|
|
|
|
|
city_slug: str | None = Query(default=None),
|
|
|
|
|
lang: str = Depends(lang_param),
|
|
|
|
|
length: str = Depends(length_param),
|
|
|
|
|
min_results: int = Query(default=5, ge=1, le=50),
|
|
|
|
|
initial_radius_m: int = Query(default=300, ge=50, le=5000),
|
|
|
|
|
step_m: int = Query(default=200, ge=50, le=5000),
|
Persist listened places, fix restart-on-reopen bug, add stats/reset
Five behavior changes from live testing feedback:
1. Narration no longer leads with the place title — the body text
already opens with the name, so it was read twice.
2. "Done" is now derived from a persisted set of listened place IDs
(UserPrefsDataStore.listenedPlaceIds), not an in-memory index set
that got wiped on every load. A place that's ever been narrated to
completion — in the guide list or the detail screen — is never
auto-narrated again, including across the 60s re-scan and app
restarts. Auto-advance now skips straight to the next unlistened
place instead of walking sequentially. Settings has a new "reset
listened places" button.
3. TtsManager is now keyed by place id: calling speak() for the place
that's already playing just re-attaches the onDone callback instead
of restarting via QUEUE_FLUSH. Fixes opening a place's detail
screen while the guide list is already narrating it restarting
playback from the beginning. Marking a place "listened" now also
lives in TtsManager itself (on natural onDone, not onError/onStop),
so it's correct regardless of which screen was driving playback.
4. Settings now shows "Listened: X of Y (Z%)" against the total place
count for the city.
5. Search radius changed from 10km to 2km (client default in
PlacesRepository/MapViewModel, and the backend's own default for
consistency) — but the result count is uncapped, same as before;
every place within the radius is returned regardless of how many
that is.
Caught a real bug while testing the "skip listened" change: the new
listenedPlaceIds collector ran in a separate coroutine that hadn't
necessarily delivered its first value before the initial loadNearby()
call, so freshly-loaded listened state could be missed on cold start.
Fixed by awaiting listenedPlaceIds.first() synchronously before the
first load, with a separate .drop(1) collector for later changes
(e.g. the reset button). Also hardened GuideViewModelTest with
try/finally around viewModelScope.cancel() — a failing assertion was
skipping cleanup and turning into a 5+ minute hang instead of a fast
failure, since the 60s re-scan loop was never cancelled.
Verified: testDebugUnitTest passes (4/4), assembleDebug produces a
working APK, sent to Telegram. Backend pytest still passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 07:44:12 +00:00
|
|
|
max_radius_m: int = Query(default=2000, ge=100, le=20000),
|
2026-07-09 18:31:29 +00:00
|
|
|
speed: float | None = Query(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Reserved for v2: user's speed in m/s. Currently accepted but ignored.",
|
|
|
|
|
),
|
|
|
|
|
heading: float | None = Query(
|
|
|
|
|
default=None,
|
|
|
|
|
description="Reserved for v2: user's heading in degrees. Currently accepted but ignored.",
|
|
|
|
|
),
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
) -> NearbyResponse:
|
2026-07-09 20:10:10 +00:00
|
|
|
if speed is not None or heading is not None:
|
|
|
|
|
logger.debug("nearby called with reserved v2 params speed=%s heading=%s (ignored)", speed, heading)
|
|
|
|
|
|
2026-07-09 18:31:29 +00:00
|
|
|
result = find_nearby(
|
|
|
|
|
db,
|
|
|
|
|
lat=lat,
|
|
|
|
|
lon=lon,
|
|
|
|
|
language=lang,
|
|
|
|
|
length=length,
|
|
|
|
|
city_slug=city_slug,
|
|
|
|
|
min_results=min_results,
|
|
|
|
|
initial_radius_m=initial_radius_m,
|
|
|
|
|
step_m=step_m,
|
|
|
|
|
max_radius_m=max_radius_m,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
places = [
|
|
|
|
|
NearbyPlace(
|
|
|
|
|
id=place.id,
|
|
|
|
|
slug=place.slug,
|
|
|
|
|
category=place.category,
|
|
|
|
|
location=LatLon(lat=place_lat, lon=place_lon),
|
|
|
|
|
address=place.address,
|
|
|
|
|
district=place.district,
|
|
|
|
|
built_year=place.built_year,
|
|
|
|
|
architect_builder=place.architect_builder,
|
|
|
|
|
architectural_style=place.architectural_style,
|
|
|
|
|
content=ContentOut(
|
|
|
|
|
language=content.language, length=content.length, title=content.title, body=content.body
|
|
|
|
|
),
|
|
|
|
|
distance_m=round(distance_m, 1),
|
|
|
|
|
)
|
|
|
|
|
for place, content, distance_m, place_lon, place_lat in result.rows
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return NearbyResponse(search_radius_m=result.search_radius_m, count=len(places), places=places)
|