From 22e0ab3ca5d86c5fd34ab7d7f0af0e8174fab66d Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 21:50:45 +0800 Subject: [PATCH 01/13] feat(map-backend): add route thumbnail generation engine Renders a PNG of the GPS route via Pillow, uploads to Supabase storage, and returns the public URL. Adds DB migration for thumbnail_url column and wires the /activities/thumbnail endpoint. Co-Authored-By: Claude Sonnet 4.6 --- .../004_activities_thumbnail_url.sql | 3 + backend/map-backend/config.py | 1 + .../domains/activities_service/api.py | 43 +++++++++++- backend/map-backend/engine/thumbnail.py | 68 +++++++++++++++++++ .../map-backend/services/supabase_client.py | 11 ++- backend/shared/track_pipeline_schemas.py | 1 + 6 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 backend/db/migrations/004_activities_thumbnail_url.sql create mode 100644 backend/map-backend/engine/thumbnail.py 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..b4e6dcfd 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 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): From fbeb57d71299f1702d3f4a5e3299a898c6b6ea40 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 21:50:50 +0800 Subject: [PATCH 02/13] feat(activity-backend): wire thumbnail generation on activity create Calls map-backend /thumbnail as a non-blocking background task after save, and propagates thumbnail_url through pipeline processor and Supabase client. Co-Authored-By: Claude Sonnet 4.6 --- backend/activity-backend/models.py | 1 + .../routes/activities_management_routes.py | 19 ++++++++++++++++++- .../routes/activity_transformers.py | 1 + .../services/map_backend_client.py | 14 ++++++++++++++ .../services/pipeline_processor.py | 2 ++ .../services/supabase_client.py | 3 +++ 6 files changed, 39 insertions(+), 1 deletion(-) 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 From 2bacbe1c930d73cc6c11aa85339e6165b536dd92 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 21:50:56 +0800 Subject: [PATCH 03/13] feat(frontend): load MapTiler key at runtime from .env.local.json Replaces compile-time String.fromEnvironment with async init() that reads from a bundled asset file, so plain flutter run works without --dart-define flags. CI/CD can still override via --dart-define. Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/core/di/service_locator.dart | 3 + frontend/lib/services/map_config.dart | 93 +++++++++++------------ frontend/pubspec.yaml | 1 + 3 files changed, 47 insertions(+), 50 deletions(-) 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/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/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 From 34cd0a35543d4b79595089845f48301749c3693e Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 21:51:03 +0800 Subject: [PATCH 04/13] fix(frontend): trailing slash on activities API, map timeout overlay, remove mock service Adds trailing slash to POST/GET /activities/ to match FastAPI routes. Adds 10s timeout error overlay to RecordMapView. Removes mock ProfileActivitiesService in favour of the real backend repository. Co-Authored-By: Claude Sonnet 4.6 --- .../data/activities_repository.dart | 14 ++ frontend/lib/screens/maps/maps_screen.dart | 61 +++++- .../lib/screens/profile/activities_tab.dart | 17 +- .../lib/screens/record/record_map_view.dart | 124 +++++++++-- .../lib/screens/record/record_screen.dart | 199 ++++++++++++++++-- .../lib/services/apis/activities_api.dart | 29 ++- .../lib/services/apis/map_activities_api.dart | 16 +- .../services/profile_activities_service.dart | 75 ------- 8 files changed, 411 insertions(+), 124 deletions(-) delete mode 100644 frontend/lib/services/profile_activities_service.dart diff --git a/frontend/lib/features/activities/data/activities_repository.dart b/frontend/lib/features/activities/data/activities_repository.dart index 4d55dca6..0ea99f88 100644 --- a/frontend/lib/features/activities/data/activities_repository.dart +++ b/frontend/lib/features/activities/data/activities_repository.dart @@ -35,4 +35,18 @@ class ActivitiesRepository { Future deleteActivity(String id) { return _api.deleteActivity(id); } + + Future> getMyActivities({ + String? search, + String? activityType, + String? startDate, + String? endDate, + }) { + return _api.getMyActivities( + search: search, + activityType: activityType, + startDate: startDate, + endDate: endDate, + ); + } } 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..b975d218 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(); @@ -326,6 +332,11 @@ class _RecordScreenState extends State { final locations = _locationService.locations; if (locations.isEmpty) return; + // Capture raw stats before the API call — used for the processing overlay. + final rawDistance = _locationService.calculateDistance(); + final rawElevationGain = _locationService.calculateElevationGain(); + final rawDuration = _elapsedNotifier.value; + final activityProvider = Provider.of(context, listen: false); final auth = Provider.of(context, listen: false); @@ -334,9 +345,9 @@ class _RecordScreenState extends State { id: '', userId: auth.user?.id ?? '', 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 +359,60 @@ 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); + while (mounted) { + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + final activity = await activityProvider.getActivity(activityId); + if (activity != null && !activity.isPipelinePending) { + if (!mounted) return; + setState(() => _isProcessing = false); + _navigateToDetail(activityId); + return; + } + } + } + + void _navigateToDetail(String activityId) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => ActivityDetailScreen(activityId: activityId), + ), + ); } void _resetState() { @@ -507,12 +555,129 @@ 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, + ), + ), ], ), ); } } +// ─── Processing overlay ─────────────────────────────────────────────────────── + +class _ProcessingOverlay extends StatelessWidget { + const _ProcessingOverlay({ + required this.distance, + required this.elevationGain, + required this.duration, + }); + + final double distance; + final double elevationGain; + final Duration duration; + + 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: Colors.white, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(SyntrakColors.primary), + strokeWidth: 3, + ), + const SizedBox(height: 32), + Text( + 'Processing your run…', + style: SyntrakTypography.headlineMedium.copyWith( + color: SyntrakColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Matching trails and correcting elevation', + style: SyntrakTypography.bodyMedium.copyWith( + color: SyntrakColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _StatItem(label: 'Distance', value: _formattedDistance), + _StatItem( + label: 'Elevation', + value: '${elevationGain.toStringAsFixed(0)} m', + ), + _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, + children: [ + Text( + value, + style: SyntrakTypography.headlineMedium.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: SyntrakTypography.bodyMedium.copyWith( + color: SyntrakColors.textSecondary, + ), + ), + ], + ); + } +} + // ─── 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/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: [], - ), - ]; - } -} From 5fd72aebee7846f637c8a4c2f5de8789e83e8748 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:04:45 +0800 Subject: [PATCH 05/13] feat(frontend): Strava-style save dialog and fix post-delete navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace simple "Save?" confirmation sheet with a rich summary bottom sheet: checkmark badge, editable activity name (auto-seeded from time-of-day + type), stats row (distance / time / elevation), full-width Save Activity button - Refine processing overlay: off-white background, bordered stats card, activity-type-aware subtitle - Thread activity name through to Activity.name on save - Compute raw stats once in _stopRecording, share between dialog and save - Fix post-delete navigation: pushReplacement → push so deleting an activity from the detail screen returns to the idle RecordScreen instead of the home tab Co-Authored-By: Claude Sonnet 4.6 --- .../lib/screens/record/record_screen.dart | 342 +++++++++++++----- 1 file changed, 251 insertions(+), 91 deletions(-) diff --git a/frontend/lib/screens/record/record_screen.dart b/frontend/lib/screens/record/record_screen.dart index b975d218..0966688b 100644 --- a/frontend/lib/screens/record/record_screen.dart +++ b/frontend/lib/screens/record/record_screen.dart @@ -238,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; - if (shouldSave == true && mounted) { - await _saveActivity(); + final activityName = + await _showSaveDialog(rawDistance, rawElevationGain, rawDuration); + + 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: _SaveStatCell( + value: fmtDist(distance), label: 'Distance')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), + Expanded( + child: _SaveStatCell( + value: fmtDur(duration), label: 'Time')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), + Expanded( + child: _SaveStatCell( + 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( @@ -321,22 +414,18 @@ 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; - // Capture raw stats before the API call — used for the processing overlay. - final rawDistance = _locationService.calculateDistance(); - final rawElevationGain = _locationService.calculateElevationGain(); - final rawDuration = _elapsedNotifier.value; - final activityProvider = Provider.of(context, listen: false); final auth = Provider.of(context, listen: false); @@ -344,6 +433,7 @@ class _RecordScreenState extends State { final activity = Activity( id: '', userId: auth.user?.id ?? '', + name: name, type: _selectedActivityType!, distance: rawDistance, duration: rawDuration.inSeconds, @@ -407,7 +497,7 @@ class _RecordScreenState extends State { } void _navigateToDetail(String activityId) { - Navigator.pushReplacement( + Navigator.push( context, MaterialPageRoute( builder: (_) => ActivityDetailScreen(activityId: activityId), @@ -563,6 +653,7 @@ class _RecordScreenState extends State { distance: _rawDistance, elevationGain: _rawElevationGain, duration: _rawDuration, + activityType: _selectedActivityType, ), ), ], @@ -578,11 +669,13 @@ class _ProcessingOverlay extends StatelessWidget { 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' @@ -599,45 +692,74 @@ class _ProcessingOverlay extends StatelessWidget { Widget build(BuildContext context) { return AbsorbPointer( child: Container( - color: Colors.white, + color: SyntrakColors.background, child: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(SyntrakColors.primary), - strokeWidth: 3, + SizedBox( + width: 56, + height: 56, + child: CircularProgressIndicator( + valueColor: + AlwaysStoppedAnimation(SyntrakColors.primary), + strokeWidth: 3, + ), ), const SizedBox(height: 32), Text( - 'Processing your run…', + 'Saving Activity', style: SyntrakTypography.headlineMedium.copyWith( color: SyntrakColors.textPrimary, ), textAlign: TextAlign.center, ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( - 'Matching trails and correcting elevation', + 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), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _StatItem(label: 'Distance', value: _formattedDistance), - _StatItem( - label: 'Elevation', - value: '${elevationGain.toStringAsFixed(0)} m', + 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)), + ], ), - _StatItem(label: 'Time', value: _formattedDuration), - ], + ), ), ], ), @@ -658,10 +780,46 @@ class _StatItem extends StatelessWidget { 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, + ), + ), + ], + ); + } +} + +class _SaveStatCell extends StatelessWidget { + const _SaveStatCell({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, - style: SyntrakTypography.headlineMedium.copyWith( + textAlign: TextAlign.center, + style: SyntrakTypography.headlineSmall.copyWith( color: SyntrakColors.textPrimary, fontWeight: FontWeight.bold, ), @@ -669,8 +827,10 @@ class _StatItem extends StatelessWidget { const SizedBox(height: 4), Text( label, - style: SyntrakTypography.bodyMedium.copyWith( - color: SyntrakColors.textSecondary, + textAlign: TextAlign.center, + style: SyntrakTypography.labelSmall.copyWith( + color: SyntrakColors.textTertiary, + letterSpacing: 0.5, ), ), ], From 934f4f689acc8c4b319466660676bccd38aa373c Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:08:28 +0800 Subject: [PATCH 06/13] feat(frontend): redesign activity detail screen and fix pace formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full visual overhaul of ActivityDetailScreen to match app color system: - AppBar: white surface, title = activity name (fallback to type), subtitle = formatted date, back arrow, delete icon in textTertiary - Map style toggle: primary blue selected state (was hardcoded orange) - Color mode chips: animated pill buttons using SyntrakColors.primary - Stats row: single bordered card with Distance / Time / Elevation / Avg Speed — no icons, clean label-under-value layout - Details section: icon+label+value tiles in a bordered card - Delete confirmation: bottom sheet matching the save dialog style - Fix formattedPace: averagePace % 60 on a double produced full float precision string; now rounds to int before formatting - Remove unused _descentSegments field Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/models/activity.dart | 5 +- .../activities/activity_detail_screen.dart | 762 +++++++++--------- 2 files changed, 402 insertions(+), 365 deletions(-) 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..4b0b9ade 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,7 +19,6 @@ 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; @@ -29,13 +30,11 @@ 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; @@ -54,7 +53,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 +68,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 +94,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, - ), - ); + points.add(TrackPoint( + lat: current.latitude, + lon: current.longitude, + elevationM: current.altitude ?? 0, + timestamp: current.timestamp.toUtc(), + speedKmh: _speedKmh(current, previous), + )); } - return ProcessedTrack( id: activity.id, points: points, @@ -130,21 +114,17 @@ 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, - ); - } + Segment _fallbackSegment(List points) => 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 []; - } + if (points.length < 2) return const []; return [_fallbackSegment(points)]; } @@ -161,24 +141,13 @@ class _ActivityDetailScreenState extends State { 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; - } - + 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, + previous.latitude, previous.longitude, + current.latitude, current.longitude, ); return (distanceMeters / deltaSeconds) * 3.6; } @@ -187,11 +156,9 @@ class _ActivityDetailScreenState extends State { const earthRadiusM = 6371000.0; final dLat = _degToRad(lat2 - lat1); final dLon = _degToRad(lon2 - lon1); - final a = - (sin(dLat / 2) * sin(dLat / 2)) + + 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; + return earthRadiusM * 2 * atan2(sqrt(a), sqrt(1 - a)); } double _degToRad(double value) => value * (pi / 180); @@ -199,10 +166,7 @@ class _ActivityDetailScreenState extends State { Future _initialiseMapIfReady() async { final controller = _mapController; final track = _track; - if (!_mapReady || controller == null || track == null || _segments.isEmpty) { - return; - } - + if (!_mapReady || controller == null || track == null || _segments.isEmpty) return; await _mapRenderingEngine!.initialise( controller, track: track, @@ -214,80 +178,177 @@ class _ActivityDetailScreenState extends State { Future _onColorModeSelected(MapColorMode mode) async { if (_selectedColorMode == mode) return; - setState(() { _selectedColorMode = mode; }); + setState(() => _selectedColorMode = mode); unawaited(_mapRenderingEngine!.setColorMode(mode)); } - Future _zoomIn() async { - if (_mapController == null) { - return; - } - await _mapController!.animateCamera(CameraUpdate.zoomIn()); - } + Future _zoomIn() async => + _mapController?.animateCamera(CameraUpdate.zoomIn()); - Future _zoomOut() async { - if (_mapController == null) { - return; - } - await _mapController!.animateCamera(CameraUpdate.zoomOut()); - } + 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: 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)), + ), + const SizedBox(height: 24), + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: SyntrakColors.error.withValues(alpha: 0.1), + 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.textTertiary)), + ), + ], + ), ), ); + + 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: AppBar(backgroundColor: SyntrakColors.surface), + 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: AppBar( + backgroundColor: SyntrakColors.surface, + title: const Text('Activity'), + ), + 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; + final title = activity.name?.isNotEmpty == true + ? activity.name! + : activity.type.displayName; + final dateStr = + DateFormat('MMM d, y · h:mm a').format(activity.startTime); + return Scaffold( + backgroundColor: SyntrakColors.background, appBar: AppBar( - title: Text(activity.type.displayName), + backgroundColor: SyntrakColors.surface, + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, + style: SyntrakTypography.headlineSmall + .copyWith(color: SyntrakColors.textPrimary)), + Text(dateStr, + style: SyntrakTypography.bodySmall + .copyWith(color: SyntrakColors.textSecondary)), + ], + ), actions: [ IconButton( - icon: const Icon(Icons.delete), - onPressed: () => _showDeleteDialog(context, activity), + icon: const Icon(Icons.delete_outline_rounded, size: 22), + color: SyntrakColors.textTertiary, + onPressed: () => _confirmDelete(activity), + tooltip: 'Delete activity', ), ], ), @@ -295,223 +356,195 @@ class _ActivityDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // ── Map ────────────────────────────────────────────────── SizedBox( - height: 300, + height: 260, 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), ), ], ), ), + // ── Color mode chips ───────────────────────────────────── if (hasRenderableTrack) Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: _ColorModeBar( selected: _selectedColorMode, onSelected: _onColorModeSelected, ), - ), + ) + else + const SizedBox(height: 16), - // Metrics + // ── Stats card ─────────────────────────────────────────── 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( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: SyntrakColors.divider), + ), + child: IntrinsicHeight( + child: Row( children: [ Expanded( - child: _MetricCard( - label: 'Distance', - value: activity.formattedDistance, - icon: Icons.straighten, - ), - ), - const SizedBox(width: 8), + child: _StatCell( + value: activity.formattedDistance, + label: 'Distance')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), Expanded( - child: _MetricCard( - label: 'Duration', - value: activity.formattedDuration, - icon: Icons.timer, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ + child: _StatCell( + value: activity.formattedDuration, + label: 'Time')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), Expanded( - child: _MetricCard( - label: 'Pace', - value: activity.formattedPace, - icon: Icons.speed, - ), - ), - const SizedBox(width: 8), + child: _StatCell( + value: + '+${activity.elevationGain.toStringAsFixed(0)} m', + label: 'Elevation')), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.divider), Expanded( - child: _MetricCard( - label: 'Elevation', - value: '${activity.elevationGain.toStringAsFixed(0)} m', - icon: Icons.terrain, - ), - ), + child: _StatCell( + value: activity.formattedSpeed, + label: 'Avg Speed')), ], ), - 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!, - ), - ], + ), ), ), - ], - ), - ), - ); - } - 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, + // ── Details ────────────────────────────────────────────── + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: SyntrakColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: SyntrakColors.divider), + ), + 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, color: SyntrakColors.divider), + _DetailTile( + icon: Icons.flag_outlined, + label: 'End', + value: DateFormat('MMM d, y · h:mm a') + .format(activity.endTime)), + if (activity.name?.isNotEmpty == true) ...[ + Divider(height: 1, color: SyntrakColors.divider), + _DetailTile( + icon: Icons.label_outline_rounded, + label: 'Name', + value: activity.name!), + ], + if (activity.description?.isNotEmpty == true) ...[ + Divider(height: 1, color: SyntrakColors.divider), + _DetailTile( + icon: Icons.notes_rounded, + label: 'Note', + value: activity.description!), + ], + ], + ), + ), ), - child: const Text('Delete'), - ), - ], + + const SizedBox(height: 32), + ], + ), ), ); - - if (confirmed == true && context.mounted) { - final provider = Provider.of(context, listen: false); - await provider.deleteActivity(activity.id); - if (context.mounted) { - Navigator.of(context).pop(); - } - } } } +// ─── Map controls ───────────────────────────────────────────────────────────── + class _MapStyleToggle extends StatelessWidget { - const _MapStyleToggle({ - required this.selectedStyle, - required this.onSelected, - }); + 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) { return DecoratedBox( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + 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: 10, + 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), - ), + _StyleBtn( + label: '2D', + selected: selectedStyle == MapVisualStyle.clean2d, + onTap: () => onSelected(MapVisualStyle.clean2d)), + _StyleBtn( + label: 'Terrain', + selected: selectedStyle == MapVisualStyle.terrain, + onTap: () => onSelected(MapVisualStyle.terrain)), ], ), ); } } -class _MapStyleButton extends StatelessWidget { - const _MapStyleButton({ - required this.label, - required this.selected, - required this.onTap, - }); +class _StyleBtn extends StatelessWidget { + const _StyleBtn( + {required this.label, required this.selected, required this.onTap}); final String label; final bool selected; @@ -520,18 +553,20 @@ class _MapStyleButton extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: selected ? const Color(0xFFFF5A1F) : Colors.white, - borderRadius: BorderRadius.circular(12), + color: + selected ? SyntrakColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(10), child: InkWell( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text( label, style: TextStyle( - color: selected ? Colors.white : Colors.black87, + color: selected ? Colors.white : SyntrakColors.textSecondary, fontWeight: FontWeight.w600, + fontSize: 13, ), ), ), @@ -541,10 +576,8 @@ class _MapStyleButton extends StatelessWidget { } 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; @@ -554,13 +587,12 @@ class _MapZoomControls extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + 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: 10, + offset: const Offset(0, 2)) ], ), child: Column( @@ -568,20 +600,15 @@ 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, ), + Container(width: 24, height: 1, color: SyntrakColors.divider), IconButton( visualDensity: VisualDensity.compact, - icon: const Icon(Icons.remove), + icon: Icon(Icons.remove, + color: SyntrakColors.textPrimary, size: 20), onPressed: () => onZoomOut(), - tooltip: 'Zoom out', ), ], ), @@ -589,141 +616,151 @@ class _MapZoomControls extends StatelessWidget { } } +// ─── Color mode chips ───────────────────────────────────────────────────────── + class _ColorModeBar extends StatelessWidget { - const _ColorModeBar({ - required this.selected, - required this.onSelected, - }); + const _ColorModeBar({required this.selected, required this.onSelected}); final MapColorMode selected; - final Future Function(MapColorMode mode) onSelected; + final Future Function(MapColorMode) 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, - ), - ], + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _ModeChip( + mode: MapColorMode.segment, + label: 'Segment', + selected: selected == MapColorMode.segment, + onTap: () => onSelected(MapColorMode.segment)), + const SizedBox(width: 8), + _ModeChip( + mode: MapColorMode.speed, + label: 'Speed', + selected: selected == MapColorMode.speed, + onTap: () => onSelected(MapColorMode.speed)), + const SizedBox(width: 8), + _ModeChip( + mode: MapColorMode.elevation, + label: 'Elevation', + selected: selected == MapColorMode.elevation, + onTap: () => onSelected(MapColorMode.elevation)), + ], + ), ); } } -class _ColorModeChip extends StatelessWidget { - const _ColorModeChip({ - required this.mode, - required this.label, - required this.selected, - required this.onSelected, - }); +class _ModeChip extends StatelessWidget { + const _ModeChip( + {required this.mode, + required this.label, + required this.selected, + required this.onTap}); final MapColorMode mode; final String label; final bool selected; - final Future Function(MapColorMode mode) onSelected; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return ChoiceChip( - selected: selected, - label: Text(label), - onSelected: (value) { - if (value) { - unawaited(onSelected(mode)); - } - }, + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: selected + ? SyntrakColors.primary + : SyntrakColors.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected + ? SyntrakColors.primary + : SyntrakColors.divider), + ), + child: Text( + label, + style: SyntrakTypography.labelMedium.copyWith( + color: selected ? Colors.white : SyntrakColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), ); } } -class _MetricCard extends StatelessWidget { - final String label; - final String value; - final IconData icon; +// ─── Stats ──────────────────────────────────────────────────────────────────── - const _MetricCard({ - required this.label, - required this.value, - required this.icon, - }); +class _StatCell extends StatelessWidget { + const _StatCell({required this.value, required this.label}); + + final String value; + final String label; @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(vertical: 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + textAlign: TextAlign.center, + style: SyntrakTypography.headlineSmall.copyWith( + color: SyntrakColors.textPrimary, + fontWeight: FontWeight.bold, ), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: Colors.grey, - ), + ), + const SizedBox(height: 4), + Text( + label, + textAlign: TextAlign.center, + style: SyntrakTypography.labelSmall.copyWith( + color: SyntrakColors.textTertiary, ), - ], - ), + ), + ], ), ); } } -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.textTertiary), + const SizedBox(width: 12), SizedBox( - width: 100, - child: Text( - label, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), + width: 52, + child: Text(label, + style: SyntrakTypography.bodySmall + .copyWith(color: SyntrakColors.textTertiary)), ), Expanded( child: Text( value, - style: const TextStyle( - fontSize: 14, - ), + style: SyntrakTypography.bodyMedium + .copyWith(color: SyntrakColors.textPrimary), ), ), ], @@ -731,4 +768,3 @@ class _DetailRow extends StatelessWidget { ); } } - From 431001c2d89ef9869e771fa17112abd685fa455e Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:15:36 +0800 Subject: [PATCH 07/13] feat(frontend): Strava-style dark activity detail screen Full layout overhaul following Strava design language with SyntrakColors: - Dark background throughout (darkBackground / darkSurface) - AppBar: sport type as title, more button opens delete confirmation - Activity name as large hero text in body, date subtitle below - Map full-width at 240px; controls styled dark - Stats in 2x2 dark card, label-above-value, left-aligned - Color mode chips as animated dark pill buttons - Details card with icon+label+value rows - Delete bottom sheet uses dark surface Co-Authored-By: Claude Sonnet 4.6 --- .../activities/activity_detail_screen.dart | 427 +++++++++--------- 1 file changed, 208 insertions(+), 219 deletions(-) diff --git a/frontend/lib/screens/activities/activity_detail_screen.dart b/frontend/lib/screens/activities/activity_detail_screen.dart index 4b0b9ade..6c7d6cd7 100644 --- a/frontend/lib/screens/activities/activity_detail_screen.dart +++ b/frontend/lib/screens/activities/activity_detail_screen.dart @@ -22,7 +22,6 @@ import 'package:syntrak/services/map_config.dart'; class ActivityDetailScreen extends StatefulWidget { final String activityId; - const ActivityDetailScreen({super.key, required this.activityId}); @override @@ -96,14 +95,14 @@ class _ActivityDetailScreenState extends State { ..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 cur = sorted[i]; + final prev = i > 0 ? sorted[i - 1] : null; points.add(TrackPoint( - lat: current.latitude, - lon: current.longitude, - elevationM: current.altitude ?? 0, - timestamp: current.timestamp.toUtc(), - speedKmh: _speedKmh(current, previous), + lat: cur.latitude, + lon: cur.longitude, + elevationM: cur.altitude ?? 0, + timestamp: cur.timestamp.toUtc(), + speedKmh: _speedKmh(cur, prev), )); } return ProcessedTrack( @@ -114,66 +113,53 @@ class _ActivityDetailScreenState extends State { ); } - Segment _fallbackSegment(List points) => Segment( + List _localFallbackSegments(List 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, - ); - - List _localFallbackSegments(List points) { - if (points.length < 2) return const []; - return [_fallbackSegment(points)]; + ) + ]; } 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)); - return earthRadiusM * 2 * atan2(sqrt(a), sqrt(1 - a)); + 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); + final c = _mapController; + final t = _track; + if (!_mapReady || c == null || t == null || _segments.isEmpty) return; + await _mapRenderingEngine!.initialise(c, + track: t, segments: _segments, initialColorMode: _selectedColorMode); + await _mapRenderingEngine!.fitToTrack(t); } Future _onColorModeSelected(MapColorMode mode) async { @@ -184,7 +170,6 @@ class _ActivityDetailScreenState extends State { Future _zoomIn() async => _mapController?.animateCamera(CameraUpdate.zoomIn()); - Future _zoomOut() async => _mapController?.animateCamera(CameraUpdate.zoomOut()); @@ -202,9 +187,9 @@ class _ActivityDetailScreenState extends State { context: context, backgroundColor: Colors.transparent, builder: (_) => Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + decoration: BoxDecoration( + color: SyntrakColors.darkSurface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), padding: EdgeInsets.fromLTRB( 24, 12, 24, MediaQuery.of(context).padding.bottom + 24), @@ -215,15 +200,16 @@ class _ActivityDetailScreenState extends State { width: 36, height: 4, decoration: BoxDecoration( - color: Colors.black12, - borderRadius: BorderRadius.circular(2)), + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), ), const SizedBox(height: 24), Container( width: 56, height: 56, decoration: BoxDecoration( - color: SyntrakColors.error.withValues(alpha: 0.1), + color: SyntrakColors.error.withValues(alpha: 0.15), shape: BoxShape.circle, ), child: Icon(Icons.delete_outline_rounded, @@ -232,11 +218,11 @@ class _ActivityDetailScreenState extends State { const SizedBox(height: 16), Text('Delete Activity?', style: SyntrakTypography.headlineMedium - .copyWith(color: SyntrakColors.textPrimary)), + .copyWith(color: SyntrakColors.darkTextPrimary)), const SizedBox(height: 8), Text('This cannot be undone.', style: SyntrakTypography.bodyMedium - .copyWith(color: SyntrakColors.textSecondary)), + .copyWith(color: SyntrakColors.darkTextSecondary)), const SizedBox(height: 28), SizedBox( width: double.infinity, @@ -260,7 +246,7 @@ class _ActivityDetailScreenState extends State { onPressed: () => Navigator.pop(context, false), child: Text('Cancel', style: SyntrakTypography.bodyMedium - .copyWith(color: SyntrakColors.textTertiary)), + .copyWith(color: SyntrakColors.darkTextSecondary)), ), ], ), @@ -278,12 +264,11 @@ class _ActivityDetailScreenState extends State { Widget build(BuildContext context) { if (_isLoading) { return Scaffold( - backgroundColor: SyntrakColors.background, - appBar: AppBar(backgroundColor: SyntrakColors.surface), + backgroundColor: SyntrakColors.darkBackground, + appBar: _buildAppBar(null), body: Center( child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(SyntrakColors.primary), + valueColor: AlwaysStoppedAnimation(SyntrakColors.primary), ), ), ); @@ -291,15 +276,12 @@ class _ActivityDetailScreenState extends State { if (_activity == null) { return Scaffold( - backgroundColor: SyntrakColors.background, - appBar: AppBar( - backgroundColor: SyntrakColors.surface, - title: const Text('Activity'), - ), + backgroundColor: SyntrakColors.darkBackground, + appBar: _buildAppBar(null), body: Center( child: Text('Activity not found', style: SyntrakTypography.bodyLarge - .copyWith(color: SyntrakColors.textSecondary)), + .copyWith(color: SyntrakColors.darkTextSecondary)), ), ); } @@ -314,51 +296,44 @@ class _ActivityDetailScreenState extends State { : const LatLng(46.8, 8.2)); final hasRenderableTrack = track != null && track.points.length > 1; - final title = activity.name?.isNotEmpty == true - ? activity.name! - : activity.type.displayName; - final dateStr = - DateFormat('MMM d, y · h:mm a').format(activity.startTime); - return Scaffold( - backgroundColor: SyntrakColors.background, - appBar: AppBar( - backgroundColor: SyntrakColors.surface, - 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(title, - style: SyntrakTypography.headlineSmall - .copyWith(color: SyntrakColors.textPrimary)), - Text(dateStr, - style: SyntrakTypography.bodySmall - .copyWith(color: SyntrakColors.textSecondary)), - ], - ), - actions: [ - IconButton( - icon: const Icon(Icons.delete_outline_rounded, size: 22), - color: SyntrakColors.textTertiary, - onPressed: () => _confirmDelete(activity), - tooltip: 'Delete activity', - ), - ], - ), + backgroundColor: SyntrakColors.darkBackground, + appBar: _buildAppBar(activity), body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ── Map ────────────────────────────────────────────────── + // ── 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.darkTextPrimary, + 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.darkTextSecondary, + ), + ), + ], + ), + ), + + // ── Map ───────────────────────────────────────────────── SizedBox( - height: 260, + height: 240, child: Stack( children: [ MapLibreMap( @@ -397,71 +372,79 @@ class _ActivityDetailScreenState extends State { if (hasRenderableTrack) Padding( padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: _ColorModeBar( selected: _selectedColorMode, onSelected: _onColorModeSelected, ), ) else - const SizedBox(height: 16), + const SizedBox(height: 20), - // ── Stats card ─────────────────────────────────────────── + // ── Stats grid ─────────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( decoration: BoxDecoration( - color: SyntrakColors.surface, + color: SyntrakColors.darkSurface, borderRadius: BorderRadius.circular(16), - border: Border.all(color: SyntrakColors.divider), ), - child: IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: _StatCell( - value: activity.formattedDistance, - label: 'Distance')), - VerticalDivider( - width: 1, - thickness: 1, - color: SyntrakColors.divider), - Expanded( - child: _StatCell( - value: activity.formattedDuration, - label: 'Time')), - VerticalDivider( - width: 1, - thickness: 1, - color: SyntrakColors.divider), - Expanded( - child: _StatCell( - value: - '+${activity.elevationGain.toStringAsFixed(0)} m', - label: 'Elevation')), - VerticalDivider( - width: 1, - thickness: 1, - color: SyntrakColors.divider), - Expanded( - child: _StatCell( - value: activity.formattedSpeed, - label: 'Avg Speed')), - ], - ), + child: Column( + children: [ + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _StatCell( + label: 'Distance', + value: activity.formattedDistance)), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.darkSurfaceVariant), + Expanded( + child: _StatCell( + label: 'Avg Speed', + value: activity.formattedSpeed)), + ], + ), + ), + Divider( + height: 1, + thickness: 1, + color: SyntrakColors.darkSurfaceVariant), + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _StatCell( + label: 'Moving Time', + value: activity.formattedDuration)), + VerticalDivider( + width: 1, + thickness: 1, + color: SyntrakColors.darkSurfaceVariant), + Expanded( + child: _StatCell( + label: 'Elevation Gain', + value: + '+${activity.elevationGain.toStringAsFixed(0)} m')), + ], + ), + ), + ], ), ), ), // ── Details ────────────────────────────────────────────── - const SizedBox(height: 20), + const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( decoration: BoxDecoration( - color: SyntrakColors.surface, + color: SyntrakColors.darkSurface, borderRadius: BorderRadius.circular(16), - border: Border.all(color: SyntrakColors.divider), ), child: Column( children: [ @@ -470,26 +453,15 @@ class _ActivityDetailScreenState extends State { label: 'Start', value: DateFormat('MMM d, y · h:mm a') .format(activity.startTime)), - Divider(height: 1, color: SyntrakColors.divider), + Divider( + height: 1, + thickness: 1, + color: SyntrakColors.darkSurfaceVariant), _DetailTile( icon: Icons.flag_outlined, label: 'End', value: DateFormat('MMM d, y · h:mm a') .format(activity.endTime)), - if (activity.name?.isNotEmpty == true) ...[ - Divider(height: 1, color: SyntrakColors.divider), - _DetailTile( - icon: Icons.label_outline_rounded, - label: 'Name', - value: activity.name!), - ], - if (activity.description?.isNotEmpty == true) ...[ - Divider(height: 1, color: SyntrakColors.divider), - _DetailTile( - icon: Icons.notes_rounded, - label: 'Note', - value: activity.description!), - ], ], ), ), @@ -501,6 +473,34 @@ class _ActivityDetailScreenState extends State { ), ); } + + AppBar _buildAppBar(Activity? activity) { + return AppBar( + backgroundColor: SyntrakColors.darkBackground, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20), + color: SyntrakColors.darkTextPrimary, + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + activity?.type.displayName ?? '', + style: SyntrakTypography.headlineSmall.copyWith( + color: SyntrakColors.darkTextPrimary, + fontWeight: FontWeight.bold, + ), + ), + actions: [ + if (activity != null) + IconButton( + icon: const Icon(Icons.more_vert_rounded, size: 22), + color: SyntrakColors.darkTextPrimary, + onPressed: () => _confirmDelete(activity), + ), + ], + ); + } } // ─── Map controls ───────────────────────────────────────────────────────────── @@ -508,7 +508,6 @@ class _ActivityDetailScreenState extends State { class _MapStyleToggle extends StatelessWidget { const _MapStyleToggle( {required this.selectedStyle, required this.onSelected}); - final MapVisualStyle selectedStyle; final void Function(MapVisualStyle) onSelected; @@ -516,12 +515,12 @@ class _MapStyleToggle extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: Colors.white, + color: SyntrakColors.darkSurface, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.12), - blurRadius: 10, + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 8, offset: const Offset(0, 2)) ], ), @@ -545,7 +544,6 @@ class _MapStyleToggle extends StatelessWidget { class _StyleBtn extends StatelessWidget { const _StyleBtn( {required this.label, required this.selected, required this.onTap}); - final String label; final bool selected; final VoidCallback onTap; @@ -553,8 +551,7 @@ class _StyleBtn extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: - selected ? SyntrakColors.primary : Colors.transparent, + color: selected ? SyntrakColors.primary : Colors.transparent, borderRadius: BorderRadius.circular(10), child: InkWell( borderRadius: BorderRadius.circular(10), @@ -564,7 +561,9 @@ class _StyleBtn extends StatelessWidget { child: Text( label, style: TextStyle( - color: selected ? Colors.white : SyntrakColors.textSecondary, + color: selected + ? Colors.white + : SyntrakColors.darkTextSecondary, fontWeight: FontWeight.w600, fontSize: 13, ), @@ -578,7 +577,6 @@ class _StyleBtn extends StatelessWidget { class _MapZoomControls extends StatelessWidget { const _MapZoomControls( {required this.onZoomIn, required this.onZoomOut}); - final Future Function() onZoomIn; final Future Function() onZoomOut; @@ -586,12 +584,12 @@ class _MapZoomControls extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: Colors.white, + color: SyntrakColors.darkSurface, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.12), - blurRadius: 10, + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 8, offset: const Offset(0, 2)) ], ), @@ -600,14 +598,18 @@ class _MapZoomControls extends StatelessWidget { children: [ IconButton( visualDensity: VisualDensity.compact, - icon: Icon(Icons.add, color: SyntrakColors.textPrimary, size: 20), + icon: Icon(Icons.add, + color: SyntrakColors.darkTextPrimary, size: 20), onPressed: () => onZoomIn(), ), - Container(width: 24, height: 1, color: SyntrakColors.divider), + Container( + width: 24, + height: 1, + color: SyntrakColors.darkSurfaceVariant), IconButton( visualDensity: VisualDensity.compact, icon: Icon(Icons.remove, - color: SyntrakColors.textPrimary, size: 20), + color: SyntrakColors.darkTextPrimary, size: 20), onPressed: () => onZoomOut(), ), ], @@ -620,7 +622,6 @@ class _MapZoomControls extends StatelessWidget { class _ColorModeBar extends StatelessWidget { const _ColorModeBar({required this.selected, required this.onSelected}); - final MapColorMode selected; final Future Function(MapColorMode) onSelected; @@ -631,19 +632,16 @@ class _ColorModeBar extends StatelessWidget { child: Row( children: [ _ModeChip( - mode: MapColorMode.segment, label: 'Segment', selected: selected == MapColorMode.segment, onTap: () => onSelected(MapColorMode.segment)), const SizedBox(width: 8), _ModeChip( - mode: MapColorMode.speed, label: 'Speed', selected: selected == MapColorMode.speed, onTap: () => onSelected(MapColorMode.speed)), const SizedBox(width: 8), _ModeChip( - mode: MapColorMode.elevation, label: 'Elevation', selected: selected == MapColorMode.elevation, onTap: () => onSelected(MapColorMode.elevation)), @@ -655,12 +653,9 @@ class _ColorModeBar extends StatelessWidget { class _ModeChip extends StatelessWidget { const _ModeChip( - {required this.mode, - required this.label, + {required this.label, required this.selected, required this.onTap}); - - final MapColorMode mode; final String label; final bool selected; final VoidCallback onTap; @@ -671,22 +666,20 @@ class _ModeChip extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 180), - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: selected - ? SyntrakColors.primary - : SyntrakColors.surface, + color: selected ? SyntrakColors.primary : SyntrakColors.darkSurface, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? SyntrakColors.primary - : SyntrakColors.divider), + : SyntrakColors.darkSurfaceVariant), ), child: Text( label, style: SyntrakTypography.labelMedium.copyWith( - color: selected ? Colors.white : SyntrakColors.textSecondary, + color: + selected ? Colors.white : SyntrakColors.darkTextSecondary, fontWeight: FontWeight.w600, ), ), @@ -698,32 +691,31 @@ class _ModeChip extends StatelessWidget { // ─── Stats ──────────────────────────────────────────────────────────────────── class _StatCell extends StatelessWidget { - const _StatCell({required this.value, required this.label}); - - final String value; + const _StatCell({required this.label, required this.value}); final String label; + final String value; @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 18), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( - value, - textAlign: TextAlign.center, - style: SyntrakTypography.headlineSmall.copyWith( - color: SyntrakColors.textPrimary, - fontWeight: FontWeight.bold, + label, + style: SyntrakTypography.labelSmall.copyWith( + color: SyntrakColors.darkTextSecondary, + letterSpacing: 0.3, ), ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( - label, - textAlign: TextAlign.center, - style: SyntrakTypography.labelSmall.copyWith( - color: SyntrakColors.textTertiary, + value, + style: SyntrakTypography.headlineMedium.copyWith( + color: SyntrakColors.darkTextPrimary, + fontWeight: FontWeight.bold, ), ), ], @@ -737,7 +729,6 @@ class _StatCell extends StatelessWidget { class _DetailTile extends StatelessWidget { const _DetailTile( {required this.icon, required this.label, required this.value}); - final IconData icon; final String label; final String value; @@ -748,20 +739,18 @@ class _DetailTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ - Icon(icon, size: 18, color: SyntrakColors.textTertiary), + Icon(icon, size: 18, color: SyntrakColors.darkTextSecondary), const SizedBox(width: 12), SizedBox( - width: 52, + width: 48, child: Text(label, style: SyntrakTypography.bodySmall - .copyWith(color: SyntrakColors.textTertiary)), + .copyWith(color: SyntrakColors.darkTextSecondary)), ), Expanded( - child: Text( - value, - style: SyntrakTypography.bodyMedium - .copyWith(color: SyntrakColors.textPrimary), - ), + child: Text(value, + style: SyntrakTypography.bodyMedium + .copyWith(color: SyntrakColors.darkTextPrimary)), ), ], ), From 92816e2697acf4fb52fd1760c583cdcec36a8882 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:17:43 +0800 Subject: [PATCH 08/13] fix(frontend): switch activity detail to light theme Same Strava-inspired layout, all dark color tokens replaced with their light SyntrakColors equivalents (background, surface, textPrimary, textSecondary, divider). Co-Authored-By: Claude Sonnet 4.6 --- .../activities/activity_detail_screen.dart | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/frontend/lib/screens/activities/activity_detail_screen.dart b/frontend/lib/screens/activities/activity_detail_screen.dart index 6c7d6cd7..bb81959b 100644 --- a/frontend/lib/screens/activities/activity_detail_screen.dart +++ b/frontend/lib/screens/activities/activity_detail_screen.dart @@ -188,7 +188,7 @@ class _ActivityDetailScreenState extends State { backgroundColor: Colors.transparent, builder: (_) => Container( decoration: BoxDecoration( - color: SyntrakColors.darkSurface, + color: SyntrakColors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), padding: EdgeInsets.fromLTRB( @@ -200,7 +200,7 @@ class _ActivityDetailScreenState extends State { width: 36, height: 4, decoration: BoxDecoration( - color: Colors.white24, + color: Colors.black12, borderRadius: BorderRadius.circular(2), ), ), @@ -218,11 +218,11 @@ class _ActivityDetailScreenState extends State { const SizedBox(height: 16), Text('Delete Activity?', style: SyntrakTypography.headlineMedium - .copyWith(color: SyntrakColors.darkTextPrimary)), + .copyWith(color: SyntrakColors.textPrimary)), const SizedBox(height: 8), Text('This cannot be undone.', style: SyntrakTypography.bodyMedium - .copyWith(color: SyntrakColors.darkTextSecondary)), + .copyWith(color: SyntrakColors.textSecondary)), const SizedBox(height: 28), SizedBox( width: double.infinity, @@ -246,7 +246,7 @@ class _ActivityDetailScreenState extends State { onPressed: () => Navigator.pop(context, false), child: Text('Cancel', style: SyntrakTypography.bodyMedium - .copyWith(color: SyntrakColors.darkTextSecondary)), + .copyWith(color: SyntrakColors.textSecondary)), ), ], ), @@ -264,7 +264,7 @@ class _ActivityDetailScreenState extends State { Widget build(BuildContext context) { if (_isLoading) { return Scaffold( - backgroundColor: SyntrakColors.darkBackground, + backgroundColor: SyntrakColors.background, appBar: _buildAppBar(null), body: Center( child: CircularProgressIndicator( @@ -276,12 +276,12 @@ class _ActivityDetailScreenState extends State { if (_activity == null) { return Scaffold( - backgroundColor: SyntrakColors.darkBackground, + backgroundColor: SyntrakColors.background, appBar: _buildAppBar(null), body: Center( child: Text('Activity not found', style: SyntrakTypography.bodyLarge - .copyWith(color: SyntrakColors.darkTextSecondary)), + .copyWith(color: SyntrakColors.textSecondary)), ), ); } @@ -297,7 +297,7 @@ class _ActivityDetailScreenState extends State { final hasRenderableTrack = track != null && track.points.length > 1; return Scaffold( - backgroundColor: SyntrakColors.darkBackground, + backgroundColor: SyntrakColors.background, appBar: _buildAppBar(activity), body: SingleChildScrollView( child: Column( @@ -315,7 +315,7 @@ class _ActivityDetailScreenState extends State { ? activity.name! : activity.type.displayName, style: SyntrakTypography.displaySmall.copyWith( - color: SyntrakColors.darkTextPrimary, + color: SyntrakColors.textPrimary, fontWeight: FontWeight.bold, ), ), @@ -324,7 +324,7 @@ class _ActivityDetailScreenState extends State { DateFormat('EEEE, MMM d, y · h:mm a') .format(activity.startTime), style: SyntrakTypography.bodySmall.copyWith( - color: SyntrakColors.darkTextSecondary, + color: SyntrakColors.textSecondary, ), ), ], @@ -386,7 +386,7 @@ class _ActivityDetailScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( decoration: BoxDecoration( - color: SyntrakColors.darkSurface, + color: SyntrakColors.surface, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -401,7 +401,7 @@ class _ActivityDetailScreenState extends State { VerticalDivider( width: 1, thickness: 1, - color: SyntrakColors.darkSurfaceVariant), + color: SyntrakColors.surfaceVariant), Expanded( child: _StatCell( label: 'Avg Speed', @@ -412,7 +412,7 @@ class _ActivityDetailScreenState extends State { Divider( height: 1, thickness: 1, - color: SyntrakColors.darkSurfaceVariant), + color: SyntrakColors.surfaceVariant), IntrinsicHeight( child: Row( children: [ @@ -423,7 +423,7 @@ class _ActivityDetailScreenState extends State { VerticalDivider( width: 1, thickness: 1, - color: SyntrakColors.darkSurfaceVariant), + color: SyntrakColors.surfaceVariant), Expanded( child: _StatCell( label: 'Elevation Gain', @@ -443,7 +443,7 @@ class _ActivityDetailScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( decoration: BoxDecoration( - color: SyntrakColors.darkSurface, + color: SyntrakColors.surface, borderRadius: BorderRadius.circular(16), ), child: Column( @@ -456,7 +456,7 @@ class _ActivityDetailScreenState extends State { Divider( height: 1, thickness: 1, - color: SyntrakColors.darkSurfaceVariant), + color: SyntrakColors.surfaceVariant), _DetailTile( icon: Icons.flag_outlined, label: 'End', @@ -476,18 +476,18 @@ class _ActivityDetailScreenState extends State { AppBar _buildAppBar(Activity? activity) { return AppBar( - backgroundColor: SyntrakColors.darkBackground, + backgroundColor: SyntrakColors.background, elevation: 0, scrolledUnderElevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20), - color: SyntrakColors.darkTextPrimary, + color: SyntrakColors.textPrimary, onPressed: () => Navigator.of(context).pop(), ), title: Text( activity?.type.displayName ?? '', style: SyntrakTypography.headlineSmall.copyWith( - color: SyntrakColors.darkTextPrimary, + color: SyntrakColors.textPrimary, fontWeight: FontWeight.bold, ), ), @@ -495,7 +495,7 @@ class _ActivityDetailScreenState extends State { if (activity != null) IconButton( icon: const Icon(Icons.more_vert_rounded, size: 22), - color: SyntrakColors.darkTextPrimary, + color: SyntrakColors.textPrimary, onPressed: () => _confirmDelete(activity), ), ], @@ -515,11 +515,11 @@ class _MapStyleToggle extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: SyntrakColors.darkSurface, + color: SyntrakColors.surface, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.4), + color: Colors.black.withValues(alpha: 0.12), blurRadius: 8, offset: const Offset(0, 2)) ], @@ -563,7 +563,7 @@ class _StyleBtn extends StatelessWidget { style: TextStyle( color: selected ? Colors.white - : SyntrakColors.darkTextSecondary, + : SyntrakColors.textSecondary, fontWeight: FontWeight.w600, fontSize: 13, ), @@ -584,11 +584,11 @@ class _MapZoomControls extends StatelessWidget { Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( - color: SyntrakColors.darkSurface, + color: SyntrakColors.surface, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.4), + color: Colors.black.withValues(alpha: 0.12), blurRadius: 8, offset: const Offset(0, 2)) ], @@ -599,17 +599,17 @@ class _MapZoomControls extends StatelessWidget { IconButton( visualDensity: VisualDensity.compact, icon: Icon(Icons.add, - color: SyntrakColors.darkTextPrimary, size: 20), + color: SyntrakColors.textPrimary, size: 20), onPressed: () => onZoomIn(), ), Container( width: 24, height: 1, - color: SyntrakColors.darkSurfaceVariant), + color: SyntrakColors.surfaceVariant), IconButton( visualDensity: VisualDensity.compact, icon: Icon(Icons.remove, - color: SyntrakColors.darkTextPrimary, size: 20), + color: SyntrakColors.textPrimary, size: 20), onPressed: () => onZoomOut(), ), ], @@ -668,18 +668,18 @@ class _ModeChip extends StatelessWidget { duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: selected ? SyntrakColors.primary : SyntrakColors.darkSurface, + color: selected ? SyntrakColors.primary : SyntrakColors.surface, borderRadius: BorderRadius.circular(999), border: Border.all( color: selected ? SyntrakColors.primary - : SyntrakColors.darkSurfaceVariant), + : SyntrakColors.surfaceVariant), ), child: Text( label, style: SyntrakTypography.labelMedium.copyWith( color: - selected ? Colors.white : SyntrakColors.darkTextSecondary, + selected ? Colors.white : SyntrakColors.textSecondary, fontWeight: FontWeight.w600, ), ), @@ -706,7 +706,7 @@ class _StatCell extends StatelessWidget { Text( label, style: SyntrakTypography.labelSmall.copyWith( - color: SyntrakColors.darkTextSecondary, + color: SyntrakColors.textSecondary, letterSpacing: 0.3, ), ), @@ -714,7 +714,7 @@ class _StatCell extends StatelessWidget { Text( value, style: SyntrakTypography.headlineMedium.copyWith( - color: SyntrakColors.darkTextPrimary, + color: SyntrakColors.textPrimary, fontWeight: FontWeight.bold, ), ), @@ -739,18 +739,18 @@ class _DetailTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( children: [ - Icon(icon, size: 18, color: SyntrakColors.darkTextSecondary), + Icon(icon, size: 18, color: SyntrakColors.textSecondary), const SizedBox(width: 12), SizedBox( width: 48, child: Text(label, style: SyntrakTypography.bodySmall - .copyWith(color: SyntrakColors.darkTextSecondary)), + .copyWith(color: SyntrakColors.textSecondary)), ), Expanded( child: Text(value, style: SyntrakTypography.bodyMedium - .copyWith(color: SyntrakColors.darkTextPrimary)), + .copyWith(color: SyntrakColors.textPrimary)), ), ], ), From 05401440d2a7ae7877cd802afd389163900670ae Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:25:53 +0800 Subject: [PATCH 09/13] feat(frontend): remove segment/speed/elevation map chips Co-Authored-By: Claude Sonnet 4.6 --- .../activities/activity_detail_screen.dart | 92 +------------------ 1 file changed, 2 insertions(+), 90 deletions(-) diff --git a/frontend/lib/screens/activities/activity_detail_screen.dart b/frontend/lib/screens/activities/activity_detail_screen.dart index bb81959b..1cd99d84 100644 --- a/frontend/lib/screens/activities/activity_detail_screen.dart +++ b/frontend/lib/screens/activities/activity_detail_screen.dart @@ -39,7 +39,6 @@ class _ActivityDetailScreenState extends State { MapRenderingEngine? _mapRenderingEngine; MapLibreMapController? _mapController; bool _mapReady = false; - MapColorMode _selectedColorMode = MapColorMode.segment; MapVisualStyle _selectedMapStyle = MapVisualStyle.terrain; @override @@ -158,16 +157,10 @@ class _ActivityDetailScreenState extends State { final t = _track; if (!_mapReady || c == null || t == null || _segments.isEmpty) return; await _mapRenderingEngine!.initialise(c, - track: t, segments: _segments, initialColorMode: _selectedColorMode); + track: t, segments: _segments, initialColorMode: MapColorMode.segment); await _mapRenderingEngine!.fitToTrack(t); } - Future _onColorModeSelected(MapColorMode mode) async { - if (_selectedColorMode == mode) return; - setState(() => _selectedColorMode = mode); - unawaited(_mapRenderingEngine!.setColorMode(mode)); - } - Future _zoomIn() async => _mapController?.animateCamera(CameraUpdate.zoomIn()); Future _zoomOut() async => @@ -368,18 +361,7 @@ class _ActivityDetailScreenState extends State { ), ), - // ── Color mode chips ───────────────────────────────────── - if (hasRenderableTrack) - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: _ColorModeBar( - selected: _selectedColorMode, - onSelected: _onColorModeSelected, - ), - ) - else - const SizedBox(height: 20), + const SizedBox(height: 20), // ── Stats grid ─────────────────────────────────────────── Padding( @@ -618,76 +600,6 @@ class _MapZoomControls extends StatelessWidget { } } -// ─── Color mode chips ───────────────────────────────────────────────────────── - -class _ColorModeBar extends StatelessWidget { - const _ColorModeBar({required this.selected, required this.onSelected}); - final MapColorMode selected; - final Future Function(MapColorMode) onSelected; - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _ModeChip( - label: 'Segment', - selected: selected == MapColorMode.segment, - onTap: () => onSelected(MapColorMode.segment)), - const SizedBox(width: 8), - _ModeChip( - label: 'Speed', - selected: selected == MapColorMode.speed, - onTap: () => onSelected(MapColorMode.speed)), - const SizedBox(width: 8), - _ModeChip( - label: 'Elevation', - selected: selected == MapColorMode.elevation, - onTap: () => onSelected(MapColorMode.elevation)), - ], - ), - ); - } -} - -class _ModeChip extends StatelessWidget { - const _ModeChip( - {required this.label, - required this.selected, - required this.onTap}); - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: selected ? SyntrakColors.primary : SyntrakColors.surface, - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: selected - ? SyntrakColors.primary - : SyntrakColors.surfaceVariant), - ), - child: Text( - label, - style: SyntrakTypography.labelMedium.copyWith( - color: - selected ? Colors.white : SyntrakColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - ); - } -} - // ─── Stats ──────────────────────────────────────────────────────────────────── class _StatCell extends StatelessWidget { From 540938fa04f3a58f482bc425675347836b6caa36 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:28:29 +0800 Subject: [PATCH 10/13] refactor(frontend): remove duplicate _SaveStatCell, flatten _StyleBtn - Delete _SaveStatCell (identical to _StatItem); update 3 call sites - Collapse _StyleBtn class into a local builder inside _MapStyleToggle Co-Authored-By: Claude Sonnet 4.6 --- .../activities/activity_detail_screen.dart | 64 +++++++------------ .../lib/screens/record/record_screen.dart | 39 +---------- 2 files changed, 26 insertions(+), 77 deletions(-) diff --git a/frontend/lib/screens/activities/activity_detail_screen.dart b/frontend/lib/screens/activities/activity_detail_screen.dart index 1cd99d84..62914840 100644 --- a/frontend/lib/screens/activities/activity_detail_screen.dart +++ b/frontend/lib/screens/activities/activity_detail_screen.dart @@ -495,6 +495,27 @@ class _MapStyleToggle extends StatelessWidget { @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: SyntrakColors.surface, @@ -509,53 +530,14 @@ class _MapStyleToggle extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _StyleBtn( - label: '2D', - selected: selectedStyle == MapVisualStyle.clean2d, - onTap: () => onSelected(MapVisualStyle.clean2d)), - _StyleBtn( - label: 'Terrain', - selected: selectedStyle == MapVisualStyle.terrain, - onTap: () => onSelected(MapVisualStyle.terrain)), + btn('2D', MapVisualStyle.clean2d), + btn('Terrain', MapVisualStyle.terrain), ], ), ); } } -class _StyleBtn extends StatelessWidget { - const _StyleBtn( - {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 ? SyntrakColors.primary : Colors.transparent, - borderRadius: BorderRadius.circular(10), - child: InkWell( - borderRadius: BorderRadius.circular(10), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Text( - label, - style: TextStyle( - color: selected - ? Colors.white - : SyntrakColors.textSecondary, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - ), - ), - ); - } -} - class _MapZoomControls extends StatelessWidget { const _MapZoomControls( {required this.onZoomIn, required this.onZoomOut}); diff --git a/frontend/lib/screens/record/record_screen.dart b/frontend/lib/screens/record/record_screen.dart index 0966688b..c2be8c17 100644 --- a/frontend/lib/screens/record/record_screen.dart +++ b/frontend/lib/screens/record/record_screen.dart @@ -361,21 +361,21 @@ class _RecordScreenState extends State { child: Row( children: [ Expanded( - child: _SaveStatCell( + child: _StatItem( value: fmtDist(distance), label: 'Distance')), VerticalDivider( width: 1, thickness: 1, color: SyntrakColors.divider), Expanded( - child: _SaveStatCell( + child: _StatItem( value: fmtDur(duration), label: 'Time')), VerticalDivider( width: 1, thickness: 1, color: SyntrakColors.divider), Expanded( - child: _SaveStatCell( + child: _StatItem( value: '+${elevation.toStringAsFixed(0)} m', label: 'Elevation')), ], @@ -804,39 +804,6 @@ class _StatItem extends StatelessWidget { } } -class _SaveStatCell extends StatelessWidget { - const _SaveStatCell({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 ──────────────────────────────────────────────────────────── From fb2286c01c2ec23000994e9d9375d2bb15462fd9 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:56:35 +0800 Subject: [PATCH 11/13] Update backend/map-backend/domains/activities_service/api.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- backend/map-backend/domains/activities_service/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/map-backend/domains/activities_service/api.py b/backend/map-backend/domains/activities_service/api.py index b4e6dcfd..b9d0c25e 100644 --- a/backend/map-backend/domains/activities_service/api.py +++ b/backend/map-backend/domains/activities_service/api.py @@ -44,7 +44,7 @@ async def _render_and_upload(activity_id: str, latlon: list[tuple[float, float]] png = await asyncio.to_thread( render_route_png, latlon, cfg.STATIC_MAP_WIDTH, cfg.STATIC_MAP_HEIGHT ) - return upload_thumbnail(activity_id, png) + 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 From 8ec328872ea582e45dd8f9ae87c5b9ebb20fe4d5 Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 22:56:56 +0800 Subject: [PATCH 12/13] Update frontend/lib/features/activities/data/activities_repository.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../lib/features/activities/data/activities_repository.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/lib/features/activities/data/activities_repository.dart b/frontend/lib/features/activities/data/activities_repository.dart index 0ea99f88..7d43a787 100644 --- a/frontend/lib/features/activities/data/activities_repository.dart +++ b/frontend/lib/features/activities/data/activities_repository.dart @@ -41,12 +41,17 @@ class ActivitiesRepository { 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, ); } + } } From 4da4d18c2991cf456fde14d90c700257067effda Mon Sep 17 00:00:00 2001 From: Chefmatteo Date: Tue, 23 Jun 2026 23:02:34 +0800 Subject: [PATCH 13/13] Update frontend/lib/screens/record/record_screen.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../lib/screens/record/record_screen.dart | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/frontend/lib/screens/record/record_screen.dart b/frontend/lib/screens/record/record_screen.dart index c2be8c17..7757136b 100644 --- a/frontend/lib/screens/record/record_screen.dart +++ b/frontend/lib/screens/record/record_screen.dart @@ -483,17 +483,28 @@ class _RecordScreenState extends State { Future _pollPipelineStatus(String activityId) async { final activityProvider = Provider.of(context, listen: false); - while (mounted) { + 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; - final activity = await activityProvider.getActivity(activityId); - if (activity != null && !activity.isPipelinePending) { - if (!mounted) return; - setState(() => _isProcessing = false); - _navigateToDetail(activityId); - 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) {