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,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
+ )