From d36db15f8e4532661ecc9d3343e0f9f20c9d95f0 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Sat, 20 Jun 2026 14:25:42 +0100 Subject: [PATCH 1/8] feat(gui): add sync milestone gamification and Ko-fi support Track lifetime activity/workout uploads, show rank progress and milestone celebrations, and surface Ko-fi links in the header, About, and sync tab. Co-authored-by: Cursor --- src/intervalssync/gui/app.py | 18 +- src/intervalssync/gui/config.py | 15 + src/intervalssync/gui/support_gamification.py | 268 ++++++++++++++++++ src/intervalssync/gui/sync_view.py | 39 +++ tests/test_config.py | 49 ++++ tests/test_support_gamification.py | 92 ++++++ 6 files changed, 476 insertions(+), 5 deletions(-) create mode 100644 src/intervalssync/gui/support_gamification.py create mode 100644 tests/test_support_gamification.py diff --git a/src/intervalssync/gui/app.py b/src/intervalssync/gui/app.py index f72e924..ae3804e 100644 --- a/src/intervalssync/gui/app.py +++ b/src/intervalssync/gui/app.py @@ -18,6 +18,7 @@ from .settings_view import build_settings_view from .sync_view import build_sync_view from . import profile_sync_ui +from . import support_gamification from . import theme @@ -181,6 +182,7 @@ def show_about(_: ft.ControlEvent | None = None) -> None: "GitHub", url="https://github.com/jorge-huxley/intervalssync", ), + ft.TextButton("Ko-fi", url=support_gamification.KOFI_URL), ft.TextButton("Close", on_click=lambda _: page.pop_dialog()), ], ) @@ -222,11 +224,17 @@ def _header() -> ft.Container: ), ], ), - ft.IconButton( - icon=ft.Icons.INFO_OUTLINE, - tooltip="About", - icon_color=colors["text_muted"], - on_click=show_about, + ft.Row( + spacing=0, + controls=[ + support_gamification.kofi_header_button(page), + ft.IconButton( + icon=ft.Icons.INFO_OUTLINE, + tooltip="About", + icon_color=colors["text_muted"], + on_click=show_about, + ), + ], ), ], ), diff --git a/src/intervalssync/gui/config.py b/src/intervalssync/gui/config.py index d052b6f..bea9ac9 100644 --- a/src/intervalssync/gui/config.py +++ b/src/intervalssync/gui/config.py @@ -61,6 +61,15 @@ class AppConfig: profile_sync_declined_fingerprint: str = "" # Prompt on launch when FTP, LTHR, or max HR differ from intervals.icu. profile_sync_check_on_launch: bool = True + # Lifetime sync stats (GUI gamification). + lifetime_activities_uploaded: int = 0 + lifetime_workouts_uploaded: int = 0 + celebrated_milestones: list[int] = field(default_factory=list) + stats_seeded: bool = False + + +def total_uploads(config: AppConfig) -> int: + return config.lifetime_activities_uploaded + config.lifetime_workouts_uploaded def any_source_enabled(config: AppConfig) -> bool: @@ -79,6 +88,12 @@ def load() -> AppConfig: if activity_source == "bryton": cfg.enable_bryton = True cfg.enable_igpsport = False + if not cfg.stats_seeded: + cfg.lifetime_workouts_uploaded = len(cfg.uploaded_workouts) + len( + cfg.uploaded_bryton_workouts + ) + cfg.stats_seeded = True + save(cfg) return cfg diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py new file mode 100644 index 0000000..c244253 --- /dev/null +++ b/src/intervalssync/gui/support_gamification.py @@ -0,0 +1,268 @@ +"""Lifetime sync stats, milestone celebrations, and Ko-fi support UI.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import flet as ft + +from . import config as config_module +from . import theme + +KOFI_URL = "https://ko-fi.com/jorge_huxley" + +MILESTONES = [1, 5, 10, 25, 50, 100, 250, 500, 1000] + +_MILESTONE_TITLES: dict[int, str] = { + 1: "First transfer!", + 5: "Domestique status!", + 10: "Breakaway!", + 25: "Quarter century!", + 50: "Half century!", + 100: "Century!", + 250: "Grand tour stage!", + 500: "Monument ride!", + 1000: "Legend status!", +} + + +@dataclass +class StatsCardRefs: + headline: ft.Ref[ft.Text] + breakdown: ft.Ref[ft.Text] + progress_label: ft.Ref[ft.Text] + progress_bar: ft.Ref[ft.ProgressBar] + + +def total_uploads(config: config_module.AppConfig) -> int: + return config_module.total_uploads(config) + + +def rank_for(total: int) -> str: + if total <= 0: + return "Rookie" + if total < 5: + return "Warm-up lap" + if total < 10: + return "Domestique" + if total < 25: + return "Breakaway" + if total < 50: + return "Climber" + if total < 100: + return "Sprinter" + if total < 250: + return "Century rider" + return "Grand tourer" + + +def next_milestone(total: int) -> int | None: + for milestone in MILESTONES: + if total < milestone: + return milestone + return None + + +def _previous_milestone(total: int) -> int: + previous = 0 + for milestone in MILESTONES: + if total >= milestone: + previous = milestone + else: + break + return previous + + +def progress_fraction(total: int) -> float: + nxt = next_milestone(total) + if nxt is None: + return 1.0 + prev = _previous_milestone(total) + span = nxt - prev + if span <= 0: + return 0.0 + return (total - prev) / span + + +def milestone_title(milestone: int) -> str: + return _MILESTONE_TITLES.get(milestone, f"{milestone} transfers!") + + +def _newly_crossed_milestone(old_total: int, new_total: int) -> int | None: + crossed: int | None = None + for milestone in MILESTONES: + if old_total < milestone <= new_total: + crossed = milestone + return crossed + + +def record_uploads( + config: config_module.AppConfig, + *, + activities: int = 0, + workouts: int = 0, +) -> int | None: + if activities <= 0 and workouts <= 0: + return None + + old_total = total_uploads(config) + config.lifetime_activities_uploaded += activities + config.lifetime_workouts_uploaded += workouts + new_total = total_uploads(config) + + milestone = _newly_crossed_milestone(old_total, new_total) + if milestone is not None and milestone not in config.celebrated_milestones: + config.celebrated_milestones.append(milestone) + config_module.save(config) + return milestone + + config_module.save(config) + return None + + +def _headline_text(config: config_module.AppConfig) -> str: + total = total_uploads(config) + rank = rank_for(total) + transfer_word = "transfer" if total == 1 else "transfers" + return f"{rank} · {total} {transfer_word}" + + +def _breakdown_text(config: config_module.AppConfig) -> str: + activities = config.lifetime_activities_uploaded + workouts = config.lifetime_workouts_uploaded + activity_word = "activity" if activities == 1 else "activities" + workout_word = "workout" if workouts == 1 else "workouts" + return f"{activities} {activity_word} · {workouts} {workout_word}" + + +def _progress_label_text(total: int) -> str: + nxt = next_milestone(total) + if nxt is None: + return "All milestones reached — you're a legend" + remaining = nxt - total + transfer_word = "transfer" if remaining == 1 else "transfers" + return f"{remaining} {transfer_word} to next milestone" + + +def update_stats_display( + page: ft.Page, + config: config_module.AppConfig, + refs: StatsCardRefs, +) -> None: + total = total_uploads(config) + if refs.headline.current: + refs.headline.current.value = _headline_text(config) + if refs.breakdown.current: + refs.breakdown.current.value = _breakdown_text(config) + if refs.progress_label.current: + refs.progress_label.current.value = _progress_label_text(total) + if refs.progress_bar.current: + refs.progress_bar.current.value = progress_fraction(total) + page.update() + + +def build_stats_card( + page: ft.Page, + config: config_module.AppConfig, + refs: StatsCardRefs, +) -> ft.Container: + colors = theme.palette(page) + total = total_uploads(config) + + headline = ft.Text( + _headline_text(config), + ref=refs.headline, + size=15, + weight=ft.FontWeight.W_600, + font_family=f"{theme.FONT_BODY}Medium", + color=colors["text"], + ) + breakdown = ft.Text( + _breakdown_text(config), + ref=refs.breakdown, + size=12, + color=colors["text_muted"], + ) + progress_label = ft.Text( + _progress_label_text(total), + ref=refs.progress_label, + size=11, + color=colors["text_muted"], + ) + progress_bar = ft.ProgressBar( + ref=refs.progress_bar, + value=progress_fraction(total), + color=colors["accent"], + bgcolor=colors["surface_alt"], + bar_height=4, + border_radius=2, + ) + + return ft.Container( + content=ft.Column( + spacing=theme.SPACE_SM, + controls=[ + headline, + breakdown, + progress_label, + progress_bar, + ft.TextButton( + "Saved you time? Buy me a coffee", + url=KOFI_URL, + style=ft.ButtonStyle( + color=colors["text_muted"], + padding=ft.Padding(0, 0, 0, 0), + ), + ), + ], + ), + padding=theme.SPACE_LG, + bgcolor=colors["surface"], + border=ft.Border.all(1, colors["border"]), + border_radius=theme.RADIUS_MD, + ) + + +def kofi_header_button(page: ft.Page) -> ft.IconButton: + colors = theme.palette(page) + return ft.IconButton( + icon=ft.Icons.LOCAL_CAFE_OUTLINED, + tooltip="Support on Ko-fi", + icon_color=colors["text_muted"], + url=KOFI_URL, + ) + + +async def show_milestone_dialog(page: ft.Page, milestone: int) -> None: + colors = theme.palette(page) + title = milestone_title(milestone) + + page.show_dialog( + ft.AlertDialog( + modal=True, + shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD), + title=theme.display_text(title, size=22), + content=ft.Column( + tight=True, + spacing=theme.SPACE_SM, + controls=[ + ft.Text( + f"You've synced {milestone} activities and workouts with Intervals Sync. " + "Nice work keeping your training data flowing.", + size=13, + color=colors["text_muted"], + ), + ft.Text( + "If this app saves you time, consider supporting development on Ko-fi.", + size=13, + color=colors["text_muted"], + ), + ], + ), + actions=[ + ft.TextButton("Support on Ko-fi", url=KOFI_URL), + ft.TextButton("Keep rolling", on_click=lambda _: page.pop_dialog()), + ], + ) + ) + page.update() diff --git a/src/intervalssync/gui/sync_view.py b/src/intervalssync/gui/sync_view.py index cb6a516..7740db8 100644 --- a/src/intervalssync/gui/sync_view.py +++ b/src/intervalssync/gui/sync_view.py @@ -18,6 +18,7 @@ from ..igpsport.core import SyncConfig as IgpSyncConfig, SyncError, sync as igpsport_sync from ..dropbox_client import get_dropbox_app_key from ..igpsport.workout import WorkoutUploadConfig, apply_uploaded_workout_map, upload_workouts +from . import support_gamification from . import theme @@ -36,6 +37,13 @@ def build_sync_view( border_radius=2, ) log = ft.ListView(spacing=4, auto_scroll=True, height=280, expand=False) + stats_refs = support_gamification.StatsCardRefs( + headline=ft.Ref[ft.Text](), + breakdown=ft.Ref[ft.Text](), + progress_label=ft.Ref[ft.Text](), + progress_bar=ft.Ref[ft.ProgressBar](), + ) + stats_card = support_gamification.build_stats_card(page, config, stats_refs) def _action_button(label: str, icon: str, *, outlined: bool = False) -> ft.FilledButton | ft.OutlinedButton: label_size = 13 if mobile else 14 @@ -156,6 +164,20 @@ def append_log(message: str) -> None: ) page.update() + def _on_sync_complete(*, activities: int = 0, workouts: int = 0) -> None: + milestone = support_gamification.record_uploads( + config, + activities=activities, + workouts=workouts, + ) + support_gamification.update_stats_display(page, config, stats_refs) + if milestone is not None: + + async def _celebrate() -> None: + await support_gamification.show_milestone_dialog(page, milestone) + + page.run_task(_celebrate) + def run_igpsport_sync( igp_password: str, api_key: str | None, @@ -183,8 +205,10 @@ def run_igpsport_sync( dropbox_date_filenames=config.dropbox_date_filenames, ) + uploaded_count = 0 try: result = igpsport_sync(sync_config, progress=append_log) + uploaded_count = result.uploaded append_log( f"\nDone — intervals uploaded {result.uploaded}, " f"Dropbox uploaded {result.uploaded_dropbox}, " @@ -197,6 +221,8 @@ def run_igpsport_sync( except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(activities=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -226,8 +252,10 @@ def run_bryton_sync( dropbox_date_filenames=config.dropbox_date_filenames, ) + uploaded_count = 0 try: result = bryton_sync(sync_config, progress=append_log) + uploaded_count = result.uploaded append_log( f"\nDone — intervals uploaded {result.uploaded}, " f"Dropbox uploaded {result.uploaded_dropbox}, " @@ -240,6 +268,8 @@ def run_bryton_sync( except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(activities=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -255,11 +285,13 @@ def run_upload_igp_workouts(igp_password: str, api_key: str) -> None: force_resync=config.force_resync, ) + uploaded_count = 0 try: result = upload_workouts(upload_config, progress=append_log) if result.uploaded_map or result.pruned_keys: apply_uploaded_workout_map(config.uploaded_workouts, result) config_module.save(config) + uploaded_count = result.uploaded append_log( f"\nDone — uploaded {result.uploaded}, " f"skipped {result.skipped}, no steps {result.no_steps}, " @@ -270,6 +302,8 @@ def run_upload_igp_workouts(igp_password: str, api_key: str) -> None: except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(workouts=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -284,11 +318,13 @@ def run_upload_bryton_workouts(bryton_password: str, api_key: str) -> None: force_resync=config.force_resync, ) + uploaded_count = 0 try: result = bryton_upload_workouts(upload_config, progress=append_log) if result.uploaded_map or result.pruned_keys: apply_uploaded_bryton_workout_map(config.uploaded_bryton_workouts, result) config_module.save(config) + uploaded_count = result.uploaded append_log( f"\nDone — uploaded {result.uploaded}, " f"skipped {result.skipped}, no steps {result.no_steps}, " @@ -299,6 +335,8 @@ def run_upload_bryton_workouts(bryton_password: str, api_key: str) -> None: except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(workouts=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -432,6 +470,7 @@ async def on_upload_bryton_workouts_click(_: ft.ControlEvent) -> None: ), ], ), + stats_card, action_area, progress, log_panel, diff --git a/tests/test_config.py b/tests/test_config.py index c54f03d..4e63522 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,11 @@ def test_defaults(): assert cfg.uploaded_workouts == {} assert cfg.uploaded_bryton_workouts == {} assert cfg.workout_days_ahead == 1 + assert cfg.lifetime_activities_uploaded == 0 + assert cfg.lifetime_workouts_uploaded == 0 + assert cfg.celebrated_milestones == [] + assert cfg.stats_seeded is False + assert config_module.total_uploads(cfg) == 0 assert config_module.any_source_enabled(cfg) is True @@ -110,6 +115,50 @@ def test_load_migrates_activity_source_igpsport(tmp_path, monkeypatch): assert loaded.igp_user == "u@x.com" +def test_load_seeds_workout_stats_from_maps(tmp_path, monkeypatch): + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "uploaded_workouts": {"evt1": 101, "evt2": 102}, + "uploaded_bryton_workouts": {"evt3": "file-a"}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + loaded = config_module.load() + assert loaded.lifetime_workouts_uploaded == 3 + assert loaded.lifetime_activities_uploaded == 0 + assert loaded.stats_seeded is True + saved = json.loads(path.read_text(encoding="utf-8")) + assert saved["lifetime_workouts_uploaded"] == 3 + assert saved["stats_seeded"] is True + + +def test_save_and_load_lifetime_stats(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig( + lifetime_activities_uploaded=42, + lifetime_workouts_uploaded=8, + celebrated_milestones=[1, 5, 10], + stats_seeded=True, + ) + config_module.save(cfg) + + loaded = config_module.load() + assert loaded.lifetime_activities_uploaded == 42 + assert loaded.lifetime_workouts_uploaded == 8 + assert loaded.celebrated_milestones == [1, 5, 10] + assert loaded.stats_seeded is True + assert config_module.total_uploads(loaded) == 50 + + def test_save_and_load_igp_region(tmp_path, monkeypatch): path = tmp_path / "config.json" monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) diff --git a/tests/test_support_gamification.py b/tests/test_support_gamification.py new file mode 100644 index 0000000..2f3c629 --- /dev/null +++ b/tests/test_support_gamification.py @@ -0,0 +1,92 @@ +"""Tests for sync milestone gamification logic.""" + +from __future__ import annotations + +from intervalssync.gui import config as config_module +from intervalssync.gui import support_gamification as gamification + + +def test_rank_for_tiers(): + assert gamification.rank_for(0) == "Rookie" + assert gamification.rank_for(1) == "Warm-up lap" + assert gamification.rank_for(4) == "Warm-up lap" + assert gamification.rank_for(5) == "Domestique" + assert gamification.rank_for(10) == "Breakaway" + assert gamification.rank_for(25) == "Climber" + assert gamification.rank_for(50) == "Sprinter" + assert gamification.rank_for(100) == "Century rider" + assert gamification.rank_for(250) == "Grand tourer" + assert gamification.rank_for(1000) == "Grand tourer" + + +def test_next_milestone_and_progress(): + assert gamification.next_milestone(0) == 1 + assert gamification.next_milestone(1) == 5 + assert gamification.next_milestone(9) == 10 + assert gamification.next_milestone(1000) is None + + assert gamification.progress_fraction(0) == 0.0 + assert gamification.progress_fraction(1) == 0.0 + assert gamification.progress_fraction(3) == 0.5 + assert gamification.progress_fraction(5) == 0.0 + assert gamification.progress_fraction(1000) == 1.0 + + +def test_record_uploads_increments_and_saves(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig( + lifetime_activities_uploaded=1, + celebrated_milestones=[1], + stats_seeded=True, + ) + assert gamification.record_uploads(cfg, activities=3) is None + assert cfg.lifetime_activities_uploaded == 4 + assert config_module.total_uploads(cfg) == 4 + + milestone = gamification.record_uploads(cfg, activities=1) + assert milestone == 5 + assert cfg.lifetime_activities_uploaded == 5 + assert cfg.celebrated_milestones == [1, 5] + + assert gamification.record_uploads(cfg, workouts=1) is None + assert cfg.lifetime_workouts_uploaded == 1 + assert config_module.total_uploads(cfg) == 6 + + +def test_record_uploads_skips_zero_and_repeat_milestones(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig( + lifetime_activities_uploaded=9, + celebrated_milestones=[1, 5], + stats_seeded=True, + ) + milestone = gamification.record_uploads(cfg, activities=1) + assert milestone == 10 + assert cfg.celebrated_milestones == [1, 5, 10] + + assert gamification.record_uploads(cfg, activities=0) is None + assert gamification.record_uploads(cfg, activities=1) is None + + +def test_record_uploads_returns_highest_crossed_milestone(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig(stats_seeded=True) + milestone = gamification.record_uploads(cfg, activities=10) + assert milestone == 10 + assert cfg.lifetime_activities_uploaded == 10 + assert cfg.celebrated_milestones == [10] + + +def test_milestone_title(): + assert gamification.milestone_title(1) == "First transfer!" + assert gamification.milestone_title(100) == "Century!" + assert gamification.milestone_title(999) == "999 transfers!" From cacb4eef95178e7e7319613ecd5f4dc2263b2248 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Sat, 20 Jun 2026 19:28:11 +0100 Subject: [PATCH 2/8] refactor(gui): remove Ko-fi link from About dialog Co-authored-by: Cursor --- src/intervalssync/gui/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/intervalssync/gui/app.py b/src/intervalssync/gui/app.py index ae3804e..5e890c5 100644 --- a/src/intervalssync/gui/app.py +++ b/src/intervalssync/gui/app.py @@ -182,7 +182,6 @@ def show_about(_: ft.ControlEvent | None = None) -> None: "GitHub", url="https://github.com/jorge-huxley/intervalssync", ), - ft.TextButton("Ko-fi", url=support_gamification.KOFI_URL), ft.TextButton("Close", on_click=lambda _: page.pop_dialog()), ], ) From e0b6cb20890c4b8694f2e03fb384d403cc015191 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 7 Jul 2026 15:47:17 +0100 Subject: [PATCH 3/8] feat(gui): confirm before opening Ko-fi from header Show a support dialog from the header coffee icon so Ko-fi opens only when the user chooses to proceed. Co-authored-by: Cursor --- src/intervalssync/gui/support_gamification.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py index c244253..2caf058 100644 --- a/src/intervalssync/gui/support_gamification.py +++ b/src/intervalssync/gui/support_gamification.py @@ -223,13 +223,40 @@ def build_stats_card( ) +async def show_kofi_dialog(page: ft.Page) -> None: + colors = theme.palette(page) + + page.show_dialog( + ft.AlertDialog( + modal=True, + shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD), + title=theme.display_text("Support development", size=22), + content=ft.Text( + "If Intervals Sync saves you time, consider supporting development on Ko-fi. " + "Tips help fund bug fixes, releases, and new features.", + size=13, + color=colors["text_muted"], + ), + actions=[ + ft.TextButton("Not now", on_click=lambda _: page.pop_dialog()), + ft.TextButton("Support on Ko-fi", url=KOFI_URL), + ], + ) + ) + page.update() + + def kofi_header_button(page: ft.Page) -> ft.IconButton: colors = theme.palette(page) + + async def on_click(_: ft.ControlEvent) -> None: + await show_kofi_dialog(page) + return ft.IconButton( icon=ft.Icons.LOCAL_CAFE_OUTLINED, tooltip="Support on Ko-fi", icon_color=colors["text_muted"], - url=KOFI_URL, + on_click=on_click, ) From c4028b99aceac69012ab726f8510d74a02075e61 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 7 Jul 2026 16:01:40 +0100 Subject: [PATCH 4/8] refactor(gui): move stats card and trim early milestones Place the lifetime stats card below the activity log and drop celebration popups at 1 and 10 transfers. Co-authored-by: Cursor --- src/intervalssync/gui/support_gamification.py | 4 +-- src/intervalssync/gui/sync_view.py | 2 +- tests/test_support_gamification.py | 26 +++++++++---------- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py index 2caf058..2e28c4e 100644 --- a/src/intervalssync/gui/support_gamification.py +++ b/src/intervalssync/gui/support_gamification.py @@ -11,12 +11,10 @@ KOFI_URL = "https://ko-fi.com/jorge_huxley" -MILESTONES = [1, 5, 10, 25, 50, 100, 250, 500, 1000] +MILESTONES = [5, 25, 50, 100, 250, 500, 1000] _MILESTONE_TITLES: dict[int, str] = { - 1: "First transfer!", 5: "Domestique status!", - 10: "Breakaway!", 25: "Quarter century!", 50: "Half century!", 100: "Century!", diff --git a/src/intervalssync/gui/sync_view.py b/src/intervalssync/gui/sync_view.py index 7740db8..a32b9a2 100644 --- a/src/intervalssync/gui/sync_view.py +++ b/src/intervalssync/gui/sync_view.py @@ -470,10 +470,10 @@ async def on_upload_bryton_workouts_click(_: ft.ControlEvent) -> None: ), ], ), - stats_card, action_area, progress, log_panel, + stats_card, ft.Container(height=theme.SPACE_MD), ], ) diff --git a/tests/test_support_gamification.py b/tests/test_support_gamification.py index 2f3c629..8f6f8e1 100644 --- a/tests/test_support_gamification.py +++ b/tests/test_support_gamification.py @@ -20,14 +20,14 @@ def test_rank_for_tiers(): def test_next_milestone_and_progress(): - assert gamification.next_milestone(0) == 1 + assert gamification.next_milestone(0) == 5 assert gamification.next_milestone(1) == 5 - assert gamification.next_milestone(9) == 10 + assert gamification.next_milestone(9) == 25 assert gamification.next_milestone(1000) is None assert gamification.progress_fraction(0) == 0.0 - assert gamification.progress_fraction(1) == 0.0 - assert gamification.progress_fraction(3) == 0.5 + assert gamification.progress_fraction(1) == 0.2 + assert gamification.progress_fraction(3) == 0.6 assert gamification.progress_fraction(5) == 0.0 assert gamification.progress_fraction(1000) == 1.0 @@ -62,13 +62,13 @@ def test_record_uploads_skips_zero_and_repeat_milestones(tmp_path, monkeypatch): monkeypatch.setattr(config_module, "CONFIG_PATH", path) cfg = config_module.AppConfig( - lifetime_activities_uploaded=9, - celebrated_milestones=[1, 5], + lifetime_activities_uploaded=24, + celebrated_milestones=[5], stats_seeded=True, ) milestone = gamification.record_uploads(cfg, activities=1) - assert milestone == 10 - assert cfg.celebrated_milestones == [1, 5, 10] + assert milestone == 25 + assert cfg.celebrated_milestones == [5, 25] assert gamification.record_uploads(cfg, activities=0) is None assert gamification.record_uploads(cfg, activities=1) is None @@ -80,13 +80,13 @@ def test_record_uploads_returns_highest_crossed_milestone(tmp_path, monkeypatch) monkeypatch.setattr(config_module, "CONFIG_PATH", path) cfg = config_module.AppConfig(stats_seeded=True) - milestone = gamification.record_uploads(cfg, activities=10) - assert milestone == 10 - assert cfg.lifetime_activities_uploaded == 10 - assert cfg.celebrated_milestones == [10] + milestone = gamification.record_uploads(cfg, activities=25) + assert milestone == 25 + assert cfg.lifetime_activities_uploaded == 25 + assert cfg.celebrated_milestones == [25] def test_milestone_title(): - assert gamification.milestone_title(1) == "First transfer!" + assert gamification.milestone_title(5) == "Domestique status!" assert gamification.milestone_title(100) == "Century!" assert gamification.milestone_title(999) == "999 transfers!" From f6ed2fd38e28ce95571f51ccebf6a839b2ece62e Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 7 Jul 2026 22:26:58 +0100 Subject: [PATCH 5/8] feat(gui): add env-gated milestone dev panel Expose a sync-page dev panel when INTERVALSSYNC_DEV_GAMIFICATION=1 to simulate uploads, reset stats, and preview milestone popups. Co-authored-by: Cursor --- src/intervalssync/gui/support_gamification.py | 151 ++++++++++++++++++ src/intervalssync/gui/sync_view.py | 5 + 2 files changed, 156 insertions(+) diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py index 2e28c4e..1b3d226 100644 --- a/src/intervalssync/gui/support_gamification.py +++ b/src/intervalssync/gui/support_gamification.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from dataclasses import dataclass import flet as ft @@ -291,3 +292,153 @@ async def show_milestone_dialog(page: ft.Page, milestone: int) -> None: ) ) page.update() + + +# --- Dev-only milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1) --- + + +def dev_mode_enabled() -> bool: + return os.environ.get("INTERVALSSYNC_DEV_GAMIFICATION", "").strip().lower() in { + "1", + "true", + "yes", + } + + +def dev_reset_stats(config: config_module.AppConfig) -> None: + config.lifetime_activities_uploaded = 0 + config.lifetime_workouts_uploaded = 0 + config.celebrated_milestones = [] + config.stats_seeded = True + config_module.save(config) + + +def dev_set_total(config: config_module.AppConfig, total: int) -> None: + total = max(0, total) + config.lifetime_activities_uploaded = total + config.lifetime_workouts_uploaded = 0 + config.celebrated_milestones = [milestone for milestone in MILESTONES if milestone <= total] + config.stats_seeded = True + config_module.save(config) + + +def dev_bump_and_maybe_celebrate( + page: ft.Page, + config: config_module.AppConfig, + stats_refs: StatsCardRefs, + *, + activities: int = 0, + workouts: int = 0, +) -> None: + milestone = record_uploads(config, activities=activities, workouts=workouts) + update_stats_display(page, config, stats_refs) + if milestone is None: + page.update() + return + + async def _celebrate() -> None: + await show_milestone_dialog(page, milestone) + + page.run_task(_celebrate) + + +def build_dev_milestone_panel( + page: ft.Page, + config: config_module.AppConfig, + stats_refs: StatsCardRefs, +) -> ft.Container: + colors = theme.palette(page) + total_field = ft.TextField( + label="Total transfers", + value=str(total_uploads(config)), + keyboard_type=ft.KeyboardType.NUMBER, + width=140, + dense=True, + ) + + def _refresh_total_field() -> None: + total_field.value = str(total_uploads(config)) + + def on_reset(_: ft.ControlEvent) -> None: + dev_reset_stats(config) + update_stats_display(page, config, stats_refs) + _refresh_total_field() + page.update() + + def on_bump(activities: int = 0, workouts: int = 0): + def handler(_: ft.ControlEvent) -> None: + dev_bump_and_maybe_celebrate( + page, + config, + stats_refs, + activities=activities, + workouts=workouts, + ) + _refresh_total_field() + page.update() + + return handler + + def on_apply_total(_: ft.ControlEvent) -> None: + try: + total = int((total_field.value or "0").strip()) + except ValueError: + return + dev_set_total(config, total) + update_stats_display(page, config, stats_refs) + _refresh_total_field() + page.update() + + def on_preview(milestone: int): + async def handler(_: ft.ControlEvent) -> None: + await show_milestone_dialog(page, milestone) + + return handler + + preview_buttons = [ + ft.TextButton(str(milestone), on_click=on_preview(milestone)) + for milestone in MILESTONES + ] + + return ft.Container( + content=ft.Column( + spacing=theme.SPACE_SM, + controls=[ + ft.Text( + "DEV milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1)", + size=11, + weight=ft.FontWeight.W_600, + color=ft.Colors.ORANGE_400, + ), + ft.Row( + wrap=True, + spacing=theme.SPACE_SM, + controls=[ + ft.OutlinedButton("+1 activity", on_click=on_bump(activities=1)), + ft.OutlinedButton("+1 workout", on_click=on_bump(workouts=1)), + ft.OutlinedButton("+5 transfers", on_click=on_bump(activities=5)), + ft.OutlinedButton("Reset stats", on_click=on_reset), + ], + ), + ft.Row( + spacing=theme.SPACE_SM, + controls=[ + total_field, + ft.OutlinedButton("Set total", on_click=on_apply_total), + ], + ), + ft.Row( + wrap=True, + spacing=0, + controls=[ + ft.Text("Preview popup:", size=11, color=colors["text_muted"]), + *preview_buttons, + ], + ), + ], + ), + padding=theme.SPACE_MD, + bgcolor=colors["surface_alt"], + border=ft.Border.all(1, ft.Colors.ORANGE_400), + border_radius=theme.RADIUS_SM, + ) diff --git a/src/intervalssync/gui/sync_view.py b/src/intervalssync/gui/sync_view.py index a32b9a2..4e6fe32 100644 --- a/src/intervalssync/gui/sync_view.py +++ b/src/intervalssync/gui/sync_view.py @@ -474,6 +474,11 @@ async def on_upload_bryton_workouts_click(_: ft.ControlEvent) -> None: progress, log_panel, stats_card, + *( + [support_gamification.build_dev_milestone_panel(page, config, stats_refs)] + if support_gamification.dev_mode_enabled() + else [] + ), ft.Container(height=theme.SPACE_MD), ], ) From a83362177d49b451f043913334e0f2f857edcdb8 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 7 Jul 2026 22:36:24 +0100 Subject: [PATCH 6/8] copy(gui): refresh milestone popup messaging Use clearer transfer-count wording and a softer Ko-fi ask in milestone celebration dialogs. Co-authored-by: Cursor --- src/intervalssync/gui/support_gamification.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py index 1b3d226..d508e73 100644 --- a/src/intervalssync/gui/support_gamification.py +++ b/src/intervalssync/gui/support_gamification.py @@ -273,13 +273,14 @@ async def show_milestone_dialog(page: ft.Page, milestone: int) -> None: spacing=theme.SPACE_SM, controls=[ ft.Text( - f"You've synced {milestone} activities and workouts with Intervals Sync. " + f"You've completed {milestone} transfers with Intervals Sync. " "Nice work keeping your training data flowing.", size=13, color=colors["text_muted"], ), ft.Text( - "If this app saves you time, consider supporting development on Ko-fi.", + "If Intervals Sync saves you time, a small Ko-fi helps keep " + "bug fixes and new features coming.", size=13, color=colors["text_muted"], ), From 58fd314e32d57bfb2435abec19d0fe6c07f04670 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 7 Jul 2026 23:11:06 +0100 Subject: [PATCH 7/8] feat(gui): celebrate sync milestones with confetti and number-hero popup Co-authored-by: Cursor --- src/intervalssync/gui/support_gamification.py | 199 ++++++++++++++++-- 1 file changed, 178 insertions(+), 21 deletions(-) diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py index d508e73..deb0086 100644 --- a/src/intervalssync/gui/support_gamification.py +++ b/src/intervalssync/gui/support_gamification.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio +import math import os +import random from dataclasses import dataclass import flet as ft @@ -24,6 +27,33 @@ 1000: "Legend status!", } +_MILESTONE_MESSAGES: dict[int, str] = { + 5: "Five activities synced on autopilot. Your training log just got a lot easier.", + 25: "Twenty-five rides across, hands-free. You're in a proper rhythm now.", + 50: "Fifty transfers done — that's a serious stack of saved clicks.", + 100: "One hundred activities on autopilot. Every ride, right where it belongs.", + 250: "250 syncs deep. Your data flows like a freshly-oiled drivetrain.", + 500: "Five hundred transfers. That's real dedication — and a lot of saved time.", + 1000: "One thousand syncs. You've officially reached legend status.", +} + +_KOFI_LINE = ( + "Intervals Sync is free and built in spare time. " + "A small Ko-fi keeps it rolling." +) + +_CONFETTI_COLORS = ( + theme.ACCENT, + theme.ACCENT_LIGHT, + "#F2C94C", + "#27AE60", + "#2D9CDB", + "#BB6BD9", +) + +_CELEBRATION_WIDTH = 320 +_CELEBRATION_HEIGHT = 340 + @dataclass class StatsCardRefs: @@ -87,6 +117,14 @@ def milestone_title(milestone: int) -> str: return _MILESTONE_TITLES.get(milestone, f"{milestone} transfers!") +def milestone_message(milestone: int) -> str: + return _MILESTONE_MESSAGES.get( + milestone, + f"{milestone} activities synced on autopilot. Nice work keeping your " + "training data flowing.", + ) + + def _newly_crossed_milestone(old_total: int, new_total: int) -> int | None: crossed: int | None = None for milestone in MILESTONES: @@ -259,41 +297,160 @@ async def on_click(_: ft.ControlEvent) -> None: ) +def _build_confetti() -> list[ft.Container]: + """Small colored squares/circles that start near the top of the card.""" + particles: list[ft.Container] = [] + for _ in range(30): + size = random.randint(6, 12) + start_left = random.uniform(0, _CELEBRATION_WIDTH) + duration = random.randint(950, 1500) + particle = ft.Container( + width=size, + height=size, + bgcolor=random.choice(_CONFETTI_COLORS), + border_radius=size / 2 if random.random() > 0.5 else 2, + left=start_left, + top=random.uniform(-24, 8), + opacity=1.0, + rotate=ft.Rotate(0, alignment=ft.Alignment.CENTER), + ignore_interactions=True, + animate_position=ft.Animation( + duration=ft.Duration(milliseconds=duration), + curve=ft.AnimationCurve.EASE_IN, + ), + animate_opacity=ft.Animation( + duration=ft.Duration(milliseconds=duration), + curve=ft.AnimationCurve.EASE_IN, + ), + animate_rotation=ft.Animation( + duration=ft.Duration(milliseconds=duration), + curve=ft.AnimationCurve.LINEAR, + ), + ) + particle.data = { + "left": start_left + random.uniform(-50, 50), + "top": _CELEBRATION_HEIGHT + random.uniform(0, 60), + "angle": random.uniform(-math.pi * 3, math.pi * 3), + } + particles.append(particle) + return particles + + +async def _play_celebration( + page: ft.Page, + number_ref: ft.Ref[ft.Container], + particles: list[ft.Container], +) -> None: + await asyncio.sleep(0.05) + if number_ref.current is not None: + number_ref.current.scale = 1.0 + number_ref.current.opacity = 1.0 + for particle in particles: + target = particle.data + particle.left = target["left"] + particle.top = target["top"] + particle.opacity = 0.0 + particle.rotate = ft.Rotate(target["angle"], alignment=ft.Alignment.CENTER) + try: + page.update() + except Exception: + pass + + async def show_milestone_dialog(page: ft.Page, milestone: int) -> None: colors = theme.palette(page) title = milestone_title(milestone) + message = milestone_message(milestone) + + number_ref: ft.Ref[ft.Container] = ft.Ref() + big_number = ft.Container( + ref=number_ref, + content=theme.display_text( + str(milestone), + size=76, + color=colors["accent"], + weight=ft.FontWeight.BOLD, + ), + alignment=ft.Alignment.CENTER, + opacity=0.0, + scale=0.5, + animate_scale=ft.Animation( + duration=ft.Duration(milliseconds=520), + curve=ft.AnimationCurve.EASE_OUT_BACK, + ), + animate_opacity=ft.Animation( + duration=ft.Duration(milliseconds=360), + curve=ft.AnimationCurve.EASE_OUT, + ), + ) + + content_column = ft.Column( + tight=True, + spacing=theme.SPACE_XS, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + big_number, + ft.Text( + "SYNCS COMPLETED", + size=11, + weight=ft.FontWeight.W_600, + color=colors["text_muted"], + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=theme.SPACE_SM), + theme.display_text(title, size=24, color=colors["text"]), + ft.Container(height=2), + ft.Text( + message, + size=13, + color=colors["text_muted"], + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=theme.SPACE_MD), + ft.Text( + _KOFI_LINE, + size=12, + color=colors["text_muted"], + text_align=ft.TextAlign.CENTER, + ), + ], + ) + + particles = _build_confetti() + content = ft.Container( + width=_CELEBRATION_WIDTH, + height=_CELEBRATION_HEIGHT, + content=ft.Stack( + controls=[ + ft.Container( + width=_CELEBRATION_WIDTH, + height=_CELEBRATION_HEIGHT, + alignment=ft.Alignment.CENTER, + content=content_column, + ), + *particles, + ], + ), + ) page.show_dialog( ft.AlertDialog( modal=True, shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD), - title=theme.display_text(title, size=22), - content=ft.Column( - tight=True, - spacing=theme.SPACE_SM, - controls=[ - ft.Text( - f"You've completed {milestone} transfers with Intervals Sync. " - "Nice work keeping your training data flowing.", - size=13, - color=colors["text_muted"], - ), - ft.Text( - "If Intervals Sync saves you time, a small Ko-fi helps keep " - "bug fixes and new features coming.", - size=13, - color=colors["text_muted"], - ), - ], - ), + content=content, actions=[ - ft.TextButton("Support on Ko-fi", url=KOFI_URL), - ft.TextButton("Keep rolling", on_click=lambda _: page.pop_dialog()), + ft.TextButton("Buy me a coffee", url=KOFI_URL), + ft.FilledButton( + "Keep rolling", + on_click=lambda _: page.pop_dialog(), + ), ], ) ) page.update() + page.run_task(_play_celebration, page, number_ref, particles) + # --- Dev-only milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1) --- From c7293cae1406fcd7ff3523c28cf38485af5bb4a6 Mon Sep 17 00:00:00 2001 From: Jorge Silva Date: Tue, 7 Jul 2026 23:37:24 +0100 Subject: [PATCH 8/8] feat(gui): redesign lifetime sync stats card as metric readout Co-authored-by: Cursor --- src/intervalssync/gui/support_gamification.py | 126 +++++++++++++----- src/intervalssync/gui/sync_view.py | 6 +- 2 files changed, 95 insertions(+), 37 deletions(-) diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py index deb0086..7737d37 100644 --- a/src/intervalssync/gui/support_gamification.py +++ b/src/intervalssync/gui/support_gamification.py @@ -57,9 +57,11 @@ @dataclass class StatsCardRefs: - headline: ft.Ref[ft.Text] + number: ft.Ref[ft.Text] + rank: ft.Ref[ft.Text] breakdown: ft.Ref[ft.Text] - progress_label: ft.Ref[ft.Text] + target: ft.Ref[ft.Text] + remaining: ft.Ref[ft.Text] progress_bar: ft.Ref[ft.ProgressBar] @@ -157,13 +159,6 @@ def record_uploads( return None -def _headline_text(config: config_module.AppConfig) -> str: - total = total_uploads(config) - rank = rank_for(total) - transfer_word = "transfer" if total == 1 else "transfers" - return f"{rank} · {total} {transfer_word}" - - def _breakdown_text(config: config_module.AppConfig) -> str: activities = config.lifetime_activities_uploaded workouts = config.lifetime_workouts_uploaded @@ -172,13 +167,20 @@ def _breakdown_text(config: config_module.AppConfig) -> str: return f"{activities} {activity_word} · {workouts} {workout_word}" -def _progress_label_text(total: int) -> str: +def _target_text(total: int) -> str: + nxt = next_milestone(total) + if nxt is None: + return "Every milestone cleared" + return f"Next milestone · {nxt}" + + +def _remaining_text(total: int) -> str: nxt = next_milestone(total) if nxt is None: - return "All milestones reached — you're a legend" + return "Legend" remaining = nxt - total - transfer_word = "transfer" if remaining == 1 else "transfers" - return f"{remaining} {transfer_word} to next milestone" + word = "sync" if remaining == 1 else "syncs" + return f"{remaining} {word} to go" def update_stats_display( @@ -187,12 +189,16 @@ def update_stats_display( refs: StatsCardRefs, ) -> None: total = total_uploads(config) - if refs.headline.current: - refs.headline.current.value = _headline_text(config) + if refs.number.current: + refs.number.current.value = str(total) + if refs.rank.current: + refs.rank.current.value = rank_for(total).upper() if refs.breakdown.current: refs.breakdown.current.value = _breakdown_text(config) - if refs.progress_label.current: - refs.progress_label.current.value = _progress_label_text(total) + if refs.target.current: + refs.target.current.value = _target_text(total) + if refs.remaining.current: + refs.remaining.current.value = _remaining_text(total) if refs.progress_bar.current: refs.progress_bar.current.value = progress_fraction(total) page.update() @@ -206,43 +212,93 @@ def build_stats_card( colors = theme.palette(page) total = total_uploads(config) - headline = ft.Text( - _headline_text(config), - ref=refs.headline, - size=15, - weight=ft.FontWeight.W_600, - font_family=f"{theme.FONT_BODY}Medium", - color=colors["text"], + # Hero: a bike-computer-style metric readout — big number, tiny eyebrow. + number = ft.Text( + str(total), + ref=refs.number, + size=46, + color=colors["accent"], + weight=ft.FontWeight.BOLD, + font_family=theme.FONT_DISPLAY, + ) + eyebrow = ft.Text( + "LIFETIME SYNCS", + size=10, + weight=ft.FontWeight.W_700, + color=colors["text_muted"], + style=ft.TextStyle(letter_spacing=1.6), + ) + hero = ft.Column(spacing=0, tight=True, controls=[number, eyebrow]) + + rank_chip = ft.Container( + content=ft.Text( + rank_for(total).upper(), + ref=refs.rank, + size=11, + weight=ft.FontWeight.W_700, + color=colors["accent"], + style=ft.TextStyle(letter_spacing=0.8), + ), + padding=ft.Padding(theme.SPACE_SM, 5, theme.SPACE_SM, 5), + bgcolor=colors["accent_soft"], + border_radius=999, ) breakdown = ft.Text( _breakdown_text(config), ref=refs.breakdown, - size=12, + size=11, color=colors["text_muted"], + text_align=ft.TextAlign.RIGHT, + ) + meta = ft.Column( + spacing=theme.SPACE_XS, + horizontal_alignment=ft.CrossAxisAlignment.END, + controls=[rank_chip, breakdown], + ) + + top_row = ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.START, + controls=[hero, meta], ) - progress_label = ft.Text( - _progress_label_text(total), - ref=refs.progress_label, + + target = ft.Text( + _target_text(total), + ref=refs.target, size=11, + weight=ft.FontWeight.W_600, color=colors["text_muted"], + font_family=f"{theme.FONT_BODY}Medium", + ) + remaining = ft.Text( + _remaining_text(total), + ref=refs.remaining, + size=11, + color=colors["text_muted"], + ) + progress_header = ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + controls=[target, remaining], ) progress_bar = ft.ProgressBar( ref=refs.progress_bar, value=progress_fraction(total), color=colors["accent"], bgcolor=colors["surface_alt"], - bar_height=4, - border_radius=2, + bar_height=6, + border_radius=3, + ) + progress = ft.Column( + spacing=theme.SPACE_XS, + controls=[progress_header, progress_bar], ) return ft.Container( content=ft.Column( - spacing=theme.SPACE_SM, + spacing=theme.SPACE_MD, controls=[ - headline, - breakdown, - progress_label, - progress_bar, + top_row, + progress, ft.TextButton( "Saved you time? Buy me a coffee", url=KOFI_URL, diff --git a/src/intervalssync/gui/sync_view.py b/src/intervalssync/gui/sync_view.py index 4e6fe32..54a1b13 100644 --- a/src/intervalssync/gui/sync_view.py +++ b/src/intervalssync/gui/sync_view.py @@ -38,9 +38,11 @@ def build_sync_view( ) log = ft.ListView(spacing=4, auto_scroll=True, height=280, expand=False) stats_refs = support_gamification.StatsCardRefs( - headline=ft.Ref[ft.Text](), + number=ft.Ref[ft.Text](), + rank=ft.Ref[ft.Text](), breakdown=ft.Ref[ft.Text](), - progress_label=ft.Ref[ft.Text](), + target=ft.Ref[ft.Text](), + remaining=ft.Ref[ft.Text](), progress_bar=ft.Ref[ft.ProgressBar](), ) stats_card = support_gamification.build_stats_card(page, config, stats_refs)