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.
- intervalssync/__init__.py +3 -0
- intervalssync/_dropbox_app_key.py +7 -0
- intervalssync/_version.py +1 -0
- intervalssync/bryton/__init__.py +1 -0
- intervalssync/bryton/api.py +49 -0
- intervalssync/bryton/core.py +249 -0
- intervalssync/bryton/ddp.py +231 -0
- intervalssync/bryton/exceptions.py +13 -0
- intervalssync/bryton/fit_encode.py +363 -0
- intervalssync/bryton/workout.py +342 -0
- intervalssync/cli/__init__.py +5 -0
- intervalssync/cli/config.py +53 -0
- intervalssync/cli/env.py +189 -0
- intervalssync/cli/main.py +637 -0
- intervalssync/dropbox_client.py +143 -0
- intervalssync/gui/__init__.py +1 -0
- intervalssync/gui/app.py +352 -0
- intervalssync/gui/config.py +102 -0
- intervalssync/gui/profile_sync_ui.py +225 -0
- intervalssync/gui/secrets.py +135 -0
- intervalssync/gui/settings_view.py +716 -0
- intervalssync/gui/support_gamification.py +658 -0
- intervalssync/gui/sync_view.py +486 -0
- intervalssync/gui/system.py +21 -0
- intervalssync/gui/theme.py +216 -0
- intervalssync/gui/update_check.py +55 -0
- intervalssync/igpsport/__init__.py +1 -0
- intervalssync/igpsport/core.py +520 -0
- intervalssync/igpsport/interval_info.py +269 -0
- intervalssync/igpsport/profile_sync.py +391 -0
- intervalssync/igpsport/region.py +80 -0
- intervalssync/igpsport/workout.py +503 -0
- intervalssync/igpsport/zone_map.py +143 -0
- intervalssync/intervals_icu.py +201 -0
- intervalssync-0.9.3.dist-info/METADATA +179 -0
- intervalssync-0.9.3.dist-info/RECORD +39 -0
- intervalssync-0.9.3.dist-info/WHEEL +4 -0
- intervalssync-0.9.3.dist-info/entry_points.txt +5 -0
- 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."""
|
intervalssync/gui/app.py
ADDED
|
@@ -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")
|