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