diff --git a/backend/activity-backend/models.py b/backend/activity-backend/models.py index f12fb592..4c81c32f 100644 --- a/backend/activity-backend/models.py +++ b/backend/activity-backend/models.py @@ -113,6 +113,7 @@ class FrontendActivityResponse(BaseModel): map_activity_id: str | None = None processing_status: ProcessingStatus = ProcessingStatus.ready storage_key: str | None = None + thumbnail_url: str | None = None class UploadUrlRequest(BaseModel): diff --git a/backend/activity-backend/routes/activities_management_routes.py b/backend/activity-backend/routes/activities_management_routes.py index fde9671d..d5a91c89 100644 --- a/backend/activity-backend/routes/activities_management_routes.py +++ b/backend/activity-backend/routes/activities_management_routes.py @@ -2,7 +2,7 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from middleware.auth import get_current_user, get_optional_user from models import ( @@ -18,6 +18,7 @@ parse_iso_timestamp, ) from services.activity_deletion_service import ActivityDeletionService +from services.map_backend_client import get_map_backend_client from services.supabase_client import get_activity_client from shared.pipeline_enums import ProcessingStatus @@ -25,9 +26,22 @@ router = APIRouter() +async def _background_thumbnail(activity_id: str, user_id: str, gps_path: list[dict]) -> None: + """Generate thumbnail after response is sent; failure is non-fatal.""" + try: + thumbnail_url = await get_map_backend_client().generate_thumbnail(activity_id, gps_path) + if thumbnail_url: + get_activity_client().update_activity_pipeline_fields( + activity_id, user_id, thumbnail_url=thumbnail_url + ) + except Exception: + logger.warning("background thumbnail failed for activity %s", activity_id, exc_info=True) + + @router.post("/", response_model=FrontendActivityResponse, status_code=status.HTTP_201_CREATED) async def create_activity( data: FrontendActivityCreate, + background_tasks: BackgroundTasks, user_id: str = Depends(get_current_user), ): """Create a new activity and return frontend response shape.""" @@ -75,6 +89,9 @@ async def create_activity( fallback_start_time=data.start_time, fallback_end_time=data.end_time, ) + background_tasks.add_task( + _background_thumbnail, created_activity["id"], user_id, gps_path_records + ) return FrontendActivityResponse(**frontend_payload) except HTTPException: raise diff --git a/backend/activity-backend/routes/activity_transformers.py b/backend/activity-backend/routes/activity_transformers.py index 839801e8..efa3106b 100644 --- a/backend/activity-backend/routes/activity_transformers.py +++ b/backend/activity-backend/routes/activity_transformers.py @@ -178,6 +178,7 @@ def map_activity_to_frontend_payload( "map_activity_id": activity_record.get("map_activity_id"), "processing_status": activity_record.get("processing_status") or "ready", "storage_key": activity_record.get("storage_key"), + "thumbnail_url": activity_record.get("thumbnail_url"), } diff --git a/backend/activity-backend/services/map_backend_client.py b/backend/activity-backend/services/map_backend_client.py index 31b247e4..282ea3d3 100644 --- a/backend/activity-backend/services/map_backend_client.py +++ b/backend/activity-backend/services/map_backend_client.py @@ -51,6 +51,20 @@ async def persist_pipeline_activity(self, body: dict) -> dict: response.raise_for_status() return response.json() + async def generate_thumbnail(self, activity_id: str, gps_path: list[dict]) -> str | None: + """Request a thumbnail for a live-recorded activity (lat/lng points).""" + # gps_path uses 'lng'; map-backend /thumbnail expects 'lon' + points = [{"lat": p["lat"], "lon": p["lng"]} for p in gps_path if "lat" in p and "lng" in p] + if not points: + return None + url = f"{self._base_url}/activities/thumbnail" + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post(url, json={"activity_id": activity_id, "points": points}) + if not response.is_success: + logger.warning("thumbnail request failed for %s: %s", activity_id, response.status_code) + return None + return response.json().get("thumbnail_url") + _map_backend_client: MapBackendClient | None = None diff --git a/backend/activity-backend/services/pipeline_processor.py b/backend/activity-backend/services/pipeline_processor.py index f0b5d06b..b5174262 100644 --- a/backend/activity-backend/services/pipeline_processor.py +++ b/backend/activity-backend/services/pipeline_processor.py @@ -72,6 +72,7 @@ async def process_uploaded_file( } map_activity = await self._map_client.persist_pipeline_activity(map_body) map_activity_id = map_activity.get("id") + thumbnail_url = map_activity.get("thumbnail_url") stats = pipeline.get("stats") or {} distance_m = float(stats.get("total_distance_km") or 0) * 1000.0 @@ -97,6 +98,7 @@ async def process_uploaded_file( duration_seconds=int(moving_time_s), elevation_gain_meters=elevation_gain, gps_path=gps_path, + thumbnail_url=thumbnail_url, name=None, ) logger.info( diff --git a/backend/activity-backend/services/supabase_client.py b/backend/activity-backend/services/supabase_client.py index 35ba33a2..d2666924 100644 --- a/backend/activity-backend/services/supabase_client.py +++ b/backend/activity-backend/services/supabase_client.py @@ -105,6 +105,7 @@ def update_activity_pipeline_fields( duration_seconds: int | None = None, elevation_gain_meters: float | None = None, gps_path: list[dict[str, Any]] | None = None, + thumbnail_url: str | None = None, name: str | None = None, ) -> dict[str, Any] | None: update_fields: dict[str, Any] = { @@ -124,6 +125,8 @@ def update_activity_pipeline_fields( update_fields["elevation_gain_meters"] = elevation_gain_meters if gps_path is not None: update_fields["gps_path"] = gps_path + if thumbnail_url is not None: + update_fields["thumbnail_url"] = thumbnail_url if name is not None: update_fields["name"] = name diff --git a/backend/db/migrations/004_activities_thumbnail_url.sql b/backend/db/migrations/004_activities_thumbnail_url.sql new file mode 100644 index 00000000..20653f42 --- /dev/null +++ b/backend/db/migrations/004_activities_thumbnail_url.sql @@ -0,0 +1,3 @@ +-- Run this in the Supabase SQL editor (or via psql against the Supabase Postgres). +-- Adds the thumbnail_url column to the Supabase activities table. +ALTER TABLE activities ADD COLUMN IF NOT EXISTS thumbnail_url text; diff --git a/backend/map-backend/config.py b/backend/map-backend/config.py index 0754f728..0e1a6a8a 100644 --- a/backend/map-backend/config.py +++ b/backend/map-backend/config.py @@ -46,6 +46,7 @@ class Config(BaseSettings): STATIC_MAP_WIDTH: int = 600 STATIC_MAP_HEIGHT: int = 400 STATIC_MAP_ZOOM: int = 12 + THUMBNAIL_BUCKET: str = "activity-thumbnails" # OpenSkiMap-style GeoJSON bulk sync (requires asyncpg pool + migration 002 ``source_id``). # Scheduler runs only when BOTH are set (see ``openskimap_sync_armed``). diff --git a/backend/map-backend/domains/activities_service/api.py b/backend/map-backend/domains/activities_service/api.py index 323e37d6..b9d0c25e 100644 --- a/backend/map-backend/domains/activities_service/api.py +++ b/backend/map-backend/domains/activities_service/api.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import json import logging from datetime import datetime @@ -12,6 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status import asyncpg +from pydantic import BaseModel from shared.track_pipeline_schemas import ( ActivityStatsOut, MapActivityCreateRequest, @@ -26,12 +28,48 @@ TrackPointOut, ) +from config import get_config from domains.activities_service.ports import get_activities_conn +from engine.thumbnail import render_route_png +from services.supabase_client import upload_thumbnail logger = logging.getLogger(__name__) + +async def _render_and_upload(activity_id: str, latlon: list[tuple[float, float]]) -> str | None: + """Render route PNG and upload to storage; returns public URL or None on failure.""" + try: + cfg = get_config() + # ponytail: asyncio.to_thread keeps Pillow off the event loop + png = await asyncio.to_thread( + render_route_png, latlon, cfg.STATIC_MAP_WIDTH, cfg.STATIC_MAP_HEIGHT + ) + return await asyncio.to_thread(upload_thumbnail, activity_id, png) + except Exception: + logger.warning("thumbnail generation failed for %s", activity_id, exc_info=True) + return None + + +async def _generate_thumbnail(activity_id: UUID, points: list) -> str | None: + return await _render_and_upload(str(activity_id), [(p.lat, p.lon) for p in points]) + + router = APIRouter(prefix="/activities", tags=["activities"]) + +class _ThumbnailRequest(BaseModel): + activity_id: str + points: list[dict] # [{lat: float, lon: float}, ...] + + +@router.post("/thumbnail") +async def generate_activity_thumbnail(body: _ThumbnailRequest) -> dict: + """Generate and upload a route thumbnail from raw GPS points (live recording path).""" + latlon = [(p["lat"], p["lon"]) for p in body.points if "lat" in p and "lon" in p] + url = await _render_and_upload(body.activity_id, latlon) + return {"thumbnail_url": url} + + _INSERT_ACTIVITY = """ INSERT INTO map_trail.activities (user_id, recorded_at, stats) VALUES ($1::uuid, $2::timestamptz, $3::jsonb) @@ -262,7 +300,10 @@ async def create_activity( if seg_tuples: await conn.executemany(_INSERT_SEGMENT, seg_tuples) - return await _detail_response(conn, aid) + detail = await _detail_response(conn, aid) + + thumbnail_url = await _generate_thumbnail(aid, body.processed_track.points) + return detail.model_copy(update={"thumbnail_url": thumbnail_url}) except asyncpg.PostgresError: logger.exception("activity insert failed (rolled back)") raise HTTPException( diff --git a/backend/map-backend/engine/thumbnail.py b/backend/map-backend/engine/thumbnail.py new file mode 100644 index 00000000..698fe52d --- /dev/null +++ b/backend/map-backend/engine/thumbnail.py @@ -0,0 +1,68 @@ +"""Route thumbnail renderer — styled dark card with GPS route overlay using Pillow.""" + +from __future__ import annotations + +import io + +from PIL import Image, ImageDraw + +_BG_TOP = (20, 20, 28) # deep blue-black +_BG_BOTTOM = (10, 10, 16) +_GLOW_COLOR = (160, 55, 10) # dim orange for glow pass +_ROUTE_COLOR = (255, 90, 31) # app primary orange +_ROUTE_WIDTH = 4 +_PADDING = 0.15 # fraction of bbox added on each side + + +def _gradient_background(width: int, height: int) -> Image.Image: + """Top-to-bottom gradient from _BG_TOP to _BG_BOTTOM.""" + data = bytearray(width * height * 3) + for y in range(height): + t = y / max(height - 1, 1) + r = int(_BG_TOP[0] + (_BG_BOTTOM[0] - _BG_TOP[0]) * t) + g = int(_BG_TOP[1] + (_BG_BOTTOM[1] - _BG_TOP[1]) * t) + b = int(_BG_TOP[2] + (_BG_BOTTOM[2] - _BG_TOP[2]) * t) + row_start = y * width * 3 + for x in range(width): + i = row_start + x * 3 + data[i], data[i + 1], data[i + 2] = r, g, b + return Image.frombytes("RGB", (width, height), bytes(data)) + + +def render_route_png(points: list[tuple[float, float]], width: int, height: int) -> bytes: + """Return PNG bytes: orange route with glow on a dark gradient background. + + Args: + points: ordered (lat, lon) pairs. + width: output image width in pixels. + height: output image height in pixels. + """ + img = _gradient_background(width, height) + + if len(points) >= 2: + lats = [p[0] for p in points] + lons = [p[1] for p in points] + lat_span = (max(lats) - min(lats)) or 1e-4 + lon_span = (max(lons) - min(lons)) or 1e-4 + min_lat = min(lats) - lat_span * _PADDING + max_lat = max(lats) + lat_span * _PADDING + min_lon = min(lons) - lon_span * _PADDING + max_lon = max(lons) + lon_span * _PADDING + lat_span = max_lat - min_lat + lon_span = max_lon - min_lon + + # ponytail: linear projection — close enough for ski-resort scale + def _to_px(lat: float, lon: float) -> tuple[int, int]: + return ( + int((lon - min_lon) / lon_span * (width - 1)), + int((max_lat - lat) / lat_span * (height - 1)), + ) + + px = [_to_px(lat, lon) for lat, lon in points] + draw = ImageDraw.Draw(img) + draw.line(px, fill=_GLOW_COLOR, width=_ROUTE_WIDTH + 6) # glow pass + draw.line(px, fill=_ROUTE_COLOR, width=_ROUTE_WIDTH) # bright pass + + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return buf.getvalue() diff --git a/backend/map-backend/services/supabase_client.py b/backend/map-backend/services/supabase_client.py index dd23e682..c6db5921 100644 --- a/backend/map-backend/services/supabase_client.py +++ b/backend/map-backend/services/supabase_client.py @@ -1,4 +1,4 @@ -"""Supabase client initialization for Map Backend.""" +"""Supabase client initialization and storage helpers for Map Backend.""" from supabase import Client, create_client @@ -22,3 +22,12 @@ def get_map_client() -> Client: if _map_client is None: raise RuntimeError("Supabase client not initialized. Call initialize_map_client() first.") return _map_client + + +def upload_thumbnail(activity_id: str, png_bytes: bytes) -> str: + """Upload a route thumbnail PNG and return its public URL.""" + cfg = get_config() + path = f"thumbnails/{activity_id}.png" + bucket = get_map_client().storage.from_(cfg.THUMBNAIL_BUCKET) + bucket.upload(path, png_bytes, {"content-type": "image/png", "upsert": "true"}) + return bucket.get_public_url(path) diff --git a/backend/shared/track_pipeline_schemas.py b/backend/shared/track_pipeline_schemas.py index 16eb6b81..74bb6155 100644 --- a/backend/shared/track_pipeline_schemas.py +++ b/backend/shared/track_pipeline_schemas.py @@ -362,6 +362,7 @@ class MapActivityDetailResponse(BaseModel): stats: dict[str, Any] | None = None processed_track: ProcessedTrackOut segments: list[SegmentOut] + thumbnail_url: str | None = None class MapActivityListItem(BaseModel): diff --git a/frontend/lib/core/di/service_locator.dart b/frontend/lib/core/di/service_locator.dart index 90535f23..d1a80449 100644 --- a/frontend/lib/core/di/service_locator.dart +++ b/frontend/lib/core/di/service_locator.dart @@ -28,6 +28,7 @@ import 'package:syntrak/services/apis/map_activities_api.dart'; import 'package:syntrak/services/apis/notifications_api.dart'; import 'package:syntrak/services/apis/users_api.dart'; import 'package:syntrak/services/location_service.dart'; +import 'package:syntrak/services/map_config.dart'; import 'package:syntrak/services/service_registry.dart'; import 'package:syntrak/services/weather_cache.dart'; import 'package:syntrak/services/weather_service.dart'; @@ -45,6 +46,8 @@ Future setupServiceLocatorWithEnvironment({ return; } + await MapConfig.init(); + final appConfig = await AppConfig.bootstrapWithOverride(environmentOverride: environment); sl.registerSingleton(appConfig); diff --git a/frontend/lib/features/activities/data/activities_repository.dart b/frontend/lib/features/activities/data/activities_repository.dart index 4d55dca6..7d43a787 100644 --- a/frontend/lib/features/activities/data/activities_repository.dart +++ b/frontend/lib/features/activities/data/activities_repository.dart @@ -35,4 +35,23 @@ class ActivitiesRepository { Future deleteActivity(String id) { return _api.deleteActivity(id); } + + Future> getMyActivities({ + String? search, + String? activityType, + String? startDate, + String? endDate, + int limit = 100, + int offset = 0, + }) { + return _api.getMyActivities( + search: search, + activityType: activityType, + startDate: startDate, + endDate: endDate, + limit: limit, + offset: offset, + ); + } + } } diff --git a/frontend/lib/models/activity.dart b/frontend/lib/models/activity.dart index 760b8141..0b5d476a 100644 --- a/frontend/lib/models/activity.dart +++ b/frontend/lib/models/activity.dart @@ -159,8 +159,9 @@ class Activity { String get formattedPace { if (averagePace == 0) return '--'; - final minutes = (averagePace ~/ 60).toString().padLeft(2, '0'); - final seconds = (averagePace % 60).toString().padLeft(2, '0'); + final total = averagePace.round(); + final minutes = (total ~/ 60).toString().padLeft(2, '0'); + final seconds = (total % 60).toString().padLeft(2, '0'); return '$minutes:$seconds /km'; } diff --git a/frontend/lib/screens/activities/activity_detail_screen.dart b/frontend/lib/screens/activities/activity_detail_screen.dart index 75dde840..62914840 100644 --- a/frontend/lib/screens/activities/activity_detail_screen.dart +++ b/frontend/lib/screens/activities/activity_detail_screen.dart @@ -3,10 +3,12 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import 'package:syntrak/core/config/app_config.dart'; import 'package:syntrak/core/di/service_locator.dart'; +import 'package:syntrak/core/theme.dart'; import 'package:syntrak/engines/map/color_mode_styler.dart'; import 'package:syntrak/engines/map/map_rendering_engine.dart'; import 'package:syntrak/engines/map/ski_map_layer_loader.dart'; @@ -17,11 +19,9 @@ import 'package:syntrak/models/segment.dart'; import 'package:syntrak/models/track_point.dart'; import 'package:syntrak/providers/activity_provider.dart'; import 'package:syntrak/services/map_config.dart'; -import 'package:intl/intl.dart'; class ActivityDetailScreen extends StatefulWidget { final String activityId; - const ActivityDetailScreen({super.key, required this.activityId}); @override @@ -29,19 +29,16 @@ class ActivityDetailScreen extends StatefulWidget { } class _ActivityDetailScreenState extends State { - Activity? _activity; bool _isLoading = true; ProcessedTrack? _track; List _segments = const []; - List _descentSegments = const []; Dio? _mapDio; MapRenderingEngine? _mapRenderingEngine; MapLibreMapController? _mapController; bool _mapReady = false; - MapColorMode _selectedColorMode = MapColorMode.segment; MapVisualStyle _selectedMapStyle = MapVisualStyle.terrain; @override @@ -54,7 +51,8 @@ class _ActivityDetailScreenState extends State { receiveTimeout: const Duration(seconds: 15), )); _mapRenderingEngine = MapRenderingEngine( - skiTrailLoader: SkiMapLayerLoader(apiClient: DioSkiMapApiClient(_mapDio!)), + skiTrailLoader: + SkiMapLayerLoader(apiClient: DioSkiMapApiClient(_mapDio!)), ); _loadActivity(); } @@ -68,35 +66,25 @@ class _ActivityDetailScreenState extends State { Future _loadActivity() async { final provider = Provider.of(context, listen: false); final activity = await provider.getActivity(widget.activityId); - - if (!mounted) { - return; - } - + if (!mounted) return; setState(() { _activity = activity; _isLoading = false; }); - - if (activity != null) { - await _prepareRouteData(activity); - } + if (activity != null) await _prepareRouteData(activity); } Future _prepareRouteData(Activity activity) async { if (activity.locations.length < 2) { - setState(() { _track = _toProcessedTrack(activity); }); + setState(() => _track = _toProcessedTrack(activity)); return; } final track = _toProcessedTrack(activity); final segments = _localFallbackSegments(track.points); - final descentSegments = - segments.where((s) => s.type == SegmentType.descent).toList(growable: false); if (!mounted) return; setState(() { _track = track; _segments = segments; - _descentSegments = descentSegments; }); await _initialiseMapIfReady(); } @@ -104,24 +92,18 @@ class _ActivityDetailScreenState extends State { ProcessedTrack _toProcessedTrack(Activity activity) { final sorted = List.from(activity.locations) ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - final points = []; for (var i = 0; i < sorted.length; i++) { - final current = sorted[i]; - final previous = i > 0 ? sorted[i - 1] : null; - final computedSpeed = _speedKmh(current, previous); - - points.add( - TrackPoint( - lat: current.latitude, - lon: current.longitude, - elevationM: current.altitude ?? 0, - timestamp: current.timestamp.toUtc(), - speedKmh: computedSpeed, - ), - ); + final cur = sorted[i]; + final prev = i > 0 ? sorted[i - 1] : null; + points.add(TrackPoint( + lat: cur.latitude, + lon: cur.longitude, + elevationM: cur.altitude ?? 0, + timestamp: cur.timestamp.toUtc(), + speedKmh: _speedKmh(cur, prev), + )); } - return ProcessedTrack( id: activity.id, points: points, @@ -130,422 +112,435 @@ class _ActivityDetailScreenState extends State { ); } - Segment _fallbackSegment(List points) { - return Segment( - type: SegmentType.descent, - points: points, - startIndex: 0, - endIndex: points.length - 1, - trailName: 'Detected run', - difficulty: null, - ); - } - List _localFallbackSegments(List points) { - if (points.length < 2) { - return const []; - } - return [_fallbackSegment(points)]; + if (points.length < 2) return const []; + return [ + Segment( + type: SegmentType.descent, + points: points, + startIndex: 0, + endIndex: points.length - 1, + trailName: 'Detected run', + difficulty: null, + ) + ]; } String _normalizeMapBaseUrl(String value) { - var trimmed = value.trim(); - while (trimmed.endsWith('/')) { - trimmed = trimmed.substring(0, trimmed.length - 1); - } - if (trimmed.toLowerCase().endsWith('/api')) { - return trimmed.substring(0, trimmed.length - 4); - } - return trimmed; + var v = value.trim(); + while (v.endsWith('/')) v = v.substring(0, v.length - 1); + if (v.toLowerCase().endsWith('/api')) v = v.substring(0, v.length - 4); + return v; } - double _speedKmh(Location current, Location? previous) { - final rawSpeedMps = current.speed; - if (previous == null) { - return rawSpeedMps == null ? 0 : rawSpeedMps * 3.6; - } - - final deltaSeconds = current.timestamp - .difference(previous.timestamp) - .inMilliseconds / - 1000.0; - - if (deltaSeconds <= 0) { - return rawSpeedMps == null ? 0 : rawSpeedMps * 3.6; - } - - final distanceMeters = _haversineMeters( - previous.latitude, - previous.longitude, - current.latitude, - current.longitude, - ); - return (distanceMeters / deltaSeconds) * 3.6; + double _speedKmh(Location cur, Location? prev) { + final raw = cur.speed; + if (prev == null) return raw == null ? 0 : raw * 3.6; + final dt = cur.timestamp.difference(prev.timestamp).inMilliseconds / 1000.0; + if (dt <= 0) return raw == null ? 0 : raw * 3.6; + return (_haversineMeters(prev.latitude, prev.longitude, cur.latitude, cur.longitude) / dt) * 3.6; } double _haversineMeters(double lat1, double lon1, double lat2, double lon2) { - const earthRadiusM = 6371000.0; - final dLat = _degToRad(lat2 - lat1); - final dLon = _degToRad(lon2 - lon1); - final a = - (sin(dLat / 2) * sin(dLat / 2)) + - cos(_degToRad(lat1)) * cos(_degToRad(lat2)) * (sin(dLon / 2) * sin(dLon / 2)); - final c = 2 * atan2(sqrt(a), sqrt(1 - a)); - return earthRadiusM * c; + const r = 6371000.0; + final dLat = _rad(lat2 - lat1); + final dLon = _rad(lon2 - lon1); + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(_rad(lat1)) * cos(_rad(lat2)) * sin(dLon / 2) * sin(dLon / 2); + return r * 2 * atan2(sqrt(a), sqrt(1 - a)); } - double _degToRad(double value) => value * (pi / 180); + double _rad(double v) => v * (pi / 180); Future _initialiseMapIfReady() async { - final controller = _mapController; - final track = _track; - if (!_mapReady || controller == null || track == null || _segments.isEmpty) { - return; - } - - await _mapRenderingEngine!.initialise( - controller, - track: track, - segments: _segments, - initialColorMode: _selectedColorMode, - ); - await _mapRenderingEngine!.fitToTrack(track); - } - - Future _onColorModeSelected(MapColorMode mode) async { - if (_selectedColorMode == mode) return; - setState(() { _selectedColorMode = mode; }); - unawaited(_mapRenderingEngine!.setColorMode(mode)); - } - - Future _zoomIn() async { - if (_mapController == null) { - return; - } - await _mapController!.animateCamera(CameraUpdate.zoomIn()); + final c = _mapController; + final t = _track; + if (!_mapReady || c == null || t == null || _segments.isEmpty) return; + await _mapRenderingEngine!.initialise(c, + track: t, segments: _segments, initialColorMode: MapColorMode.segment); + await _mapRenderingEngine!.fitToTrack(t); } - Future _zoomOut() async { - if (_mapController == null) { - return; - } - await _mapController!.animateCamera(CameraUpdate.zoomOut()); - } + Future _zoomIn() async => + _mapController?.animateCamera(CameraUpdate.zoomIn()); + Future _zoomOut() async => + _mapController?.animateCamera(CameraUpdate.zoomOut()); void _setMapStyle(MapVisualStyle style) { - if (_selectedMapStyle == style) { - return; - } - + if (_selectedMapStyle == style) return; setState(() { _selectedMapStyle = style; _mapReady = false; _mapController = null; }); + } - final source = MapConfig.resolvedSourceLabel(style); - final needsMapTiler = style == MapVisualStyle.terrain; - final suffix = needsMapTiler && !MapConfig.hasMapTilerApiKey - ? ' (MAPTILER_API_KEY not set)' - : ''; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Map style: $source$suffix'), - duration: const Duration(seconds: 2), + Future _confirmDelete(Activity activity) async { + final confirmed = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (_) => Container( + decoration: BoxDecoration( + color: SyntrakColors.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.fromLTRB( + 24, 12, 24, MediaQuery.of(context).padding.bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.black12, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 24), + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: SyntrakColors.error.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.delete_outline_rounded, + color: SyntrakColors.error, size: 28), + ), + const SizedBox(height: 16), + Text('Delete Activity?', + style: SyntrakTypography.headlineMedium + .copyWith(color: SyntrakColors.textPrimary)), + const SizedBox(height: 8), + Text('This cannot be undone.', + style: SyntrakTypography.bodyMedium + .copyWith(color: SyntrakColors.textSecondary)), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: SyntrakColors.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: const Text('Delete', + style: + TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 4), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text('Cancel', + style: SyntrakTypography.bodyMedium + .copyWith(color: SyntrakColors.textSecondary)), + ), + ], + ), ), ); + + if (confirmed == true && mounted) { + final provider = Provider.of(context, listen: false); + await provider.deleteActivity(activity.id); + if (mounted) Navigator.of(context).pop(); + } } @override Widget build(BuildContext context) { if (_isLoading) { return Scaffold( - appBar: AppBar(title: const Text('Activity Details')), - body: const Center(child: CircularProgressIndicator()), + backgroundColor: SyntrakColors.background, + appBar: _buildAppBar(null), + body: Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(SyntrakColors.primary), + ), + ), ); } if (_activity == null) { return Scaffold( - appBar: AppBar(title: const Text('Activity Details')), - body: const Center(child: Text('Activity not found')), + backgroundColor: SyntrakColors.background, + appBar: _buildAppBar(null), + body: Center( + child: Text('Activity not found', + style: SyntrakTypography.bodyLarge + .copyWith(color: SyntrakColors.textSecondary)), + ), ); } final activity = _activity!; final track = _track; final mapCenter = track != null && track.points.isNotEmpty - ? LatLng(track.points.first.lat, track.points.first.lon) - : (activity.locations.isNotEmpty - ? LatLng(activity.locations.first.latitude, activity.locations.first.longitude) - : const LatLng(46.8, 8.2)); + ? LatLng(track.points.first.lat, track.points.first.lon) + : (activity.locations.isNotEmpty + ? LatLng(activity.locations.first.latitude, + activity.locations.first.longitude) + : const LatLng(46.8, 8.2)); final hasRenderableTrack = track != null && track.points.length > 1; return Scaffold( - appBar: AppBar( - title: Text(activity.type.displayName), - actions: [ - IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _showDeleteDialog(context, activity), - ), - ], - ), + backgroundColor: SyntrakColors.background, + appBar: _buildAppBar(activity), body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ── Activity name + date ──────────────────────────────── + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.name?.isNotEmpty == true + ? activity.name! + : activity.type.displayName, + style: SyntrakTypography.displaySmall.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + DateFormat('EEEE, MMM d, y · h:mm a') + .format(activity.startTime), + style: SyntrakTypography.bodySmall.copyWith( + color: SyntrakColors.textSecondary, + ), + ), + ], + ), + ), + + // ── Map ───────────────────────────────────────────────── SizedBox( - height: 300, + height: 240, child: Stack( children: [ MapLibreMap( - key: ValueKey('activity-${_selectedMapStyle.name}'), + key: ValueKey( + 'activity-${_selectedMapStyle.name}'), styleString: MapConfig.styleForMode(_selectedMapStyle), initialCameraPosition: CameraPosition( target: mapCenter, zoom: hasRenderableTrack ? 13 : 10, ), - onMapCreated: (controller) { - _mapController = controller; - }, + onMapCreated: (c) => _mapController = c, onStyleLoadedCallback: () async { _mapReady = true; await _initialiseMapIfReady(); }, ), Positioned( - top: 10, - right: 10, + top: 12, + right: 12, child: _MapStyleToggle( selectedStyle: _selectedMapStyle, onSelected: _setMapStyle, ), ), Positioned( - right: 10, - bottom: 10, + right: 12, + bottom: 12, child: _MapZoomControls( - onZoomIn: _zoomIn, - onZoomOut: _zoomOut, - ), + onZoomIn: _zoomIn, onZoomOut: _zoomOut), ), ], ), ), - if (hasRenderableTrack) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: _ColorModeBar( - selected: _selectedColorMode, - onSelected: _onColorModeSelected, - ), - ), + const SizedBox(height: 20), - // Metrics + // ── Stats grid ─────────────────────────────────────────── Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Metrics', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _MetricCard( - label: 'Distance', - value: activity.formattedDistance, - icon: Icons.straighten, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _MetricCard( - label: 'Duration', - value: activity.formattedDuration, - icon: Icons.timer, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: _MetricCard( - label: 'Pace', - value: activity.formattedPace, - icon: Icons.speed, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _MetricCard( - label: 'Elevation', - value: '${activity.elevationGain.toStringAsFixed(0)} m', - icon: Icons.terrain, - ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _StatCell( + label: 'Distance', + value: activity.formattedDistance)), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.surfaceVariant), + Expanded( + child: _StatCell( + label: 'Avg Speed', + value: activity.formattedSpeed)), + ], ), - ], - ), - const SizedBox(height: 24), - const Text( - 'Details', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, ), - ), - const SizedBox(height: 16), - _DetailRow( - label: 'Start Time', - value: DateFormat('MMM d, y • h:mm a').format(activity.startTime), - ), - _DetailRow( - label: 'End Time', - value: DateFormat('MMM d, y • h:mm a').format(activity.endTime), - ), - if (activity.name != null && activity.name!.isNotEmpty) - _DetailRow( - label: 'Name', - value: activity.name!, - ), - if (activity.description != null && activity.description!.isNotEmpty) - _DetailRow( - label: 'Description', - value: activity.description!, + Divider( + height: 1, + thickness: 1, + color: SyntrakColors.surfaceVariant), + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _StatCell( + label: 'Moving Time', + value: activity.formattedDuration)), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.surfaceVariant), + Expanded( + child: _StatCell( + label: 'Elevation Gain', + value: + '+${activity.elevationGain.toStringAsFixed(0)} m')), + ], + ), ), - ], + ], + ), + ), + ), + + // ── Details ────────────────────────────────────────────── + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + _DetailTile( + icon: Icons.calendar_today_outlined, + label: 'Start', + value: DateFormat('MMM d, y · h:mm a') + .format(activity.startTime)), + Divider( + height: 1, + thickness: 1, + color: SyntrakColors.surfaceVariant), + _DetailTile( + icon: Icons.flag_outlined, + label: 'End', + value: DateFormat('MMM d, y · h:mm a') + .format(activity.endTime)), + ], + ), ), ), + + const SizedBox(height: 32), ], ), ), ); } - Future _showDeleteDialog(BuildContext context, Activity activity) async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Activity?'), - content: const Text('This action cannot be undone.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - ), - child: const Text('Delete'), - ), - ], + AppBar _buildAppBar(Activity? activity) { + return AppBar( + backgroundColor: SyntrakColors.background, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20), + color: SyntrakColors.textPrimary, + onPressed: () => Navigator.of(context).pop(), ), + title: Text( + activity?.type.displayName ?? '', + style: SyntrakTypography.headlineSmall.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + actions: [ + if (activity != null) + IconButton( + icon: const Icon(Icons.more_vert_rounded, size: 22), + color: SyntrakColors.textPrimary, + onPressed: () => _confirmDelete(activity), + ), + ], ); - - if (confirmed == true && context.mounted) { - final provider = Provider.of(context, listen: false); - await provider.deleteActivity(activity.id); - if (context.mounted) { - Navigator.of(context).pop(); - } - } } } -class _MapStyleToggle extends StatelessWidget { - const _MapStyleToggle({ - required this.selectedStyle, - required this.onSelected, - }); +// ─── Map controls ───────────────────────────────────────────────────────────── +class _MapStyleToggle extends StatelessWidget { + const _MapStyleToggle( + {required this.selectedStyle, required this.onSelected}); final MapVisualStyle selectedStyle; - final void Function(MapVisualStyle style) onSelected; + final void Function(MapVisualStyle) onSelected; @override Widget build(BuildContext context) { + Widget btn(String label, MapVisualStyle style) { + final active = selectedStyle == style; + return Material( + color: active ? SyntrakColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => onSelected(style), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text(label, + style: TextStyle( + color: active ? Colors.white : SyntrakColors.textSecondary, + fontWeight: FontWeight.w600, + fontSize: 13, + )), + ), + ), + ); + } + return DecoratedBox( decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.12), - blurRadius: 12, - offset: const Offset(0, 3), - ), + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 8, + offset: const Offset(0, 2)) ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - _MapStyleButton( - label: '2D', - selected: selectedStyle == MapVisualStyle.clean2d, - onTap: () => onSelected(MapVisualStyle.clean2d), - ), - _MapStyleButton( - label: 'Terrain', - selected: selectedStyle == MapVisualStyle.terrain, - onTap: () => onSelected(MapVisualStyle.terrain), - ), + btn('2D', MapVisualStyle.clean2d), + btn('Terrain', MapVisualStyle.terrain), ], ), ); } } -class _MapStyleButton extends StatelessWidget { - const _MapStyleButton({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: selected ? const Color(0xFFFF5A1F) : Colors.white, - borderRadius: BorderRadius.circular(12), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Text( - label, - style: TextStyle( - color: selected ? Colors.white : Colors.black87, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ); - } -} - class _MapZoomControls extends StatelessWidget { - const _MapZoomControls({ - required this.onZoomIn, - required this.onZoomOut, - }); - + const _MapZoomControls( + {required this.onZoomIn, required this.onZoomOut}); final Future Function() onZoomIn; final Future Function() onZoomOut; @@ -553,14 +548,13 @@ class _MapZoomControls extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.12), - blurRadius: 12, - offset: const Offset(0, 3), - ), + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 8, + offset: const Offset(0, 2)) ], ), child: Column( @@ -568,20 +562,19 @@ class _MapZoomControls extends StatelessWidget { children: [ IconButton( visualDensity: VisualDensity.compact, - icon: const Icon(Icons.add), + icon: Icon(Icons.add, + color: SyntrakColors.textPrimary, size: 20), onPressed: () => onZoomIn(), - tooltip: 'Zoom in', ), Container( - width: 30, - height: 1, - color: Colors.black12, - ), + width: 24, + height: 1, + color: SyntrakColors.surfaceVariant), IconButton( visualDensity: VisualDensity.compact, - icon: const Icon(Icons.remove), + icon: Icon(Icons.remove, + color: SyntrakColors.textPrimary, size: 20), onPressed: () => onZoomOut(), - tooltip: 'Zoom out', ), ], ), @@ -589,146 +582,72 @@ class _MapZoomControls extends StatelessWidget { } } -class _ColorModeBar extends StatelessWidget { - const _ColorModeBar({ - required this.selected, - required this.onSelected, - }); - - final MapColorMode selected; - final Future Function(MapColorMode mode) onSelected; - - @override - Widget build(BuildContext context) { - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _ColorModeChip( - mode: MapColorMode.segment, - label: 'Segment', - selected: selected == MapColorMode.segment, - onSelected: onSelected, - ), - _ColorModeChip( - mode: MapColorMode.speed, - label: 'Speed', - selected: selected == MapColorMode.speed, - onSelected: onSelected, - ), - _ColorModeChip( - mode: MapColorMode.elevation, - label: 'Elevation', - selected: selected == MapColorMode.elevation, - onSelected: onSelected, - ), - ], - ); - } -} - -class _ColorModeChip extends StatelessWidget { - const _ColorModeChip({ - required this.mode, - required this.label, - required this.selected, - required this.onSelected, - }); +// ─── Stats ──────────────────────────────────────────────────────────────────── - final MapColorMode mode; - final String label; - final bool selected; - final Future Function(MapColorMode mode) onSelected; - - @override - Widget build(BuildContext context) { - return ChoiceChip( - selected: selected, - label: Text(label), - onSelected: (value) { - if (value) { - unawaited(onSelected(mode)); - } - }, - ); - } -} - -class _MetricCard extends StatelessWidget { +class _StatCell extends StatelessWidget { + const _StatCell({required this.label, required this.value}); final String label; final String value; - final IconData icon; - - const _MetricCard({ - required this.label, - required this.value, - required this.icon, - }); @override Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Icon(icon, color: const Color(0xFFFF4500)), - const SizedBox(height: 8), - Text( - value, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: SyntrakTypography.labelSmall.copyWith( + color: SyntrakColors.textSecondary, + letterSpacing: 0.3, ), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), + ), + const SizedBox(height: 6), + Text( + value, + style: SyntrakTypography.headlineMedium.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.bold, ), - ], - ), + ), + ], ), ); } } -class _DetailRow extends StatelessWidget { +// ─── Details ────────────────────────────────────────────────────────────────── + +class _DetailTile extends StatelessWidget { + const _DetailTile( + {required this.icon, required this.label, required this.value}); + final IconData icon; final String label; final String value; - const _DetailRow({required this.label, required this.value}); - @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon(icon, size: 18, color: SyntrakColors.textSecondary), + const SizedBox(width: 12), SizedBox( - width: 100, - child: Text( - label, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), + width: 48, + child: Text(label, + style: SyntrakTypography.bodySmall + .copyWith(color: SyntrakColors.textSecondary)), ), Expanded( - child: Text( - value, - style: const TextStyle( - fontSize: 14, - ), - ), + child: Text(value, + style: SyntrakTypography.bodyMedium + .copyWith(color: SyntrakColors.textPrimary)), ), ], ), ); } } - diff --git a/frontend/lib/screens/maps/maps_screen.dart b/frontend/lib/screens/maps/maps_screen.dart index 660801f1..0f947466 100644 --- a/frontend/lib/screens/maps/maps_screen.dart +++ b/frontend/lib/screens/maps/maps_screen.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:provider/provider.dart'; import 'package:syntrak/core/activity_helpers.dart'; +import 'package:syntrak/core/di/service_locator.dart'; import 'package:syntrak/models/activity.dart'; import 'package:syntrak/providers/activity_provider.dart'; +import 'package:syntrak/screens/activities/activity_detail_screen.dart'; +import 'package:syntrak/services/apis/map_activities_api.dart'; import 'package:syntrak/services/location_service.dart'; import 'package:syntrak/services/map_config.dart'; -import 'package:syntrak/screens/activities/activity_detail_screen.dart'; class MapsScreen extends StatefulWidget { const MapsScreen({super.key}); @@ -15,6 +17,9 @@ class MapsScreen extends StatefulWidget { State createState() => _MapsScreenState(); } +const _trailSourceId = 'ski-trails'; +const _trailLayerId = 'ski-trails-layer'; + class _MapsScreenState extends State { final LocationService _locationService = LocationService(); MapLibreMapController? _mapController; @@ -24,6 +29,7 @@ class _MapsScreenState extends State { bool _hasError = false; String? _errorMessage; List _activities = []; + bool _trailsLoaded = false; @override void initState() { @@ -99,6 +105,50 @@ class _MapsScreenState extends State { } } + Future _onStyleLoaded() async { + final controller = _mapController; + if (controller == null) return; + await controller.addGeoJsonSource( + _trailSourceId, + const {'type': 'FeatureCollection', 'features': []}, + ); + await controller.addLineLayer( + _trailSourceId, + _trailLayerId, + const LineLayerProperties( + lineColor: [ + 'match', ['get', 'difficulty'], + 'easy', '#4CAF50', + 'novice', '#4CAF50', + 'intermediate', '#2196F3', + 'advanced', '#212121', + 'expert', '#212121', + 'freeride', '#FF5A1F', + '#9E9E9E', // fallback (null / unknown) + ], + lineWidth: 2.5, + lineOpacity: 0.85, + ), + ); + _trailsLoaded = true; + await _refreshTrails(); + } + + // ponytail: fires on every camera idle — add client-side bbox debounce if this gets chatty + Future _refreshTrails() async { + final controller = _mapController; + if (controller == null || !_trailsLoaded) return; + final zoom = controller.cameraPosition?.zoom ?? 0; + if (zoom < 11) return; // too zoomed out for trail detail + try { + final bounds = await controller.getVisibleRegion(); + final geojson = await sl().getResortTrails(bounds); + await controller.setGeoJsonSource(_trailSourceId, geojson); + } catch (e) { + debugPrint('[MapsScreen] Failed to refresh trails: $e'); + } + } + void _showActivityDetails(Activity activity) { Navigator.push( context, @@ -142,6 +192,7 @@ class _MapsScreenState extends State { setState(() { _selectedStyle = style; _mapController = null; + _trailsLoaded = false; }); final source = MapConfig.resolvedSourceLabel(style); @@ -247,11 +298,9 @@ class _MapsScreenState extends State { styleString: MapConfig.styleForMode(_selectedStyle), initialCameraPosition: _initialCameraPosition!, myLocationEnabled: false, - onMapCreated: (controller) { - setState(() { - _mapController = controller; - }); - }, + onMapCreated: (controller) => setState(() => _mapController = controller), + onStyleLoadedCallback: _onStyleLoaded, + onCameraIdle: _refreshTrails, ), // Activity list overlay if (_activities.isNotEmpty) diff --git a/frontend/lib/screens/profile/activities_tab.dart b/frontend/lib/screens/profile/activities_tab.dart index 56de6fba..790fe3d5 100644 --- a/frontend/lib/screens/profile/activities_tab.dart +++ b/frontend/lib/screens/profile/activities_tab.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:syntrak/core/di/service_locator.dart'; import 'package:syntrak/core/theme.dart'; +import 'package:syntrak/features/activities/data/activities_repository.dart'; import 'package:syntrak/models/activity.dart'; import 'package:syntrak/providers/auth_provider.dart'; import 'package:syntrak/screens/profile/widgets/profile_activity_list_card.dart'; import 'package:syntrak/screens/profile/widgets/profile_activities_search_bar.dart'; -import 'package:syntrak/services/profile_activities_service.dart'; class ActivitiesTab extends StatefulWidget { const ActivitiesTab({ @@ -20,7 +21,7 @@ class ActivitiesTab extends StatefulWidget { } class _ActivitiesTabState extends State { - final ProfileActivitiesService _activityService = ProfileActivitiesService(); + final ActivitiesRepository _repo = sl(); final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; List _activities = []; @@ -51,7 +52,7 @@ class _ActivitiesTabState extends State { final activities = widget.activities.isNotEmpty ? List.from(widget.activities) - : await _activityService.getUserActivities(); + : await _repo.getMyActivities(); activities.sort((a, b) => b.startTime.compareTo(a.startTime)); setState(() { @@ -243,14 +244,10 @@ class _ActivitiesTabState extends State { _kudosCountMap[activityId] = currentValue ? currentCount - 1 : currentCount + 1; }); - _activityService.toggleKudos(activityId); + // ponytail: kudos backend not wired yet } - void _shareActivity(String activityId) { - _activityService.shareActivity(activityId); - } + void _shareActivity(String activityId) {} - void _commentActivity(String activityId) { - _activityService.addComment(activityId, ''); - } + void _commentActivity(String activityId) {} } diff --git a/frontend/lib/screens/record/record_map_view.dart b/frontend/lib/screens/record/record_map_view.dart index 487fb59f..9599a3da 100644 --- a/frontend/lib/screens/record/record_map_view.dart +++ b/frontend/lib/screens/record/record_map_view.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:syntrak/core/theme.dart'; import 'package:syntrak/services/map_config.dart'; -class RecordMapView extends StatelessWidget { +class RecordMapView extends StatefulWidget { const RecordMapView({ super.key, required this.initialCameraPosition, @@ -18,20 +20,116 @@ class RecordMapView extends StatelessWidget { final VoidCallback? onTrackingDismissed; final VoidCallback? onStyleLoaded; + @override + State createState() => _RecordMapViewState(); +} + +class _RecordMapViewState extends State { + bool _styleLoaded = false; + bool _timedOut = false; + Timer? _loadTimer; + + @override + void initState() { + super.initState(); + // ponytail: 10s covers slow connections; shows actionable error instead of silent blank + _loadTimer = Timer(const Duration(seconds: 10), () { + if (mounted && !_styleLoaded) setState(() => _timedOut = true); + }); + } + + @override + void dispose() { + _loadTimer?.cancel(); + super.dispose(); + } + + void _onStyleLoaded() { + _loadTimer?.cancel(); + setState(() => _styleLoaded = true); + widget.onStyleLoaded?.call(); + } + + void _retry() { + setState(() { + _styleLoaded = false; + _timedOut = false; + }); + _loadTimer = Timer(const Duration(seconds: 10), () { + if (mounted && !_styleLoaded) setState(() => _timedOut = true); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + MapLibreMap( + // ponytail: terrain locked during recording + styleString: MapConfig.styleForMode(MapVisualStyle.terrain), + initialCameraPosition: widget.initialCameraPosition, + myLocationEnabled: true, + myLocationTrackingMode: widget.myLocationTrackingMode, + compassEnabled: false, + rotateGesturesEnabled: false, + tiltGesturesEnabled: false, + onCameraTrackingDismissed: widget.onTrackingDismissed, + onMapCreated: widget.onMapCreated, + onStyleLoadedCallback: _onStyleLoaded, + ), + if (_timedOut) + Positioned.fill( + child: _MapErrorOverlay( + message: MapConfig.hasMapTilerApiKey + ? 'Map failed to load — check your network connection.' + : 'Map tiles not configured.\nRun with MAPTILER_API_KEY set.', + onRetry: _retry, + ), + ), + ], + ); + } +} + +class _MapErrorOverlay extends StatelessWidget { + const _MapErrorOverlay({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + @override Widget build(BuildContext context) { - return MapLibreMap( - // ponytail: terrain locked during recording - styleString: MapConfig.styleForMode(MapVisualStyle.terrain), - initialCameraPosition: initialCameraPosition, - myLocationEnabled: true, - myLocationTrackingMode: myLocationTrackingMode, - compassEnabled: false, - rotateGesturesEnabled: false, - tiltGesturesEnabled: false, - onCameraTrackingDismissed: onTrackingDismissed, - onMapCreated: onMapCreated, - onStyleLoadedCallback: onStyleLoaded, + return Container( + color: SyntrakColors.darkBackground.withValues(alpha: 0.85), + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.map_outlined, color: Colors.white54, size: 48), + const SizedBox(height: 16), + Text( + message, + style: SyntrakTypography.bodyMedium + .copyWith(color: Colors.white70), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: SyntrakColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), ); } } diff --git a/frontend/lib/screens/record/record_screen.dart b/frontend/lib/screens/record/record_screen.dart index b575775c..7757136b 100644 --- a/frontend/lib/screens/record/record_screen.dart +++ b/frontend/lib/screens/record/record_screen.dart @@ -49,6 +49,12 @@ class _RecordScreenState extends State { // Track last point where GeoJSON was pushed to avoid redundant redraws. LatLng? _lastGeoJsonUpdatePoint; + // Processing overlay state — shown after save while pipeline runs. + bool _isProcessing = false; + double _rawDistance = 0; + double _rawElevationGain = 0; + Duration _rawDuration = Duration.zero; + @override void initState() { super.initState(); @@ -232,82 +238,175 @@ class _RecordScreenState extends State { return; } - final shouldSave = await _showSaveDialog(); + // Compute once — shared by the dialog display and the save call. + final rawDistance = _locationService.calculateDistance(); + final rawElevationGain = _locationService.calculateElevationGain(); + final rawDuration = _elapsedNotifier.value; + + final activityName = + await _showSaveDialog(rawDistance, rawElevationGain, rawDuration); - if (shouldSave == true && mounted) { - await _saveActivity(); + if (activityName != null && mounted) { + await _saveActivity(activityName, rawDistance, rawElevationGain, rawDuration); } else { _resetState(); } } - Future _showSaveDialog() { - return showModalBottomSheet( + Future _showSaveDialog( + double distance, double elevation, Duration duration) { + final hour = DateTime.now().hour; + final timeOfDay = + hour < 12 ? 'Morning' : (hour < 17 ? 'Afternoon' : 'Evening'); + final defaultName = + '$timeOfDay ${_selectedActivityType?.displayName ?? 'Activity'}'; + final nameController = TextEditingController(text: defaultName); + + String fmtDist(double d) => d >= 1000 + ? '${(d / 1000).toStringAsFixed(2)} km' + : '${d.toStringAsFixed(0)} m'; + String fmtDur(Duration d) { + final h = d.inHours; + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return h > 0 ? '$h:$m:$s' : '$m:$s'; + } + + return showModalBottomSheet( context: context, backgroundColor: Colors.transparent, - builder: (_) => Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - padding: EdgeInsets.fromLTRB( - 24, - 12, - 24, - MediaQuery.of(context).padding.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: Colors.black12, - borderRadius: BorderRadius.circular(2), + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + padding: EdgeInsets.fromLTRB( + 24, 12, 24, MediaQuery.of(context).padding.bottom + 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: Colors.black12, + borderRadius: BorderRadius.circular(2), + ), ), - ), - const SizedBox(height: 24), - Text( - 'Save Activity?', - style: SyntrakTypography.headlineMedium.copyWith( - color: SyntrakColors.textPrimary, + const SizedBox(height: 24), + // Success badge + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: SyntrakColors.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(Icons.check_rounded, + color: SyntrakColors.primary, size: 34), ), - ), - const SizedBox(height: 8), - Text( - 'Your route and stats will be saved to your profile.', - textAlign: TextAlign.center, - style: SyntrakTypography.bodyMedium.copyWith( - color: SyntrakColors.textSecondary, + const SizedBox(height: 14), + Text( + 'Activity Complete', + style: SyntrakTypography.headlineMedium.copyWith( + color: SyntrakColors.textPrimary, + ), ), - ), - const SizedBox(height: 28), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Navigator.pop(context, true), - style: ElevatedButton.styleFrom( - backgroundColor: SyntrakColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), - elevation: 0, + if (_selectedActivityType != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _selectedActivityType!.displayName, + style: SyntrakTypography.bodyMedium.copyWith( + color: SyntrakColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 20), + // Editable activity name + TextField( + controller: nameController, + style: SyntrakTypography.bodyLarge.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.w600, + ), + textCapitalization: TextCapitalization.words, + decoration: InputDecoration( + hintText: 'Activity name', + filled: true, + fillColor: SyntrakColors.surfaceVariant, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 14), ), - child: const Text('Save', - style: - TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), ), - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: TextButton( - onPressed: () => Navigator.pop(context, false), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), + const SizedBox(height: 16), + // Stats row + Container( + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + color: SyntrakColors.surfaceVariant, + borderRadius: BorderRadius.circular(16), + ), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _StatItem( + value: fmtDist(distance), label: 'Distance')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), + Expanded( + child: _StatItem( + value: fmtDur(duration), label: 'Time')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), + Expanded( + child: _StatItem( + value: '+${elevation.toStringAsFixed(0)} m', + label: 'Elevation')), + ], + ), ), + ), + const SizedBox(height: 24), + // Save button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + final name = nameController.text.trim(); + Navigator.pop(context, name.isEmpty ? defaultName : name); + }, + style: ElevatedButton.styleFrom( + backgroundColor: SyntrakColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + child: const Text('Save Activity', + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 4), + TextButton( + onPressed: () => Navigator.pop(context, null), child: Text( 'Discard', style: SyntrakTypography.bodyMedium.copyWith( @@ -315,14 +414,15 @@ class _RecordScreenState extends State { ), ), ), - ), - ], + ], + ), ), ), ); } - Future _saveActivity() async { + Future _saveActivity(String name, double rawDistance, + double rawElevationGain, Duration rawDuration) async { final locations = _locationService.locations; if (locations.isEmpty) return; @@ -333,10 +433,11 @@ class _RecordScreenState extends State { final activity = Activity( id: '', userId: auth.user?.id ?? '', + name: name, type: _selectedActivityType!, - distance: _locationService.calculateDistance(), - duration: _elapsedNotifier.value.inSeconds, - elevationGain: _locationService.calculateElevationGain(), + distance: rawDistance, + duration: rawDuration.inSeconds, + elevationGain: rawElevationGain, startTime: locations.first.timestamp, endTime: locations.last.timestamp, averagePace: 0, @@ -348,23 +449,71 @@ class _RecordScreenState extends State { final saved = await activityProvider.createActivity(activity); - if (saved != null && mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => ActivityDetailScreen(activityId: saved.id), - ), - ); - } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to save activity'), - backgroundColor: Color(0xFFDC2626), - ), - ); + if (saved == null || !mounted) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to save activity'), + backgroundColor: Color(0xFFDC2626), + ), + ); + } + _resetState(); + return; } + // If the backend already processed it inline, skip the wait. + if (!saved.isPipelinePending) { + _resetState(); + _navigateToDetail(saved.id); + return; + } + + // Show processing overlay while pipeline runs. _resetState(); + setState(() { + _isProcessing = true; + _rawDistance = rawDistance; + _rawElevationGain = rawElevationGain; + _rawDuration = rawDuration; + }); + _pollPipelineStatus(saved.id); + } + + Future _pollPipelineStatus(String activityId) async { + final activityProvider = + Provider.of(context, listen: false); + final deadline = DateTime.now().add(const Duration(minutes: 2)); + while (mounted && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + try { + final activity = await activityProvider.getActivity(activityId); + if (activity != null && !activity.isPipelinePending) { + if (!mounted) return; + setState(() => _isProcessing = false); + _navigateToDetail(activityId); + return; + } + } catch (_) { + // swallow transient errors and keep polling until the deadline + } + } + // Timed out: drop the overlay and navigate anyway (detail screen can + // continue to reflect status) or surface a retry. + if (mounted) { + setState(() => _isProcessing = false); + _navigateToDetail(activityId); + } + } + + void _navigateToDetail(String activityId) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ActivityDetailScreen(activityId: activityId), + ), + ); } void _resetState() { @@ -507,12 +656,166 @@ class _RecordScreenState extends State { onResume: _resumeRecording, ), ), + + // 5 — Pipeline processing overlay (blocks all input until ready) + if (_isProcessing) + Positioned.fill( + child: _ProcessingOverlay( + distance: _rawDistance, + elevationGain: _rawElevationGain, + duration: _rawDuration, + activityType: _selectedActivityType, + ), + ), ], ), ); } } +// ─── Processing overlay ─────────────────────────────────────────────────────── + +class _ProcessingOverlay extends StatelessWidget { + const _ProcessingOverlay({ + required this.distance, + required this.elevationGain, + required this.duration, + this.activityType, + }); + + final double distance; + final double elevationGain; + final Duration duration; + final ActivityType? activityType; + + String get _formattedDistance => distance >= 1000 + ? '${(distance / 1000).toStringAsFixed(2)} km' + : '${distance.toStringAsFixed(0)} m'; + + String get _formattedDuration { + final h = duration.inHours; + final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return h > 0 ? '$h:$m:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) { + return AbsorbPointer( + child: Container( + color: SyntrakColors.background, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 56, + height: 56, + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(SyntrakColors.primary), + strokeWidth: 3, + ), + ), + const SizedBox(height: 32), + Text( + 'Saving Activity', + style: SyntrakTypography.headlineMedium.copyWith( + color: SyntrakColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + activityType != null + ? 'Matching trails for your ${activityType!.displayName.toLowerCase()}…' + : 'Matching trails and correcting elevation…', + style: SyntrakTypography.bodyMedium.copyWith( + color: SyntrakColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + Container( + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: SyntrakColors.divider), + ), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _StatItem( + label: 'Distance', + value: _formattedDistance)), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), + Expanded( + child: _StatItem( + label: 'Elevation', + value: + '+${elevationGain.toStringAsFixed(0)} m')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), + Expanded( + child: _StatItem( + label: 'Time', value: _formattedDuration)), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _StatItem extends StatelessWidget { + const _StatItem({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + value, + textAlign: TextAlign.center, + style: SyntrakTypography.headlineSmall.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + label, + textAlign: TextAlign.center, + style: SyntrakTypography.labelSmall.copyWith( + color: SyntrakColors.textTertiary, + letterSpacing: 0.5, + ), + ), + ], + ); + } +} + + // ─── Re-centre FAB ──────────────────────────────────────────────────────────── class _RecentreButton extends StatelessWidget { diff --git a/frontend/lib/services/apis/activities_api.dart b/frontend/lib/services/apis/activities_api.dart index 214483a0..a9d02153 100644 --- a/frontend/lib/services/apis/activities_api.dart +++ b/frontend/lib/services/apis/activities_api.dart @@ -7,13 +7,13 @@ class ActivitiesApi { final Dio _dio; Future createActivity(Activity activity) async { - final response = await _dio.post('/activities', data: activity.toJson()); + final response = await _dio.post('/activities/', data: activity.toJson()); return Activity.fromJson(response.data); } Future> getActivities({int page = 1, int limit = 20}) async { final offset = (page - 1) * limit; - final response = await _dio.get('/activities', queryParameters: { + final response = await _dio.get('/activities/', queryParameters: { 'limit': limit, 'offset': offset, }); @@ -53,4 +53,29 @@ class ActivitiesApi { Future deleteActivity(String id) async { await _dio.delete('/activities/$id'); } + + Future> getMyActivities({ + String? search, + String? activityType, + String? startDate, + String? endDate, + int limit = 100, + int offset = 0, + }) async { + final response = await _dio.get('/activities/me', queryParameters: { + if (search != null) 'search': search, + if (activityType != null) 'activity_type': activityType, + if (startDate != null) 'start_date': startDate, + if (endDate != null) 'end_date': endDate, + 'limit': limit, + 'offset': offset, + }); + final data = response.data; + final List rows = data is Map + ? (data['items'] as List? ?? const []) + : (data as List? ?? const []); + return rows + .map((json) => Activity.fromJson(json as Map)) + .toList(); + } } diff --git a/frontend/lib/services/apis/map_activities_api.dart b/frontend/lib/services/apis/map_activities_api.dart index 40ab3c23..fbbc9e4b 100644 --- a/frontend/lib/services/apis/map_activities_api.dart +++ b/frontend/lib/services/apis/map_activities_api.dart @@ -1,11 +1,25 @@ import 'package:dio/dio.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; -/// Thin HTTP client for map-backend `map_trail` activity persistence. +/// Thin HTTP client for map-backend: trail overlays and `map_trail` activity persistence. class MapActivitiesApi { MapActivitiesApi({required Dio dio}) : _dio = dio; final Dio _dio; + static const _emptyGeoJson = {'type': 'FeatureCollection', 'features': []}; + + Future> getResortTrails(LatLngBounds bbox) async { + final response = await _dio.get>( + '/trails/resort', + queryParameters: { + 'bbox': '${bbox.southwest.longitude},${bbox.southwest.latitude}' + ',${bbox.northeast.longitude},${bbox.northeast.latitude}', + }, + ); + return Map.from(response.data ?? _emptyGeoJson); + } + Future> createActivity(Map body) async { final response = await _dio.post>('/activities', data: body); return Map.from(response.data ?? const {}); diff --git a/frontend/lib/services/map_config.dart b/frontend/lib/services/map_config.dart index 7f1881e6..40a2dd31 100644 --- a/frontend/lib/services/map_config.dart +++ b/frontend/lib/services/map_config.dart @@ -1,7 +1,5 @@ -// Shared map UI configuration. -// -// Defaults to a clean 2D basemap so all map screens stay readable and avoid -// heavy 3D visual clutter. +import 'dart:convert'; +import 'package:flutter/services.dart'; enum MapVisualStyle { clean2d, @@ -9,22 +7,34 @@ enum MapVisualStyle { } class MapConfig { - static const String _styleUrl = String.fromEnvironment( - 'MAP_STYLE_URL', - defaultValue: '', - ); - - static const String _mapTilerKey = String.fromEnvironment( - 'MAPTILER_API_KEY', - defaultValue: '', - ); - - static const String _mapTilerStreetsStyleUrl = - 'https://api.maptiler.com/maps/streets-v2/style.json'; - static const String _mapTilerOutdoorStyleUrl = - 'https://api.maptiler.com/maps/outdoor-v2/style.json'; - - // Very quiet 2D raster style for a Strava-like clean base map. + // Runtime key — populated by init() below. + static String _mapTilerKey = ''; + + // ponytail: --dart-define takes precedence so CI/CD never needs the asset file. + static const String _compiledKey = String.fromEnvironment('MAPTILER_API_KEY'); + static const String _compiledStyleUrl = String.fromEnvironment('MAP_STYLE_URL'); + + /// Call once during app bootstrap (before runApp). + /// Loads keys from .env.local.json; --dart-define values override the file. + static Future init() async { + if (_compiledKey.isNotEmpty) { + _mapTilerKey = _compiledKey; + return; + } + try { + final raw = await rootBundle.loadString('.env.local.json'); + final map = jsonDecode(raw) as Map; + _mapTilerKey = (map['MAPTILER_API_KEY'] as String? ?? '').trim(); + } catch (_) { + // File missing or malformed — tiles fall back to open sources. + } + } + + static const String _mapTilerStreetsStyleUrl = + 'https://api.maptiler.com/maps/streets-v2/style.json'; + static const String _mapTilerOutdoorStyleUrl = + 'https://api.maptiler.com/maps/outdoor-v2/style.json'; + static const String _clean2dRasterStyleJson = ''' { "version": 8, @@ -52,7 +62,6 @@ class MapConfig { } '''; - // Terrain style fallback (topographic tiles), useful for slope readability. static const String _terrainRasterStyleJson = ''' { "version": 8, @@ -81,15 +90,12 @@ class MapConfig { '''; static String styleForMode(MapVisualStyle style) { - final hasStyleOverride = _styleUrl.trim().isNotEmpty; - - // Respect explicit MAP_STYLE_URL override for clean mode, while still - // allowing terrain toggle to switch maps. - if (style == MapVisualStyle.clean2d && hasStyleOverride) { - return _styleUrl; + final styleUrl = _compiledStyleUrl.trim(); + if (style == MapVisualStyle.clean2d && styleUrl.isNotEmpty) { + return styleUrl; } - if (_mapTilerKey.trim().isNotEmpty) { + if (_mapTilerKey.isNotEmpty) { switch (style) { case MapVisualStyle.clean2d: return '$_mapTilerStreetsStyleUrl?key=$_mapTilerKey'; @@ -106,11 +112,9 @@ class MapConfig { } } - static String get defaultStyleString { - return styleForMode(MapVisualStyle.clean2d); - } + static String get defaultStyleString => styleForMode(MapVisualStyle.clean2d); - static bool get hasMapTilerApiKey => _mapTilerKey.trim().isNotEmpty; + static bool get hasMapTilerApiKey => _mapTilerKey.isNotEmpty; static bool isUsingMapTiler(MapVisualStyle style) { return styleForMode(style).contains('api.maptiler.com/maps/'); @@ -118,27 +122,16 @@ class MapConfig { static String resolvedSourceLabel(MapVisualStyle style) { final resolved = styleForMode(style); - if (resolved.contains('api.maptiler.com/maps/outdoor')) { - return 'MapTiler Outdoor'; - } - if (resolved.contains('api.maptiler.com/maps/streets')) { - return 'MapTiler Streets'; - } - if (resolved.contains('tile.opentopomap.org')) { - return 'OpenTopoMap fallback'; - } - if (resolved.contains('basemaps.cartocdn.com')) { - return 'CARTO fallback'; - } - if (resolved.contains('tiles.openfreemap.org')) { - return 'OpenFreeMap fallback'; - } + if (resolved.contains('api.maptiler.com/maps/outdoor')) return 'MapTiler Outdoor'; + if (resolved.contains('api.maptiler.com/maps/streets')) return 'MapTiler Streets'; + if (resolved.contains('tile.opentopomap.org')) return 'OpenTopoMap fallback'; + if (resolved.contains('basemaps.cartocdn.com')) return 'CARTO fallback'; + if (resolved.contains('tiles.openfreemap.org')) return 'OpenFreeMap fallback'; return 'Custom style'; } - static String get emergencyFallbackStyleJson { - return 'https://demotiles.maplibre.org/style.json'; - } + static String get emergencyFallbackStyleJson => + 'https://demotiles.maplibre.org/style.json'; static bool get isConfigured => true; } diff --git a/frontend/lib/services/profile_activities_service.dart b/frontend/lib/services/profile_activities_service.dart deleted file mode 100644 index b0c1fcb3..00000000 --- a/frontend/lib/services/profile_activities_service.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:syntrak/models/activity.dart'; - -/// Local/mock activity list for the profile Activities tab until backend wiring is complete. -class ProfileActivitiesService { - Future> getUserActivities({ - String? searchQuery, - ActivityType? typeFilter, - DateTime? dateFrom, - DateTime? dateTo, - }) async { - return _mockActivities(); - } - - Future toggleKudos(String activityId) async {} - - Future shareActivity(String activityId) async {} - - Future addComment(String activityId, String comment) async {} - - List _mockActivities() { - final activityDate = DateTime(2025, 1, 27, 21, 30); - final now = DateTime.now(); - - return [ - Activity( - id: '1', - userId: 'user1', - type: ActivityType.alpine, - name: 'Night Hike', - distance: 1390, - duration: 773, - elevationGain: 10, - startTime: activityDate, - endTime: activityDate.add(const Duration(minutes: 12, seconds: 53)), - averagePace: 556, - maxPace: 500, - isPublic: true, - createdAt: activityDate, - locations: [], - ), - Activity( - id: '2', - userId: 'user1', - type: ActivityType.alpine, - name: 'Morning Alpine Run', - distance: 12500, - duration: 3600, - elevationGain: 850, - startTime: now.subtract(const Duration(days: 2, hours: 2)), - endTime: now.subtract(const Duration(days: 2, hours: 1)), - averagePace: 288, - maxPace: 240, - isPublic: true, - createdAt: now.subtract(const Duration(days: 2)), - locations: [], - ), - Activity( - id: '3', - userId: 'user1', - type: ActivityType.backcountry, - name: 'Backcountry Adventure', - distance: 18500, - duration: 7200, - elevationGain: 1200, - startTime: now.subtract(const Duration(days: 5, hours: 3)), - endTime: now.subtract(const Duration(days: 5, hours: 1)), - averagePace: 389, - maxPace: 320, - isPublic: true, - createdAt: now.subtract(const Duration(days: 5)), - locations: [], - ), - ]; - } -} diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index fc21eee7..6dbfdd84 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -71,3 +71,4 @@ flutter: assets: - assets/images/ - assets/logos/ + - .env.local.json