Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/activity-backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
19 changes: 18 additions & 1 deletion backend/activity-backend/routes/activities_management_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -18,16 +18,30 @@
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

logger = logging.getLogger(__name__)
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."""
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/activity-backend/routes/activity_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}


Expand Down
14 changes: 14 additions & 0 deletions backend/activity-backend/services/map_backend_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions backend/activity-backend/services/pipeline_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions backend/activity-backend/services/supabase_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions backend/db/migrations/004_activities_thumbnail_url.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions backend/map-backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``).
Expand Down
43 changes: 42 additions & 1 deletion backend/map-backend/domains/activities_service/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from __future__ import annotations

import asyncio
import json
import logging
from datetime import datetime
Expand All @@ -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,
Expand All @@ -26,12 +28,48 @@
TrackPointOut,
)

from config import get_config
from domains.activities_service.ports import get_activities_conn
from engine.thumbnail import render_route_png
from services.supabase_client import upload_thumbnail

logger = logging.getLogger(__name__)


async def _render_and_upload(activity_id: str, latlon: list[tuple[float, float]]) -> str | None:
"""Render route PNG and upload to storage; returns public URL or None on failure."""
try:
cfg = get_config()
# ponytail: asyncio.to_thread keeps Pillow off the event loop
png = await asyncio.to_thread(
render_route_png, latlon, cfg.STATIC_MAP_WIDTH, cfg.STATIC_MAP_HEIGHT
)
return await asyncio.to_thread(upload_thumbnail, activity_id, png)
except Exception:
logger.warning("thumbnail generation failed for %s", activity_id, exc_info=True)
return None


async def _generate_thumbnail(activity_id: UUID, points: list) -> str | None:
return await _render_and_upload(str(activity_id), [(p.lat, p.lon) for p in points])


router = APIRouter(prefix="/activities", tags=["activities"])


class _ThumbnailRequest(BaseModel):
activity_id: str
points: list[dict] # [{lat: float, lon: float}, ...]


@router.post("/thumbnail")
async def generate_activity_thumbnail(body: _ThumbnailRequest) -> dict:
"""Generate and upload a route thumbnail from raw GPS points (live recording path)."""
latlon = [(p["lat"], p["lon"]) for p in body.points if "lat" in p and "lon" in p]
url = await _render_and_upload(body.activity_id, latlon)
return {"thumbnail_url": url}


_INSERT_ACTIVITY = """
INSERT INTO map_trail.activities (user_id, recorded_at, stats)
VALUES ($1::uuid, $2::timestamptz, $3::jsonb)
Expand Down Expand Up @@ -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(
Expand Down
68 changes: 68 additions & 0 deletions backend/map-backend/engine/thumbnail.py
Original file line number Diff line number Diff line change
@@ -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()
11 changes: 10 additions & 1 deletion backend/map-backend/services/supabase_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Comment on lines +27 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's explore the repository structure and find relevant files
git ls-files | grep -E "(supabase|storage|thumbnail|activity)" | head -20

Repository: Syntraksoftware/Snowtrak

Length of output: 1162


🏁 Script executed:

# Find the supabase_client.py file and examine its content
find . -name "supabase_client.py" -type f

Repository: Syntraksoftware/Snowtrak

Length of output: 323


🏁 Script executed:

# Search for configuration and bucket setup
rg "THUMBNAIL_BUCKET" -A 2 -B 2

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Look for how thumbnails are accessed/served in the application
rg "upload_thumbnail|get_public_url|thumbnail" --type py -B 2 -A 2 | head -60

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Search for any authorization/visibility checks related to thumbnails or activities
rg "private.*activity|visibility.*check|auth.*check" --type py | head -20

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Read the supabase_client.py file from map-backend
cat -n backend/map-backend/services/supabase_client.py

Repository: Syntraksoftware/Snowtrak

Length of output: 1503


🏁 Script executed:

# Check the config file in map-backend
cat -n backend/map-backend/config.py 2>/dev/null || echo "Not found in map-backend"

Repository: Syntraksoftware/Snowtrak

Length of output: 4184


🏁 Script executed:

# Look for config files with bucket settings
find backend -name "config.py" -o -name ".env*" | head -10

Repository: Syntraksoftware/Snowtrak

Length of output: 447


🏁 Script executed:

# Search for get_public_url usage across the codebase
rg "get_public_url" -B 3 -A 3 --type py

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Search for bucket configuration patterns
rg "storage\.from_\|BUCKET\|bucket" --type py -B 2 -A 2 | head -80

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Search for calls to upload_thumbnail
rg "upload_thumbnail" --type py -B 3 -A 3

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Look for endpoints that serve/access thumbnails
rg "thumbnail" --type py -B 2 -A 2

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Check the Supabase setup SQL to understand bucket configuration
cat backend/map-backend/SUPABASE_SETUP.sql 2>/dev/null || find backend -name "SUPABASE*.sql" -exec cat {} \;

Repository: Syntraksoftware/Snowtrak

Length of output: 4161


🏁 Script executed:

# Search for activity visibility/privacy checks
rg "is_private|private.*activity|visibility" --type py -B 2 -A 2 | head -100

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Look for main.py or API routes to see how thumbnails are exposed
find backend/map-backend -name "main.py" -o -name "*routes*" -o -name "*api*" | xargs cat 2>/dev/null | head -200

Repository: Syntraksoftware/Snowtrak

Length of output: 15953


🏁 Script executed:

# Search for activity retrieval and thumbnail URL handling in activity-backend
find backend/activity-backend -name "*.py" -type f | head -20

Repository: Syntraksoftware/Snowtrak

Length of output: 1160


🏁 Script executed:

# Look at activity-backend main routes
cat backend/activity-backend/main.py 2>/dev/null | head -150

Repository: Syntraksoftware/Snowtrak

Length of output: 3104


🏁 Script executed:

# Search for activity API endpoints that might return thumbnail info
rg "def.*activity\|@.*route\|@.*get\|@.*post" backend/activity-backend --type py -B 1 -A 3 | head -100

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Check how map_activity_id is used and where thumbnails are exposed
rg "map_activity_id\|thumbnail" --type py -B 2 -A 2 | head -80

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Look for any authorization checks on storage/thumbnail access
rg "visibility\|private\|public\|auth" backend/activity-backend --type py -B 1 -A 1 | head -60

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Read the activity routes file
cat -n backend/activity-backend/routes/activities.py 2>/dev/null | head -200

Repository: Syntraksoftware/Snowtrak

Length of output: 910


🏁 Script executed:

# Read the activity list routes
cat -n backend/activity-backend/routes/activities_list_routes.py 2>/dev/null | head -150

Repository: Syntraksoftware/Snowtrak

Length of output: 5553


🏁 Script executed:

# Look for where thumbnail URLs or map_activity_id is exposed in responses
rg "map_activity_id" -B 3 -A 3 --type py

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Check the transformers file for how activities are converted to responses
cat -n backend/activity-backend/routes/activity_transformers.py 2>/dev/null | head -200

Repository: Syntraksoftware/Snowtrak

Length of output: 9095


🏁 Script executed:

# Search for endpoint definitions that return activity data
rg "@.*get\|@.*post" backend/activity-backend/routes --type py -A 5 | head -150

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Search for where activities are filtered by visibility
rg "visibility.*public\|is_public" --type py -B 2 -A 2

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Check the supabase client list_activities method to see if it filters
cat -n backend/activity-backend/services/supabase_client.py | head -200

Repository: Syntraksoftware/Snowtrak

Length of output: 8765


🏁 Script executed:

# Look for any signed URL implementations in the codebase
rg "signed.*url\|sign.*url" --type py -i

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Check if bucket access control is configured anywhere
rg "RLS\|row.*level\|bucket.*public\|bucket.*private" --type py --type sql -i

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Search for where list_activities is called and if there's any visibility filtering
rg "list_activities\|visibility.*public\|\.eq.*visibility" --type py -B 2 -A 2

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Check if there's RLS (Row Level Security) configured in Supabase setup
find backend -name "*.sql" -exec grep -l "RLS\|POLICY" {} \;

Repository: Syntraksoftware/Snowtrak

Length of output: 162


🏁 Script executed:

# Look at the full supabase_client list_activities to see if visibility filter is applied
sed -n '159,169p' backend/activity-backend/services/supabase_client.py

Repository: Syntraksoftware/Snowtrak

Length of output: 579


🏁 Script executed:

# Check the activity-backend upload routes to see if thumbnails are only created for certain visibility levels
cat -n backend/activity-backend/routes/activities_upload_routes.py 2>/dev/null | head -200

Repository: Syntraksoftware/Snowtrak

Length of output: 5921


🏁 Script executed:

# Check the complete list_activities implementation in supabase_client
sed -n '159,198p' backend/activity-backend/services/supabase_client.py

Repository: Syntraksoftware/Snowtrak

Length of output: 1576


🏁 Script executed:

# Verify what the public endpoint actually returns
cat -n backend/activity-backend/routes/activities_list_routes.py | sed -n '28,54p'

Repository: Syntraksoftware/Snowtrak

Length of output: 1441


Private activity thumbnails permanently accessible due to public URL generation without visibility checks.

The list_activities() endpoint docstring claims to return "public activities" but the underlying activity_client.list_activities() query contains no visibility filter—it retrieves all activities regardless of privacy status. Combined with upload_thumbnail() always returning public URLs via get_public_url(), this exposes private activity thumbnails as permanently accessible assets outside authorization checks. Users believe private activities are protected, but the thumbnail assets remain discoverable and accessible to anyone with the URL.

Consider filtering activities by visibility = 'public' in the database query, or issuing signed URLs that expire after visibility/authorization verification instead of permanent public URLs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/map-backend/services/supabase_client.py` around lines 27 - 33, The
upload_thumbnail function always returns a permanent public URL via
get_public_url() regardless of the activity's visibility status, exposing
private activity thumbnails as permanently accessible assets. Modify the
upload_thumbnail function to either accept a visibility parameter and only call
get_public_url() when visibility is public, or replace get_public_url() with
bucket.create_signed_url() to generate time-limited signed URLs that expire.
This ensures private activity thumbnails are not permanently accessible outside
of authorization checks.

1 change: 1 addition & 0 deletions backend/shared/track_pipeline_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions frontend/lib/core/di/service_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -45,6 +46,8 @@ Future<void> setupServiceLocatorWithEnvironment({
return;
}

await MapConfig.init();

final appConfig =
await AppConfig.bootstrapWithOverride(environmentOverride: environment);
sl.registerSingleton<AppConfig>(appConfig);
Expand Down
19 changes: 19 additions & 0 deletions frontend/lib/features/activities/data/activities_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,23 @@ class ActivitiesRepository {
Future<void> deleteActivity(String id) {
return _api.deleteActivity(id);
}

Future<List<Activity>> getMyActivities({
String? search,
String? activityType,
String? startDate,
String? endDate,
int limit = 100,
int offset = 0,
}) {
return _api.getMyActivities(
search: search,
activityType: activityType,
startDate: startDate,
endDate: endDate,
limit: limit,
offset: offset,
);
Comment thread
chefmatteo marked this conversation as resolved.
}
}
}
5 changes: 3 additions & 2 deletions frontend/lib/models/activity.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
Loading
Loading