intervalssync 0.9.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. intervalssync/__init__.py +3 -0
  2. intervalssync/_dropbox_app_key.py +7 -0
  3. intervalssync/_version.py +1 -0
  4. intervalssync/bryton/__init__.py +1 -0
  5. intervalssync/bryton/api.py +49 -0
  6. intervalssync/bryton/core.py +249 -0
  7. intervalssync/bryton/ddp.py +231 -0
  8. intervalssync/bryton/exceptions.py +13 -0
  9. intervalssync/bryton/fit_encode.py +363 -0
  10. intervalssync/bryton/workout.py +342 -0
  11. intervalssync/cli/__init__.py +5 -0
  12. intervalssync/cli/config.py +53 -0
  13. intervalssync/cli/env.py +189 -0
  14. intervalssync/cli/main.py +637 -0
  15. intervalssync/dropbox_client.py +143 -0
  16. intervalssync/gui/__init__.py +1 -0
  17. intervalssync/gui/app.py +352 -0
  18. intervalssync/gui/config.py +102 -0
  19. intervalssync/gui/profile_sync_ui.py +225 -0
  20. intervalssync/gui/secrets.py +135 -0
  21. intervalssync/gui/settings_view.py +716 -0
  22. intervalssync/gui/support_gamification.py +658 -0
  23. intervalssync/gui/sync_view.py +486 -0
  24. intervalssync/gui/system.py +21 -0
  25. intervalssync/gui/theme.py +216 -0
  26. intervalssync/gui/update_check.py +55 -0
  27. intervalssync/igpsport/__init__.py +1 -0
  28. intervalssync/igpsport/core.py +520 -0
  29. intervalssync/igpsport/interval_info.py +269 -0
  30. intervalssync/igpsport/profile_sync.py +391 -0
  31. intervalssync/igpsport/region.py +80 -0
  32. intervalssync/igpsport/workout.py +503 -0
  33. intervalssync/igpsport/zone_map.py +143 -0
  34. intervalssync/intervals_icu.py +201 -0
  35. intervalssync-0.9.3.dist-info/METADATA +179 -0
  36. intervalssync-0.9.3.dist-info/RECORD +39 -0
  37. intervalssync-0.9.3.dist-info/WHEEL +4 -0
  38. intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
  39. intervalssync-0.9.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,143 @@
1
+ """Dropbox OAuth and upload helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import dropbox
9
+ from dropbox import DropboxOAuth2FlowNoRedirect
10
+ from dropbox.exceptions import ApiError
11
+ from dropbox.files import FileMetadata, WriteMode
12
+ from requests.exceptions import HTTPError
13
+
14
+ from ._dropbox_app_key import DROPBOX_APP_KEY as BUILD_DROPBOX_APP_KEY
15
+
16
+ DROPBOX_APP_KEY_ENV = "IGPSYNC_DROPBOX_APP_KEY"
17
+ DEFAULT_DROPBOX_FOLDER = "/intervalssync-fit"
18
+
19
+ def get_dropbox_app_key() -> str | None:
20
+ """Return the Dropbox app key from env or the build-stamped constant."""
21
+ return os.getenv(DROPBOX_APP_KEY_ENV) or BUILD_DROPBOX_APP_KEY or None
22
+
23
+
24
+ def normalize_folder(folder: str) -> str:
25
+ """Return a leading-slash, no-trailing-slash Dropbox folder path."""
26
+ clean = (folder or DEFAULT_DROPBOX_FOLDER).strip()
27
+ if not clean.startswith("/"):
28
+ clean = f"/{clean}"
29
+ return clean.rstrip("/")
30
+
31
+
32
+ def dropbox_path_for(folder: str, filename: str) -> str:
33
+ """Return the Dropbox path for a FIT filename."""
34
+ return f"{normalize_folder(folder)}/{filename}"
35
+
36
+
37
+ def start_dropbox_auth(app_key: str) -> tuple[DropboxOAuth2FlowNoRedirect, str]:
38
+ """Start a PKCE OAuth flow and return the flow object plus auth URL."""
39
+ auth_flow = DropboxOAuth2FlowNoRedirect(
40
+ app_key,
41
+ use_pkce=True,
42
+ token_access_type="offline",
43
+ # files.metadata.read lets us list the folder to skip rides already there.
44
+ scope=[
45
+ "account_info.read",
46
+ "files.metadata.read",
47
+ "files.content.write",
48
+ ],
49
+ )
50
+ return auth_flow, auth_flow.start()
51
+
52
+
53
+ def _friendly_auth_error(exc: HTTPError) -> str:
54
+ """Turn a token-exchange HTTPError into a user-facing message.
55
+
56
+ The Dropbox SDK calls ``raise_for_status()`` and drops the response body,
57
+ so a failed exchange surfaces only a generic "400 Client Error". The most
58
+ common failure with the copy-paste flow is an expired/used code
59
+ (``invalid_grant``) — give that a friendly hint and fall back to Dropbox's
60
+ raw error text for anything unexpected.
61
+ """
62
+ body = ""
63
+ if exc.response is not None:
64
+ body = exc.response.text.strip()
65
+ if "invalid_grant" in body:
66
+ return (
67
+ "That authorization code expired or was already used. Tap "
68
+ "Connect Dropbox again and paste the new code quickly."
69
+ )
70
+ return f"{exc} — {body}" if body else str(exc)
71
+
72
+
73
+ def finish_dropbox_auth(
74
+ auth_flow: DropboxOAuth2FlowNoRedirect, auth_code: str
75
+ ) -> str | None:
76
+ """Finish OAuth and return the long-lived refresh token."""
77
+ try:
78
+ result = auth_flow.finish(auth_code.strip())
79
+ except HTTPError as exc:
80
+ raise RuntimeError(_friendly_auth_error(exc)) from exc
81
+ return result.refresh_token
82
+
83
+
84
+ def upload_to_dropbox(
85
+ fit_path: Path,
86
+ filename: str,
87
+ refresh_token: str,
88
+ app_key: str,
89
+ folder: str = DEFAULT_DROPBOX_FOLDER,
90
+ ) -> None:
91
+ """Upload a FIT file to Dropbox, overwriting the ride's existing file.
92
+
93
+ Raises on failure; a normal return means the upload succeeded.
94
+ """
95
+ target = dropbox_path_for(folder, filename)
96
+ with dropbox.Dropbox(
97
+ oauth2_refresh_token=refresh_token,
98
+ app_key=app_key,
99
+ timeout=100,
100
+ user_agent="intervalssync",
101
+ ) as dbx:
102
+ with fit_path.open("rb") as f:
103
+ dbx.files_upload(
104
+ f.read(),
105
+ target,
106
+ mode=WriteMode.overwrite,
107
+ mute=True,
108
+ )
109
+
110
+
111
+ def list_dropbox_fit_names(
112
+ folder: str, refresh_token: str, app_key: str
113
+ ) -> set[str]:
114
+ """Return FIT filenames already present in the folder.
115
+
116
+ Returns an empty set when the folder doesn't exist yet. Raises on other
117
+ Dropbox errors so the caller can decide whether to treat them as fatal.
118
+ """
119
+ base = normalize_folder(folder)
120
+ fit_names: set[str] = set()
121
+ with dropbox.Dropbox(
122
+ oauth2_refresh_token=refresh_token,
123
+ app_key=app_key,
124
+ timeout=100,
125
+ user_agent="intervalssync",
126
+ ) as dbx:
127
+ try:
128
+ result = dbx.files_list_folder(base)
129
+ except ApiError as exc:
130
+ if exc.error.is_path() and exc.error.get_path().is_not_found():
131
+ return fit_names
132
+ raise
133
+ entries = list(result.entries)
134
+ while result.has_more:
135
+ result = dbx.files_list_folder_continue(result.cursor)
136
+ entries.extend(result.entries)
137
+
138
+ for entry in entries:
139
+ if not isinstance(entry, FileMetadata):
140
+ continue
141
+ if entry.name.lower().endswith(".fit"):
142
+ fit_names.add(entry.name)
143
+ return fit_names
@@ -0,0 +1 @@
1
+ """Flet GUI for intervalssync."""
@@ -0,0 +1,352 @@
1
+ """Flet application entry point and view routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import flet as ft
11
+ from flet_permission_handler import Permission, PermissionHandler, PermissionStatus
12
+ from flet_secure_storage import SecureStorage
13
+
14
+ from .. import __version__
15
+ from . import config as config_module
16
+ from . import secrets as secrets_module
17
+ from .update_check import RELEASES_PAGE, check_for_update
18
+ from .settings_view import build_settings_view
19
+ from .sync_view import build_sync_view
20
+ from . import profile_sync_ui
21
+ from . import support_gamification
22
+ from . import theme
23
+
24
+
25
+ _DESKTOP = {ft.PagePlatform.WINDOWS, ft.PagePlatform.MACOS, ft.PagePlatform.LINUX}
26
+ _MOBILE = {ft.PagePlatform.ANDROID, ft.PagePlatform.ANDROID_TV, ft.PagePlatform.IOS}
27
+ _PERMISSION_HANDLER_PLATFORMS = {
28
+ ft.PagePlatform.ANDROID,
29
+ ft.PagePlatform.ANDROID_TV,
30
+ ft.PagePlatform.IOS,
31
+ ft.PagePlatform.WINDOWS,
32
+ *([ft.PagePlatform.WEB] if hasattr(ft.PagePlatform, "WEB") else []),
33
+ }
34
+ # Standard public Downloads directory on Android (the filesystem name is the
35
+ # singular "Download"). Used when the user opts in and grants storage access.
36
+ ANDROID_DOWNLOADS = "/storage/emulated/0/Download/intervalssync-fit"
37
+
38
+ APP_TITLE = "Intervals Sync"
39
+ APP_ICON = "icon.png"
40
+
41
+
42
+ def _assets_dir() -> str:
43
+ dev_assets = Path(__file__).resolve().parents[3] / "assets"
44
+ if dev_assets.is_dir():
45
+ return str(dev_assets)
46
+ return "assets"
47
+
48
+ _TAB_SYNC = 0
49
+ _TAB_SETTINGS = 1
50
+
51
+
52
+ async def _has_credentials(
53
+ config: config_module.AppConfig,
54
+ store: secrets_module.SecretStore,
55
+ ) -> bool:
56
+ if config.enable_igpsport and config.igp_user and await store.get(secrets_module.IGP_PASSWORD):
57
+ return True
58
+ if config.enable_bryton and config.bryton_user and await store.get(secrets_module.BRYTON_PASSWORD):
59
+ return True
60
+ return False
61
+
62
+
63
+ def _supports_permission_handler(platform: ft.PagePlatform) -> bool:
64
+ return platform in _PERMISSION_HANDLER_PLATFORMS
65
+
66
+
67
+ def _secret_store_for_platform(
68
+ platform: ft.PagePlatform,
69
+ ) -> tuple[secrets_module.SecretStore, SecureStorage | None]:
70
+ if platform == ft.PagePlatform.MACOS:
71
+ return secrets_module.MacOSKeychainStore(), None
72
+
73
+ storage = SecureStorage()
74
+ return secrets_module.FletSecureStorage(storage), storage
75
+
76
+
77
+ async def _app(page: ft.Page) -> None:
78
+ page.title = APP_TITLE
79
+ page.theme_mode = ft.ThemeMode.SYSTEM
80
+ theme.apply_page_theme(page)
81
+ page.padding = 0
82
+ page.bgcolor = theme.palette(page)["bg"]
83
+
84
+ if page.platform in _DESKTOP:
85
+ page.window.width = 600
86
+ page.window.height = 760
87
+ page.window.min_width = 400
88
+ page.window.min_height = 560
89
+
90
+ config = config_module.load()
91
+
92
+ store, storage = _secret_store_for_platform(page.platform)
93
+ perms = PermissionHandler() if _supports_permission_handler(page.platform) else None
94
+ if storage is not None:
95
+ page.services.append(storage)
96
+ if perms is not None:
97
+ page.services.append(perms)
98
+
99
+ def _private_download_dir() -> str:
100
+ base = os.getenv("FLET_APP_STORAGE_DATA") or tempfile.gettempdir()
101
+ return str(Path(base) / "intervalssync-fit")
102
+
103
+ async def apply_download_location() -> None:
104
+ if page.platform not in _MOBILE:
105
+ return
106
+ config.download_dir = _private_download_dir()
107
+ if config.save_to_downloads:
108
+ try:
109
+ status = await perms.get_status(Permission.MANAGE_EXTERNAL_STORAGE)
110
+ except Exception: # noqa: BLE001 — never let this break startup
111
+ status = None
112
+ if status == PermissionStatus.GRANTED:
113
+ config.download_dir = ANDROID_DOWNLOADS
114
+
115
+ await apply_download_location()
116
+
117
+ body = ft.Container(expand=True)
118
+ header_slot = ft.Container()
119
+ current_tab = _TAB_SYNC
120
+ is_mobile = page.platform in _MOBILE
121
+
122
+ def _refresh_bg() -> None:
123
+ page.bgcolor = theme.palette(page)["bg"]
124
+
125
+ def _scrollable(view: ft.Control) -> ft.Control:
126
+ return ft.Column([view], scroll=ft.ScrollMode.AUTO, expand=True)
127
+
128
+ async def open_releases(_: ft.ControlEvent) -> None:
129
+ await page.launch_url(RELEASES_PAGE)
130
+
131
+ def notify_update(latest: str | None, *, quiet_when_current: bool) -> None:
132
+ if latest:
133
+ page.show_dialog(
134
+ ft.SnackBar(
135
+ content=ft.Text(f"Update available: v{latest}"),
136
+ action="View",
137
+ on_action=open_releases,
138
+ duration=8000,
139
+ )
140
+ )
141
+ elif not quiet_when_current:
142
+ page.show_dialog(ft.SnackBar(ft.Text("You're on the latest version.")))
143
+ page.update()
144
+
145
+ async def check_updates_now(_: ft.ControlEvent) -> None:
146
+ page.pop_dialog()
147
+ latest = await asyncio.to_thread(check_for_update, __version__, allow_dev=True)
148
+ notify_update(latest, quiet_when_current=False)
149
+
150
+ def show_about(_: ft.ControlEvent | None = None) -> None:
151
+ colors = theme.palette(page)
152
+ page.show_dialog(
153
+ ft.AlertDialog(
154
+ modal=True,
155
+ shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD),
156
+ title=theme.display_text("About", size=22),
157
+ content=ft.Column(
158
+ tight=True,
159
+ spacing=theme.SPACE_SM,
160
+ controls=[
161
+ ft.Text(
162
+ APP_TITLE,
163
+ weight=ft.FontWeight.W_600,
164
+ color=colors["text"],
165
+ ),
166
+ ft.Text(
167
+ f"Version {__version__}",
168
+ size=13,
169
+ color=colors["text_muted"],
170
+ ),
171
+ ft.Text(
172
+ "Sync rides and planned workouts between iGPSPORT, "
173
+ "Bryton Active, and intervals.icu.",
174
+ size=13,
175
+ color=colors["text_muted"],
176
+ ),
177
+ ],
178
+ ),
179
+ actions=[
180
+ ft.TextButton("Check for updates", on_click=check_updates_now),
181
+ ft.TextButton(
182
+ "GitHub",
183
+ url="https://github.com/jorge-huxley/intervalssync",
184
+ ),
185
+ ft.TextButton("Close", on_click=lambda _: page.pop_dialog()),
186
+ ],
187
+ )
188
+ )
189
+
190
+ def _header() -> ft.Container:
191
+ colors = theme.palette(page)
192
+ return ft.Container(
193
+ content=ft.Row(
194
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
195
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
196
+ controls=[
197
+ ft.Row(
198
+ spacing=theme.SPACE_SM,
199
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
200
+ controls=[
201
+ ft.Container(
202
+ width=36,
203
+ height=36,
204
+ border_radius=theme.RADIUS_SM,
205
+ clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
206
+ content=ft.Image(
207
+ src=APP_ICON,
208
+ width=36,
209
+ height=36,
210
+ fit=ft.BoxFit.COVER,
211
+ ),
212
+ ),
213
+ ft.Column(
214
+ spacing=0,
215
+ controls=[
216
+ theme.display_text(APP_TITLE, size=18, color=colors["text"]),
217
+ ft.Text(
218
+ "iGPSPORT · Bryton · intervals.icu",
219
+ size=11,
220
+ color=colors["text_muted"],
221
+ ),
222
+ ],
223
+ ),
224
+ ],
225
+ ),
226
+ ft.Row(
227
+ spacing=0,
228
+ controls=[
229
+ support_gamification.kofi_header_button(page),
230
+ ft.IconButton(
231
+ icon=ft.Icons.INFO_OUTLINE,
232
+ tooltip="About",
233
+ icon_color=colors["text_muted"],
234
+ on_click=show_about,
235
+ ),
236
+ ],
237
+ ),
238
+ ],
239
+ ),
240
+ padding=ft.Padding(
241
+ theme.SPACE_LG,
242
+ theme.SPACE_MD,
243
+ theme.SPACE_LG,
244
+ theme.SPACE_MD,
245
+ ),
246
+ bgcolor=colors["bg"],
247
+ border=ft.Border(bottom=ft.BorderSide(1, colors["border"])),
248
+ )
249
+
250
+ async def show_sync(_: ft.ControlEvent | None = None) -> None:
251
+ nonlocal current_tab
252
+ current_tab = _TAB_SYNC
253
+ body.content = _scrollable(build_sync_view(page, config, store))
254
+ header_slot.content = _header()
255
+ _update_nav()
256
+ _refresh_bg()
257
+ page.update()
258
+
259
+ async def show_settings(_: ft.ControlEvent | None = None) -> None:
260
+ nonlocal current_tab
261
+ current_tab = _TAB_SETTINGS
262
+
263
+ async def on_profile_sync_check() -> None:
264
+ await profile_sync_ui.prompt_if_needed(page, config, store)
265
+
266
+ body.content = _scrollable(
267
+ await build_settings_view(
268
+ page,
269
+ config,
270
+ store,
271
+ on_saved=show_sync,
272
+ perms=perms,
273
+ apply_download_location=apply_download_location,
274
+ on_profile_sync_check=on_profile_sync_check,
275
+ )
276
+ )
277
+ header_slot.content = _header()
278
+ _update_nav()
279
+ _refresh_bg()
280
+ page.update()
281
+
282
+ sync_tab = ft.TextButton("Sync", on_click=show_sync)
283
+ settings_tab = ft.TextButton("Settings", on_click=show_settings)
284
+ desktop_tabs = ft.Container(
285
+ content=ft.Row(
286
+ alignment=ft.MainAxisAlignment.CENTER,
287
+ controls=[sync_tab, settings_tab],
288
+ ),
289
+ padding=ft.Padding(0, theme.SPACE_SM, 0, theme.SPACE_SM),
290
+ )
291
+
292
+ def _update_nav() -> None:
293
+ colors = theme.palette(page)
294
+ for idx, btn in enumerate((sync_tab, settings_tab)):
295
+ active = idx == current_tab
296
+ btn.style = ft.ButtonStyle(
297
+ color=colors["accent"] if active else colors["text_muted"],
298
+ bgcolor=colors["accent_soft"] if active else ft.Colors.TRANSPARENT,
299
+ shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_SM),
300
+ padding=ft.Padding(
301
+ theme.SPACE_MD, theme.SPACE_SM, theme.SPACE_MD, theme.SPACE_SM
302
+ ),
303
+ )
304
+
305
+ page.add(
306
+ ft.SafeArea(
307
+ expand=True,
308
+ content=ft.Column(
309
+ expand=True,
310
+ spacing=0,
311
+ controls=[
312
+ header_slot,
313
+ desktop_tabs,
314
+ ft.Container(
315
+ content=body,
316
+ expand=True,
317
+ padding=ft.Padding(
318
+ theme.SPACE_LG,
319
+ theme.SPACE_MD,
320
+ theme.SPACE_LG,
321
+ theme.SPACE_LG if not is_mobile else theme.SPACE_MD,
322
+ ),
323
+ ),
324
+ ],
325
+ ),
326
+ )
327
+ )
328
+
329
+ if await _has_credentials(config, store):
330
+ await show_sync()
331
+ else:
332
+ await show_settings()
333
+
334
+ async def auto_check_updates() -> None:
335
+ latest = await asyncio.to_thread(check_for_update, __version__)
336
+ notify_update(latest, quiet_when_current=True)
337
+
338
+ async def auto_check_profile_sync() -> None:
339
+ if not config.enable_igpsport or not config.profile_sync_check_on_launch:
340
+ return
341
+ await profile_sync_ui.prompt_if_needed(page, config, store)
342
+
343
+ page.run_task(auto_check_updates)
344
+ page.run_task(auto_check_profile_sync)
345
+
346
+
347
+ def main() -> None:
348
+ ft.run(_app, assets_dir=_assets_dir())
349
+
350
+
351
+ if __name__ == "__main__":
352
+ main()
@@ -0,0 +1,102 @@
1
+ """Non-secret settings persisted as JSON in the per-user config directory.
2
+
3
+ Secrets never live here — they go to the OS vault via `secrets.py`. This file
4
+ holds source usernames (identifiers, not secrets), step toggles, the activity
5
+ cap and the download directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import asdict, dataclass, field
12
+ from pathlib import Path
13
+
14
+ from platformdirs import user_config_dir, user_downloads_dir
15
+
16
+ APP_NAME = "intervalssync"
17
+
18
+ CONFIG_DIR = Path(user_config_dir(APP_NAME, appauthor=False))
19
+ CONFIG_PATH = CONFIG_DIR / "config.json"
20
+
21
+
22
+ def _default_download_dir() -> str:
23
+ return str(Path(user_downloads_dir()) / "intervalssync-fit")
24
+
25
+
26
+ @dataclass
27
+ class AppConfig:
28
+ enable_igpsport: bool = True
29
+ enable_bryton: bool = False
30
+ igp_user: str = ""
31
+ igp_region: str = "international"
32
+ bryton_user: str = ""
33
+ max_activities: int = 5
34
+ download_dir: str = field(default_factory=_default_download_dir)
35
+ # Remove each .fit file once it has been uploaded to intervals.icu.
36
+ delete_after_upload: bool = True
37
+ # Re-download/re-upload activities even if already on intervals.icu or Dropbox.
38
+ force_resync: bool = False
39
+ # intervals.icu sport set on uploaded activities ("" = leave as uploaded).
40
+ activity_type: str = ""
41
+ # Android only: save downloads to the public Downloads folder (needs the
42
+ # "all files access" permission) instead of app-private storage.
43
+ save_to_downloads: bool = False
44
+ # Step toggles (advanced). The GUI's one-click sync sets these for the user.
45
+ step_list_activities: bool = True
46
+ step_get_download_url: bool = True
47
+ step_download_fit: bool = True
48
+ step_upload_intervals: bool = True
49
+ # Optional secondary upload target. Off by default because most users only
50
+ # want intervals.icu.
51
+ upload_dropbox: bool = False
52
+ dropbox_folder: str = "/intervalssync-fit"
53
+ dropbox_date_filenames: bool = True
54
+ # Planned workouts: intervals.icu event id → iGPSPORT workoutId.
55
+ uploaded_workouts: dict[str, int] = field(default_factory=dict)
56
+ # Planned workouts: intervals.icu event id → Bryton file id or filename stem.
57
+ uploaded_bryton_workouts: dict[str, str] = field(default_factory=dict)
58
+ # How many calendar days of planned workouts to upload (1 = today only).
59
+ workout_days_ahead: int = 1
60
+ # intervals.icu threshold fingerprint the user declined to sync; cleared after sync.
61
+ profile_sync_declined_fingerprint: str = ""
62
+ # Prompt on launch when FTP, LTHR, max HR, or weight differ from intervals.icu.
63
+ profile_sync_check_on_launch: bool = True
64
+ # Lifetime sync stats (GUI gamification).
65
+ lifetime_activities_uploaded: int = 0
66
+ lifetime_workouts_uploaded: int = 0
67
+ celebrated_milestones: list[int] = field(default_factory=list)
68
+ stats_seeded: bool = False
69
+
70
+
71
+ def total_uploads(config: AppConfig) -> int:
72
+ return config.lifetime_activities_uploaded + config.lifetime_workouts_uploaded
73
+
74
+
75
+ def any_source_enabled(config: AppConfig) -> bool:
76
+ return config.enable_igpsport or config.enable_bryton
77
+
78
+
79
+ def load() -> AppConfig:
80
+ """Load config from disk, falling back to defaults."""
81
+ data: dict = {}
82
+ if CONFIG_PATH.exists():
83
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
84
+
85
+ # Migrate legacy single-source picker (ignored on save going forward).
86
+ activity_source = data.pop("activity_source", None)
87
+ cfg = AppConfig(**{k: v for k, v in data.items() if k in AppConfig.__annotations__})
88
+ if activity_source == "bryton":
89
+ cfg.enable_bryton = True
90
+ cfg.enable_igpsport = False
91
+ if not cfg.stats_seeded:
92
+ cfg.lifetime_workouts_uploaded = len(cfg.uploaded_workouts) + len(
93
+ cfg.uploaded_bryton_workouts
94
+ )
95
+ cfg.stats_seeded = True
96
+ save(cfg)
97
+ return cfg
98
+
99
+
100
+ def save(config: AppConfig) -> None:
101
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
102
+ CONFIG_PATH.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")