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,658 @@
1
+ """Lifetime sync stats, milestone celebrations, and Ko-fi support UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import os
8
+ import random
9
+ from dataclasses import dataclass
10
+
11
+ import flet as ft
12
+
13
+ from . import config as config_module
14
+ from . import theme
15
+
16
+ KOFI_URL = "https://ko-fi.com/jorge_huxley"
17
+
18
+ MILESTONES = [5, 25, 50, 100, 250, 500, 1000]
19
+
20
+ _MILESTONE_TITLES: dict[int, str] = {
21
+ 5: "Domestique status!",
22
+ 25: "Quarter century!",
23
+ 50: "Half century!",
24
+ 100: "Century!",
25
+ 250: "Grand tour stage!",
26
+ 500: "Monument ride!",
27
+ 1000: "Legend status!",
28
+ }
29
+
30
+ _MILESTONE_MESSAGES: dict[int, str] = {
31
+ 5: "Five activities synced on autopilot. Your training log just got a lot easier.",
32
+ 25: "Twenty-five rides across, hands-free. You're in a proper rhythm now.",
33
+ 50: "Fifty transfers done — that's a serious stack of saved clicks.",
34
+ 100: "One hundred activities on autopilot. Every ride, right where it belongs.",
35
+ 250: "250 syncs deep. Your data flows like a freshly-oiled drivetrain.",
36
+ 500: "Five hundred transfers. That's real dedication — and a lot of saved time.",
37
+ 1000: "One thousand syncs. You've officially reached legend status.",
38
+ }
39
+
40
+ _KOFI_LINE = (
41
+ "Intervals Sync is free and built in spare time. "
42
+ "A small Ko-fi keeps it rolling."
43
+ )
44
+
45
+ _CONFETTI_COLORS = (
46
+ theme.ACCENT,
47
+ theme.ACCENT_LIGHT,
48
+ "#F2C94C",
49
+ "#27AE60",
50
+ "#2D9CDB",
51
+ "#BB6BD9",
52
+ )
53
+
54
+ _CELEBRATION_WIDTH = 320
55
+ _CELEBRATION_HEIGHT = 340
56
+
57
+
58
+ @dataclass
59
+ class StatsCardRefs:
60
+ number: ft.Ref[ft.Text]
61
+ rank: ft.Ref[ft.Text]
62
+ breakdown: ft.Ref[ft.Text]
63
+ target: ft.Ref[ft.Text]
64
+ remaining: ft.Ref[ft.Text]
65
+ progress_bar: ft.Ref[ft.ProgressBar]
66
+
67
+
68
+ def total_uploads(config: config_module.AppConfig) -> int:
69
+ return config_module.total_uploads(config)
70
+
71
+
72
+ def rank_for(total: int) -> str:
73
+ if total <= 0:
74
+ return "Rookie"
75
+ if total < 5:
76
+ return "Warm-up lap"
77
+ if total < 10:
78
+ return "Domestique"
79
+ if total < 25:
80
+ return "Breakaway"
81
+ if total < 50:
82
+ return "Climber"
83
+ if total < 100:
84
+ return "Sprinter"
85
+ if total < 250:
86
+ return "Century rider"
87
+ return "Grand tourer"
88
+
89
+
90
+ def next_milestone(total: int) -> int | None:
91
+ for milestone in MILESTONES:
92
+ if total < milestone:
93
+ return milestone
94
+ return None
95
+
96
+
97
+ def _previous_milestone(total: int) -> int:
98
+ previous = 0
99
+ for milestone in MILESTONES:
100
+ if total >= milestone:
101
+ previous = milestone
102
+ else:
103
+ break
104
+ return previous
105
+
106
+
107
+ def progress_fraction(total: int) -> float:
108
+ nxt = next_milestone(total)
109
+ if nxt is None:
110
+ return 1.0
111
+ prev = _previous_milestone(total)
112
+ span = nxt - prev
113
+ if span <= 0:
114
+ return 0.0
115
+ return (total - prev) / span
116
+
117
+
118
+ def milestone_title(milestone: int) -> str:
119
+ return _MILESTONE_TITLES.get(milestone, f"{milestone} transfers!")
120
+
121
+
122
+ def milestone_message(milestone: int) -> str:
123
+ return _MILESTONE_MESSAGES.get(
124
+ milestone,
125
+ f"{milestone} activities synced on autopilot. Nice work keeping your "
126
+ "training data flowing.",
127
+ )
128
+
129
+
130
+ def _newly_crossed_milestone(old_total: int, new_total: int) -> int | None:
131
+ crossed: int | None = None
132
+ for milestone in MILESTONES:
133
+ if old_total < milestone <= new_total:
134
+ crossed = milestone
135
+ return crossed
136
+
137
+
138
+ def record_uploads(
139
+ config: config_module.AppConfig,
140
+ *,
141
+ activities: int = 0,
142
+ workouts: int = 0,
143
+ ) -> int | None:
144
+ if activities <= 0 and workouts <= 0:
145
+ return None
146
+
147
+ old_total = total_uploads(config)
148
+ config.lifetime_activities_uploaded += activities
149
+ config.lifetime_workouts_uploaded += workouts
150
+ new_total = total_uploads(config)
151
+
152
+ milestone = _newly_crossed_milestone(old_total, new_total)
153
+ if milestone is not None and milestone not in config.celebrated_milestones:
154
+ config.celebrated_milestones.append(milestone)
155
+ config_module.save(config)
156
+ return milestone
157
+
158
+ config_module.save(config)
159
+ return None
160
+
161
+
162
+ def _breakdown_text(config: config_module.AppConfig) -> str:
163
+ activities = config.lifetime_activities_uploaded
164
+ workouts = config.lifetime_workouts_uploaded
165
+ activity_word = "activity" if activities == 1 else "activities"
166
+ workout_word = "workout" if workouts == 1 else "workouts"
167
+ return f"{activities} {activity_word} · {workouts} {workout_word}"
168
+
169
+
170
+ def _target_text(total: int) -> str:
171
+ nxt = next_milestone(total)
172
+ if nxt is None:
173
+ return "Every milestone cleared"
174
+ return f"Next milestone · {nxt}"
175
+
176
+
177
+ def _remaining_text(total: int) -> str:
178
+ nxt = next_milestone(total)
179
+ if nxt is None:
180
+ return "Legend"
181
+ remaining = nxt - total
182
+ word = "sync" if remaining == 1 else "syncs"
183
+ return f"{remaining} {word} to go"
184
+
185
+
186
+ def update_stats_display(
187
+ page: ft.Page,
188
+ config: config_module.AppConfig,
189
+ refs: StatsCardRefs,
190
+ ) -> None:
191
+ total = total_uploads(config)
192
+ if refs.number.current:
193
+ refs.number.current.value = str(total)
194
+ if refs.rank.current:
195
+ refs.rank.current.value = rank_for(total).upper()
196
+ if refs.breakdown.current:
197
+ refs.breakdown.current.value = _breakdown_text(config)
198
+ if refs.target.current:
199
+ refs.target.current.value = _target_text(total)
200
+ if refs.remaining.current:
201
+ refs.remaining.current.value = _remaining_text(total)
202
+ if refs.progress_bar.current:
203
+ refs.progress_bar.current.value = progress_fraction(total)
204
+ page.update()
205
+
206
+
207
+ def build_stats_card(
208
+ page: ft.Page,
209
+ config: config_module.AppConfig,
210
+ refs: StatsCardRefs,
211
+ ) -> ft.Container:
212
+ colors = theme.palette(page)
213
+ total = total_uploads(config)
214
+
215
+ # Hero: a bike-computer-style metric readout — big number, tiny eyebrow.
216
+ number = ft.Text(
217
+ str(total),
218
+ ref=refs.number,
219
+ size=46,
220
+ color=colors["accent"],
221
+ weight=ft.FontWeight.BOLD,
222
+ font_family=theme.FONT_DISPLAY,
223
+ )
224
+ eyebrow = ft.Text(
225
+ "LIFETIME SYNCS",
226
+ size=10,
227
+ weight=ft.FontWeight.W_700,
228
+ color=colors["text_muted"],
229
+ style=ft.TextStyle(letter_spacing=1.6),
230
+ )
231
+ hero = ft.Column(spacing=0, tight=True, controls=[number, eyebrow])
232
+
233
+ rank_chip = ft.Container(
234
+ content=ft.Text(
235
+ rank_for(total).upper(),
236
+ ref=refs.rank,
237
+ size=11,
238
+ weight=ft.FontWeight.W_700,
239
+ color=colors["accent"],
240
+ style=ft.TextStyle(letter_spacing=0.8),
241
+ ),
242
+ padding=ft.Padding(theme.SPACE_SM, 5, theme.SPACE_SM, 5),
243
+ bgcolor=colors["accent_soft"],
244
+ border_radius=999,
245
+ )
246
+ breakdown = ft.Text(
247
+ _breakdown_text(config),
248
+ ref=refs.breakdown,
249
+ size=11,
250
+ color=colors["text_muted"],
251
+ text_align=ft.TextAlign.RIGHT,
252
+ )
253
+ meta = ft.Column(
254
+ spacing=theme.SPACE_XS,
255
+ horizontal_alignment=ft.CrossAxisAlignment.END,
256
+ controls=[rank_chip, breakdown],
257
+ )
258
+
259
+ top_row = ft.Row(
260
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
261
+ vertical_alignment=ft.CrossAxisAlignment.START,
262
+ controls=[hero, meta],
263
+ )
264
+
265
+ target = ft.Text(
266
+ _target_text(total),
267
+ ref=refs.target,
268
+ size=11,
269
+ weight=ft.FontWeight.W_600,
270
+ color=colors["text_muted"],
271
+ font_family=f"{theme.FONT_BODY}Medium",
272
+ )
273
+ remaining = ft.Text(
274
+ _remaining_text(total),
275
+ ref=refs.remaining,
276
+ size=11,
277
+ color=colors["text_muted"],
278
+ )
279
+ progress_header = ft.Row(
280
+ alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
281
+ controls=[target, remaining],
282
+ )
283
+ progress_bar = ft.ProgressBar(
284
+ ref=refs.progress_bar,
285
+ value=progress_fraction(total),
286
+ color=colors["accent"],
287
+ bgcolor=colors["surface_alt"],
288
+ bar_height=6,
289
+ border_radius=3,
290
+ )
291
+ progress = ft.Column(
292
+ spacing=theme.SPACE_XS,
293
+ controls=[progress_header, progress_bar],
294
+ )
295
+
296
+ return ft.Container(
297
+ content=ft.Column(
298
+ spacing=theme.SPACE_MD,
299
+ controls=[
300
+ top_row,
301
+ progress,
302
+ ft.TextButton(
303
+ "Saved you time? Buy me a coffee",
304
+ url=KOFI_URL,
305
+ style=ft.ButtonStyle(
306
+ color=colors["text_muted"],
307
+ padding=ft.Padding(0, 0, 0, 0),
308
+ ),
309
+ ),
310
+ ],
311
+ ),
312
+ padding=theme.SPACE_LG,
313
+ bgcolor=colors["surface"],
314
+ border=ft.Border.all(1, colors["border"]),
315
+ border_radius=theme.RADIUS_MD,
316
+ )
317
+
318
+
319
+ async def show_kofi_dialog(page: ft.Page) -> None:
320
+ colors = theme.palette(page)
321
+
322
+ page.show_dialog(
323
+ ft.AlertDialog(
324
+ modal=True,
325
+ shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD),
326
+ title=theme.display_text("Support development", size=22),
327
+ content=ft.Text(
328
+ "If Intervals Sync saves you time, consider supporting development on Ko-fi. "
329
+ "Tips help fund bug fixes, releases, and new features.",
330
+ size=13,
331
+ color=colors["text_muted"],
332
+ ),
333
+ actions=[
334
+ ft.TextButton("Not now", on_click=lambda _: page.pop_dialog()),
335
+ ft.TextButton("Support on Ko-fi", url=KOFI_URL),
336
+ ],
337
+ )
338
+ )
339
+ page.update()
340
+
341
+
342
+ def kofi_header_button(page: ft.Page) -> ft.IconButton:
343
+ colors = theme.palette(page)
344
+
345
+ async def on_click(_: ft.ControlEvent) -> None:
346
+ await show_kofi_dialog(page)
347
+
348
+ return ft.IconButton(
349
+ icon=ft.Icons.LOCAL_CAFE_OUTLINED,
350
+ tooltip="Support on Ko-fi",
351
+ icon_color=colors["text_muted"],
352
+ on_click=on_click,
353
+ )
354
+
355
+
356
+ def _build_confetti() -> list[ft.Container]:
357
+ """Small colored squares/circles that start near the top of the card."""
358
+ particles: list[ft.Container] = []
359
+ for _ in range(30):
360
+ size = random.randint(6, 12)
361
+ start_left = random.uniform(0, _CELEBRATION_WIDTH)
362
+ duration = random.randint(950, 1500)
363
+ particle = ft.Container(
364
+ width=size,
365
+ height=size,
366
+ bgcolor=random.choice(_CONFETTI_COLORS),
367
+ border_radius=size / 2 if random.random() > 0.5 else 2,
368
+ left=start_left,
369
+ top=random.uniform(-24, 8),
370
+ opacity=1.0,
371
+ rotate=ft.Rotate(0, alignment=ft.Alignment.CENTER),
372
+ ignore_interactions=True,
373
+ animate_position=ft.Animation(
374
+ duration=ft.Duration(milliseconds=duration),
375
+ curve=ft.AnimationCurve.EASE_IN,
376
+ ),
377
+ animate_opacity=ft.Animation(
378
+ duration=ft.Duration(milliseconds=duration),
379
+ curve=ft.AnimationCurve.EASE_IN,
380
+ ),
381
+ animate_rotation=ft.Animation(
382
+ duration=ft.Duration(milliseconds=duration),
383
+ curve=ft.AnimationCurve.LINEAR,
384
+ ),
385
+ )
386
+ particle.data = {
387
+ "left": start_left + random.uniform(-50, 50),
388
+ "top": _CELEBRATION_HEIGHT + random.uniform(0, 60),
389
+ "angle": random.uniform(-math.pi * 3, math.pi * 3),
390
+ }
391
+ particles.append(particle)
392
+ return particles
393
+
394
+
395
+ async def _play_celebration(
396
+ page: ft.Page,
397
+ number_ref: ft.Ref[ft.Container],
398
+ particles: list[ft.Container],
399
+ ) -> None:
400
+ await asyncio.sleep(0.05)
401
+ if number_ref.current is not None:
402
+ number_ref.current.scale = 1.0
403
+ number_ref.current.opacity = 1.0
404
+ for particle in particles:
405
+ target = particle.data
406
+ particle.left = target["left"]
407
+ particle.top = target["top"]
408
+ particle.opacity = 0.0
409
+ particle.rotate = ft.Rotate(target["angle"], alignment=ft.Alignment.CENTER)
410
+ try:
411
+ page.update()
412
+ except Exception:
413
+ pass
414
+
415
+
416
+ async def show_milestone_dialog(page: ft.Page, milestone: int) -> None:
417
+ colors = theme.palette(page)
418
+ title = milestone_title(milestone)
419
+ message = milestone_message(milestone)
420
+
421
+ number_ref: ft.Ref[ft.Container] = ft.Ref()
422
+ big_number = ft.Container(
423
+ ref=number_ref,
424
+ content=theme.display_text(
425
+ str(milestone),
426
+ size=76,
427
+ color=colors["accent"],
428
+ weight=ft.FontWeight.BOLD,
429
+ ),
430
+ alignment=ft.Alignment.CENTER,
431
+ opacity=0.0,
432
+ scale=0.5,
433
+ animate_scale=ft.Animation(
434
+ duration=ft.Duration(milliseconds=520),
435
+ curve=ft.AnimationCurve.EASE_OUT_BACK,
436
+ ),
437
+ animate_opacity=ft.Animation(
438
+ duration=ft.Duration(milliseconds=360),
439
+ curve=ft.AnimationCurve.EASE_OUT,
440
+ ),
441
+ )
442
+
443
+ content_column = ft.Column(
444
+ tight=True,
445
+ spacing=theme.SPACE_XS,
446
+ horizontal_alignment=ft.CrossAxisAlignment.CENTER,
447
+ controls=[
448
+ big_number,
449
+ ft.Text(
450
+ "SYNCS COMPLETED",
451
+ size=11,
452
+ weight=ft.FontWeight.W_600,
453
+ color=colors["text_muted"],
454
+ text_align=ft.TextAlign.CENTER,
455
+ ),
456
+ ft.Container(height=theme.SPACE_SM),
457
+ theme.display_text(title, size=24, color=colors["text"]),
458
+ ft.Container(height=2),
459
+ ft.Text(
460
+ message,
461
+ size=13,
462
+ color=colors["text_muted"],
463
+ text_align=ft.TextAlign.CENTER,
464
+ ),
465
+ ft.Container(height=theme.SPACE_MD),
466
+ ft.Text(
467
+ _KOFI_LINE,
468
+ size=12,
469
+ color=colors["text_muted"],
470
+ text_align=ft.TextAlign.CENTER,
471
+ ),
472
+ ],
473
+ )
474
+
475
+ particles = _build_confetti()
476
+ content = ft.Container(
477
+ width=_CELEBRATION_WIDTH,
478
+ height=_CELEBRATION_HEIGHT,
479
+ content=ft.Stack(
480
+ controls=[
481
+ ft.Container(
482
+ width=_CELEBRATION_WIDTH,
483
+ height=_CELEBRATION_HEIGHT,
484
+ alignment=ft.Alignment.CENTER,
485
+ content=content_column,
486
+ ),
487
+ *particles,
488
+ ],
489
+ ),
490
+ )
491
+
492
+ page.show_dialog(
493
+ ft.AlertDialog(
494
+ modal=True,
495
+ shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD),
496
+ content=content,
497
+ actions=[
498
+ ft.TextButton("Buy me a coffee", url=KOFI_URL),
499
+ ft.FilledButton(
500
+ "Keep rolling",
501
+ on_click=lambda _: page.pop_dialog(),
502
+ ),
503
+ ],
504
+ )
505
+ )
506
+ page.update()
507
+
508
+ page.run_task(_play_celebration, page, number_ref, particles)
509
+
510
+
511
+ # --- Dev-only milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1) ---
512
+
513
+
514
+ def dev_mode_enabled() -> bool:
515
+ return os.environ.get("INTERVALSSYNC_DEV_GAMIFICATION", "").strip().lower() in {
516
+ "1",
517
+ "true",
518
+ "yes",
519
+ }
520
+
521
+
522
+ def dev_reset_stats(config: config_module.AppConfig) -> None:
523
+ config.lifetime_activities_uploaded = 0
524
+ config.lifetime_workouts_uploaded = 0
525
+ config.celebrated_milestones = []
526
+ config.stats_seeded = True
527
+ config_module.save(config)
528
+
529
+
530
+ def dev_set_total(config: config_module.AppConfig, total: int) -> None:
531
+ total = max(0, total)
532
+ config.lifetime_activities_uploaded = total
533
+ config.lifetime_workouts_uploaded = 0
534
+ config.celebrated_milestones = [milestone for milestone in MILESTONES if milestone <= total]
535
+ config.stats_seeded = True
536
+ config_module.save(config)
537
+
538
+
539
+ def dev_bump_and_maybe_celebrate(
540
+ page: ft.Page,
541
+ config: config_module.AppConfig,
542
+ stats_refs: StatsCardRefs,
543
+ *,
544
+ activities: int = 0,
545
+ workouts: int = 0,
546
+ ) -> None:
547
+ milestone = record_uploads(config, activities=activities, workouts=workouts)
548
+ update_stats_display(page, config, stats_refs)
549
+ if milestone is None:
550
+ page.update()
551
+ return
552
+
553
+ async def _celebrate() -> None:
554
+ await show_milestone_dialog(page, milestone)
555
+
556
+ page.run_task(_celebrate)
557
+
558
+
559
+ def build_dev_milestone_panel(
560
+ page: ft.Page,
561
+ config: config_module.AppConfig,
562
+ stats_refs: StatsCardRefs,
563
+ ) -> ft.Container:
564
+ colors = theme.palette(page)
565
+ total_field = ft.TextField(
566
+ label="Total transfers",
567
+ value=str(total_uploads(config)),
568
+ keyboard_type=ft.KeyboardType.NUMBER,
569
+ width=140,
570
+ dense=True,
571
+ )
572
+
573
+ def _refresh_total_field() -> None:
574
+ total_field.value = str(total_uploads(config))
575
+
576
+ def on_reset(_: ft.ControlEvent) -> None:
577
+ dev_reset_stats(config)
578
+ update_stats_display(page, config, stats_refs)
579
+ _refresh_total_field()
580
+ page.update()
581
+
582
+ def on_bump(activities: int = 0, workouts: int = 0):
583
+ def handler(_: ft.ControlEvent) -> None:
584
+ dev_bump_and_maybe_celebrate(
585
+ page,
586
+ config,
587
+ stats_refs,
588
+ activities=activities,
589
+ workouts=workouts,
590
+ )
591
+ _refresh_total_field()
592
+ page.update()
593
+
594
+ return handler
595
+
596
+ def on_apply_total(_: ft.ControlEvent) -> None:
597
+ try:
598
+ total = int((total_field.value or "0").strip())
599
+ except ValueError:
600
+ return
601
+ dev_set_total(config, total)
602
+ update_stats_display(page, config, stats_refs)
603
+ _refresh_total_field()
604
+ page.update()
605
+
606
+ def on_preview(milestone: int):
607
+ async def handler(_: ft.ControlEvent) -> None:
608
+ await show_milestone_dialog(page, milestone)
609
+
610
+ return handler
611
+
612
+ preview_buttons = [
613
+ ft.TextButton(str(milestone), on_click=on_preview(milestone))
614
+ for milestone in MILESTONES
615
+ ]
616
+
617
+ return ft.Container(
618
+ content=ft.Column(
619
+ spacing=theme.SPACE_SM,
620
+ controls=[
621
+ ft.Text(
622
+ "DEV milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1)",
623
+ size=11,
624
+ weight=ft.FontWeight.W_600,
625
+ color=ft.Colors.ORANGE_400,
626
+ ),
627
+ ft.Row(
628
+ wrap=True,
629
+ spacing=theme.SPACE_SM,
630
+ controls=[
631
+ ft.OutlinedButton("+1 activity", on_click=on_bump(activities=1)),
632
+ ft.OutlinedButton("+1 workout", on_click=on_bump(workouts=1)),
633
+ ft.OutlinedButton("+5 transfers", on_click=on_bump(activities=5)),
634
+ ft.OutlinedButton("Reset stats", on_click=on_reset),
635
+ ],
636
+ ),
637
+ ft.Row(
638
+ spacing=theme.SPACE_SM,
639
+ controls=[
640
+ total_field,
641
+ ft.OutlinedButton("Set total", on_click=on_apply_total),
642
+ ],
643
+ ),
644
+ ft.Row(
645
+ wrap=True,
646
+ spacing=0,
647
+ controls=[
648
+ ft.Text("Preview popup:", size=11, color=colors["text_muted"]),
649
+ *preview_buttons,
650
+ ],
651
+ ),
652
+ ],
653
+ ),
654
+ padding=theme.SPACE_MD,
655
+ bgcolor=colors["surface_alt"],
656
+ border=ft.Border.all(1, ft.Colors.ORANGE_400),
657
+ border_radius=theme.RADIUS_SM,
658
+ )