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,716 @@
|
|
|
1
|
+
"""Settings view: enter credentials and have them saved to the OS vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
import flet as ft
|
|
8
|
+
from flet_permission_handler import Permission, PermissionHandler, PermissionStatus
|
|
9
|
+
|
|
10
|
+
from . import config as config_module
|
|
11
|
+
from . import secrets as secrets_module
|
|
12
|
+
from ..igpsport.core import CYCLING_ACTIVITY_TYPES
|
|
13
|
+
from ..dropbox_client import (
|
|
14
|
+
DEFAULT_DROPBOX_FOLDER,
|
|
15
|
+
finish_dropbox_auth,
|
|
16
|
+
get_dropbox_app_key,
|
|
17
|
+
start_dropbox_auth,
|
|
18
|
+
)
|
|
19
|
+
from . import theme
|
|
20
|
+
from . import profile_sync_ui
|
|
21
|
+
from .system import open_folder
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _developer_step_controls(config: config_module.AppConfig) -> list[ft.Control]:
|
|
25
|
+
"""Pipeline step toggles — not shown in Settings; kept for local/dev use."""
|
|
26
|
+
return [
|
|
27
|
+
ft.Switch(label="List activities", value=config.step_list_activities),
|
|
28
|
+
ft.Switch(label="Resolve download URLs", value=config.step_get_download_url),
|
|
29
|
+
ft.Switch(label="Download .fit files", value=config.step_download_fit),
|
|
30
|
+
ft.Switch(label="Upload to intervals.icu", value=config.step_upload_intervals),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def build_settings_view(
|
|
35
|
+
page: ft.Page,
|
|
36
|
+
config: config_module.AppConfig,
|
|
37
|
+
store: secrets_module.SecretStore,
|
|
38
|
+
on_saved: Callable[[], Awaitable[None]],
|
|
39
|
+
perms: PermissionHandler | None = None,
|
|
40
|
+
apply_download_location: Callable[[], Awaitable[None]] | None = None,
|
|
41
|
+
on_profile_sync_check: Callable[[], Awaitable[None]] | None = None,
|
|
42
|
+
) -> ft.Control:
|
|
43
|
+
colors = theme.palette(page)
|
|
44
|
+
|
|
45
|
+
existing_igp_password = await store.get(secrets_module.IGP_PASSWORD) or ""
|
|
46
|
+
existing_bryton_password = await store.get(secrets_module.BRYTON_PASSWORD) or ""
|
|
47
|
+
existing_api_key = await store.get(secrets_module.INTERVALS_API_KEY) or ""
|
|
48
|
+
existing_dropbox_token = await store.get(secrets_module.DROPBOX_REFRESH_TOKEN)
|
|
49
|
+
dropbox_app_key = get_dropbox_app_key()
|
|
50
|
+
|
|
51
|
+
is_mobile = theme.is_mobile(page)
|
|
52
|
+
|
|
53
|
+
def _input_field(**kwargs: object) -> ft.TextField:
|
|
54
|
+
kwargs.setdefault("border_radius", theme.RADIUS_SM)
|
|
55
|
+
if is_mobile:
|
|
56
|
+
kwargs.setdefault("text_size", 14)
|
|
57
|
+
return ft.TextField(**kwargs)
|
|
58
|
+
|
|
59
|
+
enable_igpsport = ft.Switch(
|
|
60
|
+
label="Enable iGPSPORT",
|
|
61
|
+
value=config.enable_igpsport,
|
|
62
|
+
active_color=colors["accent"],
|
|
63
|
+
)
|
|
64
|
+
igp_region_value = (
|
|
65
|
+
config.igp_region if config.igp_region in ("international", "china") else "international"
|
|
66
|
+
)
|
|
67
|
+
igp_region = ft.Dropdown(
|
|
68
|
+
label="iGPSPORT region",
|
|
69
|
+
value=igp_region_value,
|
|
70
|
+
options=[
|
|
71
|
+
ft.dropdown.Option("international", "International"),
|
|
72
|
+
ft.dropdown.Option("china", "China"),
|
|
73
|
+
],
|
|
74
|
+
helper_text="Use China if your account logs in at app.igpsport.cn",
|
|
75
|
+
)
|
|
76
|
+
igp_user = _input_field(
|
|
77
|
+
label=(
|
|
78
|
+
"iGPSPORT phone number"
|
|
79
|
+
if igp_region_value == "china"
|
|
80
|
+
else "iGPSPORT email"
|
|
81
|
+
),
|
|
82
|
+
value=config.igp_user,
|
|
83
|
+
prefix_icon=ft.Icons.PERSON_OUTLINED,
|
|
84
|
+
autofocus=not config.igp_user,
|
|
85
|
+
)
|
|
86
|
+
igp_password = _input_field(
|
|
87
|
+
label="iGPSPORT password",
|
|
88
|
+
value=existing_igp_password,
|
|
89
|
+
prefix_icon=ft.Icons.LOCK_OUTLINED,
|
|
90
|
+
password=True,
|
|
91
|
+
can_reveal_password=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
enable_bryton = ft.Switch(
|
|
95
|
+
label="Enable Bryton Active",
|
|
96
|
+
value=config.enable_bryton,
|
|
97
|
+
active_color=colors["accent"],
|
|
98
|
+
)
|
|
99
|
+
bryton_user = _input_field(
|
|
100
|
+
label="Bryton Active email",
|
|
101
|
+
value=config.bryton_user,
|
|
102
|
+
prefix_icon=ft.Icons.PERSON_OUTLINED,
|
|
103
|
+
)
|
|
104
|
+
bryton_password = _input_field(
|
|
105
|
+
label="Bryton Active password",
|
|
106
|
+
value=existing_bryton_password,
|
|
107
|
+
prefix_icon=ft.Icons.LOCK_OUTLINED,
|
|
108
|
+
password=True,
|
|
109
|
+
can_reveal_password=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
api_key = _input_field(
|
|
113
|
+
label="intervals.icu API key",
|
|
114
|
+
value=existing_api_key,
|
|
115
|
+
prefix_icon=ft.Icons.KEY_OUTLINED,
|
|
116
|
+
password=True,
|
|
117
|
+
can_reveal_password=True,
|
|
118
|
+
helper="Settings → Developer on intervals.icu",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
max_activities = _input_field(
|
|
122
|
+
label="Activities to sync",
|
|
123
|
+
value=str(config.max_activities),
|
|
124
|
+
prefix_icon=ft.Icons.FORMAT_LIST_NUMBERED,
|
|
125
|
+
keyboard_type=ft.KeyboardType.NUMBER,
|
|
126
|
+
helper="Number of recent activities to sync on each run",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
workout_days_ahead = _input_field(
|
|
130
|
+
label="Workout upload window (days)",
|
|
131
|
+
value=str(config.workout_days_ahead),
|
|
132
|
+
prefix_icon=ft.Icons.CALENDAR_MONTH_OUTLINED,
|
|
133
|
+
keyboard_type=ft.KeyboardType.NUMBER,
|
|
134
|
+
helper=(
|
|
135
|
+
"Planned workouts from intervals.icu to upload to iGPSPORT and/or Bryton; "
|
|
136
|
+
"1 = today only"
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
activity_type = ft.Dropdown(
|
|
141
|
+
label="Activity type on intervals.icu",
|
|
142
|
+
value=config.activity_type,
|
|
143
|
+
border_radius=theme.RADIUS_SM,
|
|
144
|
+
options=[
|
|
145
|
+
ft.dropdown.Option(key="", text="Don't change (leave as uploaded)"),
|
|
146
|
+
*(
|
|
147
|
+
ft.dropdown.Option(key=value, text=label)
|
|
148
|
+
for value, label in CYCLING_ACTIVITY_TYPES
|
|
149
|
+
),
|
|
150
|
+
],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
delete_after_upload = ft.Switch(
|
|
154
|
+
label="Delete downloaded files after upload",
|
|
155
|
+
value=config.delete_after_upload,
|
|
156
|
+
active_color=colors["accent"],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
force_resync = ft.Switch(
|
|
160
|
+
label="Force re-sync (re-download even if already uploaded)",
|
|
161
|
+
value=config.force_resync,
|
|
162
|
+
active_color=colors["accent"],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
upload_dropbox = ft.Switch(
|
|
166
|
+
label="Upload activities to Dropbox",
|
|
167
|
+
value=(
|
|
168
|
+
config.upload_dropbox
|
|
169
|
+
and bool(existing_dropbox_token)
|
|
170
|
+
and bool(dropbox_app_key)
|
|
171
|
+
),
|
|
172
|
+
disabled=not bool(existing_dropbox_token and dropbox_app_key),
|
|
173
|
+
active_color=colors["accent"],
|
|
174
|
+
)
|
|
175
|
+
dropbox_folder = _input_field(
|
|
176
|
+
label="Dropbox folder",
|
|
177
|
+
value=config.dropbox_folder or DEFAULT_DROPBOX_FOLDER,
|
|
178
|
+
prefix_icon=ft.Icons.FOLDER_OUTLINED,
|
|
179
|
+
helper="Dropbox path, e.g. /Fit files",
|
|
180
|
+
)
|
|
181
|
+
dropbox_date_filenames_switch = ft.Switch(
|
|
182
|
+
label="Use date in Dropbox filenames",
|
|
183
|
+
value=config.dropbox_date_filenames,
|
|
184
|
+
active_color=colors["accent"],
|
|
185
|
+
)
|
|
186
|
+
dropbox_date_filenames = ft.Column(
|
|
187
|
+
spacing=4,
|
|
188
|
+
tight=True,
|
|
189
|
+
controls=[
|
|
190
|
+
dropbox_date_filenames_switch,
|
|
191
|
+
ft.Text(
|
|
192
|
+
"iGPSPORT: ride-0-YYYY-MM-DD-HH-MM-SS.fit · "
|
|
193
|
+
"Bryton: YYMMDDHHMMSS.fit",
|
|
194
|
+
size=12,
|
|
195
|
+
color=colors["text_muted"],
|
|
196
|
+
),
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
dropbox_status = ft.Text(
|
|
200
|
+
(
|
|
201
|
+
"Connected"
|
|
202
|
+
if existing_dropbox_token and dropbox_app_key
|
|
203
|
+
else "Dropbox app key missing from this build"
|
|
204
|
+
if not dropbox_app_key
|
|
205
|
+
else "Not connected"
|
|
206
|
+
),
|
|
207
|
+
size=13,
|
|
208
|
+
color=colors["text_muted"],
|
|
209
|
+
)
|
|
210
|
+
dropbox_auth_code = _input_field(
|
|
211
|
+
label="Dropbox authorization code",
|
|
212
|
+
prefix_icon=ft.Icons.KEY_OUTLINED,
|
|
213
|
+
visible=False,
|
|
214
|
+
)
|
|
215
|
+
dropbox_finish_button = ft.OutlinedButton(
|
|
216
|
+
"Finish connection",
|
|
217
|
+
icon=ft.Icons.CHECK,
|
|
218
|
+
visible=False,
|
|
219
|
+
style=ft.ButtonStyle(
|
|
220
|
+
shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_SM),
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
dropbox_auth_flow = None
|
|
224
|
+
|
|
225
|
+
async def connect_dropbox(_: ft.ControlEvent) -> None:
|
|
226
|
+
nonlocal dropbox_auth_flow
|
|
227
|
+
if not dropbox_app_key:
|
|
228
|
+
page.show_dialog(
|
|
229
|
+
ft.SnackBar(ft.Text("Dropbox app key is missing from this build."))
|
|
230
|
+
)
|
|
231
|
+
return
|
|
232
|
+
dropbox_auth_flow, auth_url = start_dropbox_auth(dropbox_app_key)
|
|
233
|
+
dropbox_auth_code.visible = True
|
|
234
|
+
dropbox_finish_button.visible = True
|
|
235
|
+
dropbox_auth_code.value = ""
|
|
236
|
+
await page.launch_url(auth_url)
|
|
237
|
+
page.show_dialog(
|
|
238
|
+
ft.SnackBar(ft.Text("Paste the Dropbox authorization code here."))
|
|
239
|
+
)
|
|
240
|
+
page.update()
|
|
241
|
+
|
|
242
|
+
async def finish_dropbox(_: ft.ControlEvent) -> None:
|
|
243
|
+
nonlocal dropbox_auth_flow
|
|
244
|
+
if dropbox_auth_flow is None:
|
|
245
|
+
page.show_dialog(ft.SnackBar(ft.Text("Start Dropbox connection first.")))
|
|
246
|
+
return
|
|
247
|
+
if not dropbox_auth_code.value:
|
|
248
|
+
page.show_dialog(ft.SnackBar(ft.Text("Paste the Dropbox code first.")))
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
refresh_token = finish_dropbox_auth(
|
|
252
|
+
dropbox_auth_flow, dropbox_auth_code.value
|
|
253
|
+
)
|
|
254
|
+
except Exception as exc: # noqa: BLE001 — show auth failures directly
|
|
255
|
+
page.show_dialog(ft.SnackBar(ft.Text(f"Dropbox connection failed: {exc}")))
|
|
256
|
+
return
|
|
257
|
+
if not refresh_token:
|
|
258
|
+
page.show_dialog(
|
|
259
|
+
ft.SnackBar(ft.Text("Dropbox did not return a refresh token."))
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
await store.set(secrets_module.DROPBOX_REFRESH_TOKEN, refresh_token)
|
|
263
|
+
dropbox_auth_flow = None
|
|
264
|
+
dropbox_auth_code.visible = False
|
|
265
|
+
dropbox_finish_button.visible = False
|
|
266
|
+
dropbox_status.value = "Connected"
|
|
267
|
+
upload_dropbox.disabled = False
|
|
268
|
+
upload_dropbox.value = True
|
|
269
|
+
dropbox_disconnect_button.disabled = False
|
|
270
|
+
page.show_dialog(ft.SnackBar(ft.Text("Dropbox connected.")))
|
|
271
|
+
page.update()
|
|
272
|
+
|
|
273
|
+
async def disconnect_dropbox(_: ft.ControlEvent) -> None:
|
|
274
|
+
await store.delete(secrets_module.DROPBOX_REFRESH_TOKEN)
|
|
275
|
+
config.upload_dropbox = False
|
|
276
|
+
config_module.save(config)
|
|
277
|
+
upload_dropbox.value = False
|
|
278
|
+
upload_dropbox.disabled = True
|
|
279
|
+
dropbox_status.value = "Not connected"
|
|
280
|
+
page.show_dialog(ft.SnackBar(ft.Text("Dropbox disconnected.")))
|
|
281
|
+
page.update()
|
|
282
|
+
|
|
283
|
+
dropbox_connect_button = ft.OutlinedButton(
|
|
284
|
+
"Connect Dropbox",
|
|
285
|
+
icon=ft.Icons.CLOUD_UPLOAD_OUTLINED,
|
|
286
|
+
disabled=not bool(dropbox_app_key),
|
|
287
|
+
on_click=connect_dropbox,
|
|
288
|
+
style=ft.ButtonStyle(
|
|
289
|
+
shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_SM),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
dropbox_disconnect_button = ft.TextButton(
|
|
293
|
+
"Disconnect",
|
|
294
|
+
icon=ft.Icons.LINK_OFF,
|
|
295
|
+
disabled=not bool(existing_dropbox_token),
|
|
296
|
+
on_click=disconnect_dropbox,
|
|
297
|
+
)
|
|
298
|
+
dropbox_finish_button.on_click = finish_dropbox
|
|
299
|
+
|
|
300
|
+
dropbox_options = ft.ExpansionTile(
|
|
301
|
+
title=ft.Text("Dropbox", weight=ft.FontWeight.W_500),
|
|
302
|
+
subtitle=ft.Text(
|
|
303
|
+
dropbox_status.value or "Optional cloud backup",
|
|
304
|
+
size=12,
|
|
305
|
+
color=colors["text_muted"],
|
|
306
|
+
),
|
|
307
|
+
leading=ft.Icon(ft.Icons.CLOUD_OUTLINED, color=colors["accent"]),
|
|
308
|
+
affinity=ft.TileAffinity.LEADING,
|
|
309
|
+
expanded=config.upload_dropbox,
|
|
310
|
+
controls=[
|
|
311
|
+
ft.Container(
|
|
312
|
+
padding=ft.Padding(theme.SPACE_MD, 0, theme.SPACE_MD, theme.SPACE_SM),
|
|
313
|
+
content=ft.Column(
|
|
314
|
+
spacing=theme.SPACE_SM,
|
|
315
|
+
controls=[
|
|
316
|
+
dropbox_status,
|
|
317
|
+
ft.Row(
|
|
318
|
+
spacing=theme.SPACE_SM,
|
|
319
|
+
controls=[
|
|
320
|
+
dropbox_connect_button,
|
|
321
|
+
dropbox_disconnect_button,
|
|
322
|
+
],
|
|
323
|
+
),
|
|
324
|
+
dropbox_auth_code,
|
|
325
|
+
dropbox_finish_button,
|
|
326
|
+
upload_dropbox,
|
|
327
|
+
dropbox_folder,
|
|
328
|
+
dropbox_date_filenames,
|
|
329
|
+
],
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
],
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
profile_sync_status = ft.Text(
|
|
336
|
+
"Checking…",
|
|
337
|
+
size=12,
|
|
338
|
+
color=colors["text_muted"],
|
|
339
|
+
max_lines=2,
|
|
340
|
+
no_wrap=False,
|
|
341
|
+
overflow=ft.TextOverflow.ELLIPSIS,
|
|
342
|
+
)
|
|
343
|
+
profile_sync_hint = ft.Text(
|
|
344
|
+
"Add iGPSPORT credentials and intervals.icu API key first.",
|
|
345
|
+
size=12,
|
|
346
|
+
color=colors["text_muted"],
|
|
347
|
+
visible=False,
|
|
348
|
+
)
|
|
349
|
+
profile_sync_message_area = ft.Container(
|
|
350
|
+
height=34,
|
|
351
|
+
content=ft.Column(
|
|
352
|
+
tight=True,
|
|
353
|
+
spacing=0,
|
|
354
|
+
controls=[profile_sync_status, profile_sync_hint],
|
|
355
|
+
),
|
|
356
|
+
alignment=ft.Alignment.TOP_LEFT,
|
|
357
|
+
)
|
|
358
|
+
profile_sync_button = ft.OutlinedButton(
|
|
359
|
+
"Sync profile now",
|
|
360
|
+
icon=ft.Icons.SYNC,
|
|
361
|
+
style=ft.ButtonStyle(
|
|
362
|
+
shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_SM),
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
profile_sync_check_on_launch = ft.Switch(
|
|
366
|
+
label="Check on app launch",
|
|
367
|
+
value=config.profile_sync_check_on_launch,
|
|
368
|
+
active_color=colors["accent"],
|
|
369
|
+
)
|
|
370
|
+
profile_sync_actions = ft.Row(
|
|
371
|
+
spacing=theme.SPACE_MD,
|
|
372
|
+
alignment=ft.MainAxisAlignment.CENTER,
|
|
373
|
+
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
|
374
|
+
controls=[
|
|
375
|
+
profile_sync_button,
|
|
376
|
+
profile_sync_check_on_launch,
|
|
377
|
+
],
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
async def refresh_profile_sync_status() -> None:
|
|
381
|
+
creds = await profile_sync_ui.credentials_ready(config, store)
|
|
382
|
+
if creds is None:
|
|
383
|
+
profile_sync_status.visible = False
|
|
384
|
+
profile_sync_hint.visible = True
|
|
385
|
+
profile_sync_button.disabled = True
|
|
386
|
+
page.update()
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
profile_sync_hint.visible = False
|
|
390
|
+
profile_sync_status.visible = True
|
|
391
|
+
profile_sync_button.disabled = False
|
|
392
|
+
profile_sync_status.value = "Checking…"
|
|
393
|
+
page.update()
|
|
394
|
+
status = await profile_sync_ui.check_profile_thresholds(config, store)
|
|
395
|
+
profile_sync_status.value = profile_sync_ui.format_threshold_status(status)
|
|
396
|
+
page.update()
|
|
397
|
+
|
|
398
|
+
async def on_profile_sync_click(_: ft.ControlEvent) -> None:
|
|
399
|
+
profile_sync_button.disabled = True
|
|
400
|
+
page.update()
|
|
401
|
+
await profile_sync_ui.sync_with_feedback(page, config, store)
|
|
402
|
+
await refresh_profile_sync_status()
|
|
403
|
+
|
|
404
|
+
profile_sync_button.on_click = on_profile_sync_click
|
|
405
|
+
|
|
406
|
+
async def on_profile_tile_change(e: ft.ControlEvent) -> None:
|
|
407
|
+
if e.control.expanded:
|
|
408
|
+
await refresh_profile_sync_status()
|
|
409
|
+
|
|
410
|
+
profile_sync_options = ft.ExpansionTile(
|
|
411
|
+
title=ft.Text("iGPSPORT profile", weight=ft.FontWeight.W_500),
|
|
412
|
+
subtitle=ft.Text(
|
|
413
|
+
"FTP, LTHR, max HR, weight, and zones from intervals.icu",
|
|
414
|
+
size=12,
|
|
415
|
+
color=colors["text_muted"],
|
|
416
|
+
),
|
|
417
|
+
leading=ft.Icon(ft.Icons.MONITOR_HEART_OUTLINED, color=colors["accent"]),
|
|
418
|
+
affinity=ft.TileAffinity.LEADING,
|
|
419
|
+
on_change=on_profile_tile_change,
|
|
420
|
+
controls=[
|
|
421
|
+
ft.Container(
|
|
422
|
+
padding=ft.Padding(theme.SPACE_MD, 0, theme.SPACE_MD, theme.SPACE_SM),
|
|
423
|
+
content=ft.Column(
|
|
424
|
+
tight=True,
|
|
425
|
+
spacing=theme.SPACE_XS,
|
|
426
|
+
controls=[
|
|
427
|
+
profile_sync_message_area,
|
|
428
|
+
profile_sync_actions,
|
|
429
|
+
],
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
save_to_downloads = ft.Switch(
|
|
436
|
+
label="Save to phone's Downloads folder",
|
|
437
|
+
value=config.save_to_downloads,
|
|
438
|
+
active_color=colors["accent"],
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if is_mobile:
|
|
442
|
+
if config.save_to_downloads:
|
|
443
|
+
note = "Saved to your phone's Downloads folder (Download/intervalssync-fit)."
|
|
444
|
+
else:
|
|
445
|
+
note = (
|
|
446
|
+
"Kept in the app's private storage and uploaded to intervals.icu "
|
|
447
|
+
"(removed afterwards unless you turn that off). Turn on “Save to "
|
|
448
|
+
"phone's Downloads folder” to keep them where you can find them."
|
|
449
|
+
)
|
|
450
|
+
folder_detail = ft.Text(note, size=13, color=colors["text_muted"])
|
|
451
|
+
folder_trailing: ft.Control | None = None
|
|
452
|
+
else:
|
|
453
|
+
folder_detail = ft.Text(
|
|
454
|
+
config.download_dir, size=13, selectable=True, no_wrap=False
|
|
455
|
+
)
|
|
456
|
+
folder_trailing = ft.IconButton(
|
|
457
|
+
ft.Icons.FOLDER_OPEN_OUTLINED,
|
|
458
|
+
tooltip="Open folder",
|
|
459
|
+
icon_color=colors["accent"],
|
|
460
|
+
on_click=lambda _: open_folder(config.download_dir),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
storage_inner_controls: list[ft.Control]
|
|
464
|
+
if is_mobile:
|
|
465
|
+
storage_inner_controls = [save_to_downloads, folder_detail]
|
|
466
|
+
else:
|
|
467
|
+
storage_inner_controls = [
|
|
468
|
+
ft.Row(
|
|
469
|
+
spacing=theme.SPACE_SM,
|
|
470
|
+
vertical_alignment=ft.CrossAxisAlignment.START,
|
|
471
|
+
controls=[
|
|
472
|
+
ft.Column(
|
|
473
|
+
expand=True,
|
|
474
|
+
spacing=2,
|
|
475
|
+
controls=[
|
|
476
|
+
ft.Text(
|
|
477
|
+
"Download folder",
|
|
478
|
+
size=12,
|
|
479
|
+
weight=ft.FontWeight.W_500,
|
|
480
|
+
color=colors["text"],
|
|
481
|
+
),
|
|
482
|
+
folder_detail,
|
|
483
|
+
],
|
|
484
|
+
),
|
|
485
|
+
*( [folder_trailing] if folder_trailing else [] ),
|
|
486
|
+
],
|
|
487
|
+
)
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
storage_options = ft.ExpansionTile(
|
|
491
|
+
title=ft.Text("Storage", weight=ft.FontWeight.W_500),
|
|
492
|
+
subtitle=ft.Text(
|
|
493
|
+
(
|
|
494
|
+
"Save to Downloads or app storage"
|
|
495
|
+
if is_mobile
|
|
496
|
+
else config.download_dir
|
|
497
|
+
),
|
|
498
|
+
size=12,
|
|
499
|
+
color=colors["text_muted"],
|
|
500
|
+
max_lines=1,
|
|
501
|
+
overflow=ft.TextOverflow.ELLIPSIS,
|
|
502
|
+
),
|
|
503
|
+
leading=ft.Icon(ft.Icons.FOLDER_OUTLINED, color=colors["accent"]),
|
|
504
|
+
affinity=ft.TileAffinity.LEADING,
|
|
505
|
+
controls=[
|
|
506
|
+
ft.Container(
|
|
507
|
+
padding=ft.Padding(theme.SPACE_MD, 0, theme.SPACE_MD, theme.SPACE_SM),
|
|
508
|
+
content=ft.Column(
|
|
509
|
+
spacing=theme.SPACE_SM,
|
|
510
|
+
controls=storage_inner_controls,
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
],
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
igp_credentials = ft.Column(
|
|
517
|
+
spacing=theme.SPACE_SM,
|
|
518
|
+
controls=[igp_region, igp_user, igp_password],
|
|
519
|
+
)
|
|
520
|
+
bryton_credentials = ft.Column(
|
|
521
|
+
spacing=theme.SPACE_SM,
|
|
522
|
+
controls=[bryton_user, bryton_password],
|
|
523
|
+
)
|
|
524
|
+
workout_sync_section = ft.Container(
|
|
525
|
+
visible=config.enable_igpsport or config.enable_bryton,
|
|
526
|
+
content=workout_days_ahead,
|
|
527
|
+
)
|
|
528
|
+
dropbox_section = ft.Container(
|
|
529
|
+
visible=config.enable_igpsport or config.enable_bryton,
|
|
530
|
+
content=dropbox_options,
|
|
531
|
+
)
|
|
532
|
+
profile_sync_section = ft.Container(
|
|
533
|
+
visible=config.enable_igpsport,
|
|
534
|
+
content=profile_sync_options,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def update_igp_user_label(_: ft.ControlEvent | None = None) -> None:
|
|
538
|
+
igp_user.label = (
|
|
539
|
+
"iGPSPORT phone number"
|
|
540
|
+
if igp_region.value == "china"
|
|
541
|
+
else "iGPSPORT email"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def update_source_visibility(_: ft.ControlEvent | None = None) -> None:
|
|
545
|
+
igp_credentials.visible = bool(enable_igpsport.value)
|
|
546
|
+
workout_sync_section.visible = bool(enable_igpsport.value or enable_bryton.value)
|
|
547
|
+
dropbox_section.visible = bool(enable_igpsport.value or enable_bryton.value)
|
|
548
|
+
profile_sync_section.visible = bool(enable_igpsport.value)
|
|
549
|
+
bryton_credentials.visible = bool(enable_bryton.value)
|
|
550
|
+
page.update()
|
|
551
|
+
|
|
552
|
+
enable_igpsport.on_change = update_source_visibility
|
|
553
|
+
enable_bryton.on_change = update_source_visibility
|
|
554
|
+
igp_region.on_change = update_igp_user_label
|
|
555
|
+
update_source_visibility()
|
|
556
|
+
update_igp_user_label()
|
|
557
|
+
|
|
558
|
+
async def save(_: ft.ControlEvent) -> None:
|
|
559
|
+
if not enable_igpsport.value and not enable_bryton.value:
|
|
560
|
+
page.show_dialog(
|
|
561
|
+
ft.SnackBar(ft.Text("Enable at least one activity source."))
|
|
562
|
+
)
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
igp_pw = igp_password.value or existing_igp_password
|
|
566
|
+
bryton_pw = bryton_password.value or existing_bryton_password
|
|
567
|
+
|
|
568
|
+
if enable_igpsport.value:
|
|
569
|
+
if not igp_user.value.strip():
|
|
570
|
+
page.show_dialog(
|
|
571
|
+
ft.SnackBar(
|
|
572
|
+
ft.Text(
|
|
573
|
+
"iGPSPORT account (email or phone) is required when enabled."
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
return
|
|
578
|
+
if not igp_pw:
|
|
579
|
+
page.show_dialog(
|
|
580
|
+
ft.SnackBar(ft.Text("iGPSPORT password is required when enabled."))
|
|
581
|
+
)
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
if enable_bryton.value:
|
|
585
|
+
if not bryton_user.value.strip():
|
|
586
|
+
page.show_dialog(
|
|
587
|
+
ft.SnackBar(ft.Text("Bryton Active email is required when enabled."))
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
if not bryton_pw:
|
|
591
|
+
page.show_dialog(
|
|
592
|
+
ft.SnackBar(ft.Text("Bryton Active password is required when enabled."))
|
|
593
|
+
)
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
config.enable_igpsport = bool(enable_igpsport.value)
|
|
597
|
+
config.enable_bryton = bool(enable_bryton.value)
|
|
598
|
+
config.igp_user = igp_user.value.strip()
|
|
599
|
+
config.igp_region = igp_region.value or "international"
|
|
600
|
+
config.bryton_user = bryton_user.value.strip()
|
|
601
|
+
try:
|
|
602
|
+
config.max_activities = max(1, int(max_activities.value))
|
|
603
|
+
except (TypeError, ValueError):
|
|
604
|
+
config.max_activities = 5
|
|
605
|
+
max_activities.value = "5"
|
|
606
|
+
try:
|
|
607
|
+
config.workout_days_ahead = max(1, int(workout_days_ahead.value))
|
|
608
|
+
except (TypeError, ValueError):
|
|
609
|
+
config.workout_days_ahead = 1
|
|
610
|
+
workout_days_ahead.value = "1"
|
|
611
|
+
config.delete_after_upload = delete_after_upload.value
|
|
612
|
+
config.force_resync = force_resync.value
|
|
613
|
+
config.profile_sync_check_on_launch = bool(profile_sync_check_on_launch.value)
|
|
614
|
+
config.activity_type = activity_type.value or ""
|
|
615
|
+
config.dropbox_folder = dropbox_folder.value.strip() or DEFAULT_DROPBOX_FOLDER
|
|
616
|
+
config.dropbox_date_filenames = bool(dropbox_date_filenames_switch.value)
|
|
617
|
+
config.upload_dropbox = bool(upload_dropbox.value)
|
|
618
|
+
|
|
619
|
+
message = "Saved securely to your system credential store."
|
|
620
|
+
if config.upload_dropbox and not dropbox_app_key:
|
|
621
|
+
config.upload_dropbox = False
|
|
622
|
+
upload_dropbox.value = False
|
|
623
|
+
message = "Saved, but Dropbox is disabled because this build has no app key."
|
|
624
|
+
elif config.upload_dropbox and not await store.get(
|
|
625
|
+
secrets_module.DROPBOX_REFRESH_TOKEN
|
|
626
|
+
):
|
|
627
|
+
config.upload_dropbox = False
|
|
628
|
+
upload_dropbox.value = False
|
|
629
|
+
message = "Saved, but Dropbox is disabled until you connect it."
|
|
630
|
+
if is_mobile:
|
|
631
|
+
want_downloads = bool(save_to_downloads.value)
|
|
632
|
+
if want_downloads and perms is not None:
|
|
633
|
+
status = await perms.request(Permission.MANAGE_EXTERNAL_STORAGE)
|
|
634
|
+
if status != PermissionStatus.GRANTED:
|
|
635
|
+
want_downloads = False
|
|
636
|
+
save_to_downloads.value = False
|
|
637
|
+
message = (
|
|
638
|
+
"Saved, but storage permission wasn't granted — files "
|
|
639
|
+
"stay in the app's private storage."
|
|
640
|
+
)
|
|
641
|
+
config.save_to_downloads = want_downloads
|
|
642
|
+
config_module.save(config)
|
|
643
|
+
|
|
644
|
+
if igp_password.value:
|
|
645
|
+
await store.set(secrets_module.IGP_PASSWORD, igp_password.value)
|
|
646
|
+
if bryton_password.value:
|
|
647
|
+
await store.set(secrets_module.BRYTON_PASSWORD, bryton_password.value)
|
|
648
|
+
if api_key.value:
|
|
649
|
+
await store.set(secrets_module.INTERVALS_API_KEY, api_key.value)
|
|
650
|
+
else:
|
|
651
|
+
await store.delete(secrets_module.INTERVALS_API_KEY)
|
|
652
|
+
|
|
653
|
+
if apply_download_location is not None:
|
|
654
|
+
await apply_download_location()
|
|
655
|
+
|
|
656
|
+
page.show_dialog(ft.SnackBar(ft.Text(message)))
|
|
657
|
+
await on_saved()
|
|
658
|
+
if on_profile_sync_check is not None and config.enable_igpsport:
|
|
659
|
+
await on_profile_sync_check()
|
|
660
|
+
|
|
661
|
+
save_button = ft.FilledButton(
|
|
662
|
+
content=ft.Row(
|
|
663
|
+
tight=True,
|
|
664
|
+
alignment=ft.MainAxisAlignment.CENTER,
|
|
665
|
+
controls=[
|
|
666
|
+
ft.Icon(ft.Icons.SAVE_OUTLINED, size=20),
|
|
667
|
+
ft.Text("Save settings", font_family=f"{theme.FONT_BODY}Medium"),
|
|
668
|
+
],
|
|
669
|
+
),
|
|
670
|
+
on_click=save,
|
|
671
|
+
style=ft.ButtonStyle(
|
|
672
|
+
shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_SM),
|
|
673
|
+
padding=ft.Padding(theme.SPACE_XL, theme.SPACE_MD, theme.SPACE_XL, theme.SPACE_MD),
|
|
674
|
+
),
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
return ft.Column(
|
|
678
|
+
spacing=theme.SPACE_LG,
|
|
679
|
+
controls=[
|
|
680
|
+
ft.Column(
|
|
681
|
+
spacing=theme.SPACE_SM,
|
|
682
|
+
controls=[
|
|
683
|
+
theme.display_text("Settings", size=26, color=colors["text"]),
|
|
684
|
+
theme.muted_text(
|
|
685
|
+
"Credentials are stored in your operating system's secure vault, "
|
|
686
|
+
"never in a plain file.",
|
|
687
|
+
page,
|
|
688
|
+
),
|
|
689
|
+
],
|
|
690
|
+
),
|
|
691
|
+
theme.settings_section(
|
|
692
|
+
page,
|
|
693
|
+
"Accounts",
|
|
694
|
+
api_key,
|
|
695
|
+
enable_igpsport,
|
|
696
|
+
igp_credentials,
|
|
697
|
+
enable_bryton,
|
|
698
|
+
bryton_credentials,
|
|
699
|
+
subtitle="Enable sources and sign in to each service.",
|
|
700
|
+
),
|
|
701
|
+
theme.settings_section(
|
|
702
|
+
page,
|
|
703
|
+
"Sync behavior",
|
|
704
|
+
max_activities,
|
|
705
|
+
activity_type,
|
|
706
|
+
delete_after_upload,
|
|
707
|
+
force_resync,
|
|
708
|
+
workout_sync_section,
|
|
709
|
+
),
|
|
710
|
+
profile_sync_section,
|
|
711
|
+
dropbox_section,
|
|
712
|
+
storage_options,
|
|
713
|
+
save_button,
|
|
714
|
+
ft.Container(height=theme.SPACE_MD),
|
|
715
|
+
],
|
|
716
|
+
)
|