Skip to main content

max / audiofiles

27.2 KB · 733 lines History Blame Raw
1 //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow.
2
3 use egui;
4 use tracing::{error, warn};
5
6 use audiofiles_sync::{AppPricing, BillingInterval, SyncManager, SyncState, SyncStatus};
7
8 use crate::state::{BrowserState, ConfirmAction};
9 use crate::ui::theme;
10 use crate::ui::widgets;
11
12 const GIB: i64 = 1024 * 1024 * 1024;
13
14 fn format_cents(cents: i64) -> String {
15 let dollars = cents / 100;
16 let pennies = cents % 100;
17 if pennies == 0 {
18 format!("${dollars}")
19 } else {
20 format!("${dollars}.{pennies:02}")
21 }
22 }
23
24 fn format_cap(cap_bytes: i64) -> String {
25 let gib = cap_bytes / GIB;
26 if gib >= 1024 {
27 format!("{:.1} TiB", gib as f64 / 1024.0)
28 } else {
29 format!("{} GiB", gib)
30 }
31 }
32
33 /// Draw the sync settings panel as a floating window.
34 pub fn draw_sync_panel(
35 ctx: &egui::Context,
36 state: &mut BrowserState,
37 sync: &SyncManager,
38 ) {
39 // Consume any pending disconnect set by execute_confirmed_action last frame.
40 // The confirm dispatcher in bulk_ops.rs runs without a SyncManager handle,
41 // so the actual sync.disconnect() lands here.
42 if state.sync.pending_disconnect {
43 state.sync.pending_disconnect = false;
44 sync.disconnect();
45 state.status = "Disconnected from cloud sync.".to_string();
46 }
47
48 // Drop the cached auth URL once we've left the Authenticating state. It's
49 // only meaningful while the Copy URL fallback is on screen, and lingering
50 // would leak the PKCE state into a stale display if the user reopens the
51 // panel later.
52 if !matches!(sync.status().state, SyncState::Authenticating)
53 && state.sync.auth_url.is_some()
54 {
55 state.sync.auth_url = None;
56 }
57
58 // Drop the per-VFS storage cache when the panel closes so reopening fetches
59 // fresh numbers (the user may have imported/deleted between sessions).
60 if !state.sync.show_panel {
61 state.sync.vfs_storage_fetched = false;
62 state.sync.vfs_storage_cache.clear();
63 }
64
65 let mut open = state.sync.show_panel;
66 widgets::modal_window_with_open(
67 ctx,
68 "Cloud Sync",
69 Some(&mut open),
70 false,
71 Some(360.0),
72 |ui| {
73 let status = sync.status();
74
75 match &status.state {
76 SyncState::Disconnected => {
77 draw_disconnected(ui, state, sync);
78 }
79 SyncState::Authenticating => {
80 draw_authenticating(ui, state, sync);
81 }
82 SyncState::NeedsEncryption { has_server_key } => {
83 draw_needs_encryption(ui, state, sync, *has_server_key);
84 }
85 SyncState::Ready | SyncState::Syncing => {
86 draw_ready(ui, state, sync, &status);
87 }
88 }
89
90 // Error banner with Retry + Dismiss. Retry is only meaningful in
91 // Ready/Syncing state (calls sync_now); in other states the user's
92 // primary action is already on screen (Connect, Set Password), so
93 // Retry hides itself and Dismiss is the only escape.
94 if let Some(err) = status.last_error.clone() {
95 ui.add_space(theme::space::SM);
96 ui.separator();
97 ui.add_space(theme::space::SM);
98 egui::Frame::new()
99 .fill(theme::bg_tertiary())
100 .corner_radius(egui::CornerRadius::same(4))
101 .inner_margin(egui::Margin::same(8))
102 .show(ui, |ui| {
103 ui.label(egui::RichText::new(err).color(theme::accent_red()));
104 ui.add_space(theme::space::SM);
105 ui.horizontal(|ui| {
106 let retryable = matches!(
107 status.state,
108 SyncState::Ready | SyncState::Syncing,
109 );
110 if retryable
111 && widgets::secondary_button(ui, "Retry").clicked()
112 {
113 sync.clear_last_error();
114 sync.sync_now();
115 }
116 if widgets::secondary_button(ui, "Dismiss").clicked() {
117 sync.clear_last_error();
118 }
119 });
120 });
121 }
122 },
123 );
124 state.sync.show_panel = open;
125 }
126
127 /// Draw the subscription status/purchase section for blob sync.
128 fn draw_subscription_section(
129 ui: &mut egui::Ui,
130 state: &mut BrowserState,
131 sync: &SyncManager,
132 ) {
133 let sync_status = sync.status();
134
135 // Loading-flag timeout: if a fetch or checkout never resolves, the panel
136 // would otherwise spin "Checking subscription..." (or grey out every
137 // Subscribe button) forever. After 30s without a response, clear the flag
138 // so the user can retry. The status message is the only feedback channel
139 // for this surface — see C-3's wiring of the same pattern.
140 const LOADING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
141 if let Some(at) = state.sync.subscription_loading_at
142 && at.elapsed() >= LOADING_TIMEOUT && sync_status.subscription.is_none() {
143 state.sync.subscription_loading = false;
144 state.sync.subscription_loading_at = None;
145 state.status = "Subscription check timed out. Click Retry to try again.".to_string();
146 }
147 if let Some(at) = state.sync.checkout_loading_at
148 && at.elapsed() >= LOADING_TIMEOUT {
149 state.sync.checkout_loading = false;
150 state.sync.checkout_loading_at = None;
151 state.status = "Checkout timed out. The browser tab may have closed; try again.".to_string();
152 }
153 // If the checkout / cap-change call reported an error, the sync manager surfaces
154 // it in `last_error` (shown by the error banner above). Clear the checkout
155 // loading flag immediately so the Subscribe / cap-change button re-enables for a
156 // retry, rather than staying greyed out until the 30s timeout above. (Only the
157 // checkout flag — subscription fetch failures don't set `last_error`, so a
158 // general sync error must not interrupt the "Checking subscription..." spinner.)
159 if sync_status.last_error.is_some() && state.sync.checkout_loading {
160 state.sync.checkout_loading = false;
161 state.sync.checkout_loading_at = None;
162 }
163
164 // Trigger initial fetch if not yet loaded
165 if sync_status.subscription.is_none() && !state.sync.subscription_loading {
166 state.sync.subscription_loading = true;
167 state.sync.subscription_loading_at = Some(std::time::Instant::now());
168 sync.fetch_subscription_status();
169 }
170
171 if state.sync.subscription_loading && sync_status.subscription.is_none() {
172 ui.horizontal(|ui| {
173 ui.label(egui::RichText::new("Checking subscription...").weak());
174 if ui.small_button("Retry").clicked() {
175 state.sync.subscription_loading_at = Some(std::time::Instant::now());
176 sync.fetch_subscription_status();
177 }
178 });
179 return;
180 }
181
182 // Once loaded, clear loading flags
183 if sync_status.subscription.is_some() {
184 state.sync.subscription_loading = false;
185 state.sync.subscription_loading_at = None;
186 state.sync.checkout_loading = false;
187 state.sync.checkout_loading_at = None;
188 }
189
190 match &sync_status.subscription {
191 Some(sub) if sub.active => {
192 let limit = sub.storage_limit_bytes.unwrap_or(0);
193 let used = sub.storage_used_bytes.unwrap_or(0);
194 let interval = sub.interval.as_deref().unwrap_or("monthly");
195
196 ui.label(format!(
197 "Subscribed: {} ({})",
198 format_cap(limit),
199 interval,
200 ));
201
202 if limit > 0 {
203 let used_gb = used as f64 / GIB as f64;
204 let limit_gb = limit as f64 / GIB as f64;
205 let fraction = (used as f32) / (limit as f32);
206 ui.add(
207 egui::ProgressBar::new(fraction)
208 .text(format!("{used_gb:.1} / {limit_gb:.0} GiB")),
209 );
210 }
211
212 if let Some(pending) = sub.pending_storage_limit_bytes {
213 ui.add_space(theme::space::XS);
214 ui.label(
215 egui::RichText::new(format!(
216 "Pending: cap changes to {} at next renewal.",
217 format_cap(pending)
218 ))
219 .weak(),
220 );
221 }
222
223 // Cap-change slider for subscribed users.
224 if let Some(pricing) = &sync_status.pricing {
225 let pricing = pricing.clone();
226 let interval_enum = BillingInterval::from_str(interval);
227 ui.add_space(theme::space::MD);
228 ui.label(egui::RichText::new("Adjust cap (takes effect next cycle):").weak());
229 if let Some(cap) = draw_cap_picker(ui, state, &pricing, interval_enum, "Update cap") {
230 sync.queue_cap_change(cap);
231 }
232 }
233 }
234 _ => {
235 if let Some(pricing) = &sync_status.pricing {
236 let pricing = pricing.clone();
237 ui.label("Pick a storage cap for audio file sync:");
238 ui.add_space(theme::space::XS);
239 ui.label(
240 egui::RichText::new(
241 "Annual is 2 months free — fewer Stripe fees, so we pass the savings on.",
242 )
243 .weak()
244 .size(11.0),
245 );
246 ui.add_space(theme::space::SM);
247
248 // One cap slider, then annual/monthly checkout buttons for that
249 // single chosen cap — two priced choices, not two sliders that
250 // secretly share a value.
251 let cap_bytes = draw_cap_slider(ui, state, &pricing);
252 ui.add_space(theme::space::SM);
253
254 if state.sync.checkout_loading {
255 ui.horizontal(|ui| {
256 ui.spinner();
257 ui.label(egui::RichText::new("Opening browser...").weak());
258 });
259 } else {
260 let annual = format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Annual));
261 let monthly = format_cents(pricing.quote_cents(cap_bytes, BillingInterval::Monthly));
262 ui.horizontal(|ui| {
263 if widgets::primary_button(ui, &format!("Subscribe annual \u{2014} {annual}/yr")).clicked() {
264 state.sync.checkout_loading = true;
265 state.sync.checkout_loading_at = Some(std::time::Instant::now());
266 sync.subscribe(cap_bytes, BillingInterval::Annual);
267 }
268 if widgets::secondary_button(ui, &format!("Monthly \u{2014} {monthly}/mo")).clicked() {
269 state.sync.checkout_loading = true;
270 state.sync.checkout_loading_at = Some(std::time::Instant::now());
271 sync.subscribe(cap_bytes, BillingInterval::Monthly);
272 }
273 });
274 }
275 } else {
276 ui.label(egui::RichText::new("Loading pricing...").weak());
277 }
278 }
279 }
280 }
281
282 /// Draw a fallback panel when no SyncManager is available (no embedded API key in dev builds).
283 pub fn draw_sync_not_configured(ctx: &egui::Context, state: &mut BrowserState) {
284 let mut open = state.sync.show_panel;
285 widgets::modal_window_with_open(
286 ctx,
287 "Cloud Sync",
288 Some(&mut open),
289 false,
290 Some(380.0),
291 |ui| {
292 ui.label("Cloud sync is unavailable.");
293 ui.add_space(theme::space::MD);
294 ui.label(
295 egui::RichText::new("Open a vault and ensure your license or trial is active to enable sync.")
296 .small()
297 .weak(),
298 );
299 },
300 );
301 state.sync.show_panel = open;
302 }
303
304 /// Disconnected state: invite user to connect.
305 fn draw_disconnected(
306 ui: &mut egui::Ui,
307 state: &mut BrowserState,
308 sync: &SyncManager,
309 ) {
310 ui.label("Connect your audiofiles vault to Makenot.work for cross-device sync.");
311 ui.add_space(theme::space::MD);
312 ui.label(
313 egui::RichText::new("Metadata (tags, vault structure, analysis) syncs automatically. Audio file sync is per-vault opt-in.")
314 .small()
315 .weak(),
316 );
317 ui.add_space(theme::space::LG);
318 if ui.button("Connect").clicked() {
319 match sync.start_auth() {
320 Ok(auth_url) => {
321 #[cfg(target_os = "macos")]
322 let _ = std::process::Command::new("open").arg(&auth_url).spawn();
323 #[cfg(target_os = "linux")]
324 let _ = std::process::Command::new("xdg-open").arg(&auth_url).spawn();
325 #[cfg(target_os = "windows")]
326 let _ = std::process::Command::new("cmd").args(["/c", "start", &auth_url]).spawn();
327 state.sync.auth_code_input.clear();
328 // Cache for the Authenticating screen's "Copy URL" fallback —
329 // the browser may have failed to open silently.
330 state.sync.auth_url = Some(auth_url);
331 }
332 Err(e) => {
333 error!("Failed to start auth: {e}");
334 state.status = format!("Sync connect failed: {e}");
335 }
336 }
337 }
338 }
339
340 /// Authenticating state: waiting for OAuth callback, with Cancel + Copy URL
341 /// escape hatches. Without these the user could be trapped here indefinitely
342 /// when the browser doesn't open, OAuth fails server-side, or they change
343 /// their mind — closing the window doesn't move the underlying state.
344 fn draw_authenticating(
345 ui: &mut egui::Ui,
346 state: &mut BrowserState,
347 sync: &SyncManager,
348 ) {
349 ui.horizontal(|ui| {
350 ui.label("Waiting for authentication in your browser...");
351 ui.spinner();
352 });
353 ui.add_space(theme::space::MD);
354 ui.label(
355 egui::RichText::new("The app will update automatically once you sign in.")
356 .small()
357 .weak(),
358 );
359
360 // Copy-URL fallback: the browser may have failed to open silently (wrong
361 // default browser, headless system, popup blocker on a Tauri host).
362 if let Some(ref url) = state.sync.auth_url.clone() {
363 ui.add_space(theme::space::MD);
364 ui.separator();
365 ui.add_space(theme::space::SM);
366 ui.label(
367 egui::RichText::new("Browser didn't open? Copy this URL into a browser manually.")
368 .small()
369 .weak(),
370 );
371 ui.add_space(theme::space::XS);
372 ui.horizontal(|ui| {
373 // Read-only truncated URL display + Copy button. The URL itself is
374 // long (OAuth + PKCE + state) so truncation is necessary.
375 let mut shown = url.clone();
376 ui.add(
377 egui::TextEdit::singleline(&mut shown)
378 .desired_width(ui.available_width() - 70.0),
379 );
380 if ui.button("Copy").clicked() {
381 ui.ctx().copy_text(url.clone());
382 state.status = "Copied auth URL.".to_string();
383 }
384 });
385 }
386
387 ui.add_space(theme::space::LG);
388 if ui.button("Cancel").clicked() {
389 sync.cancel_auth();
390 state.sync.auth_url = None;
391 state.status = "Sync connection cancelled.".to_string();
392 }
393 }
394
395 /// NeedsEncryption state: password setup.
396 fn draw_needs_encryption(
397 ui: &mut egui::Ui,
398 state: &mut BrowserState,
399 sync: &SyncManager,
400 has_server_key: bool,
401 ) {
402 if has_server_key {
403 ui.label("Enter your encryption password to unlock this device.");
404 ui.add_space(theme::space::MD);
405 ui.label(
406 egui::RichText::new("This is the password you set when you first connected.")
407 .small()
408 .weak(),
409 );
410 } else {
411 ui.label("Set an encryption password to protect your synced data.");
412 ui.add_space(theme::space::MD);
413 ui.label(
414 egui::RichText::new(
415 "Your sample audio, filenames, tags, folder structure, \
416 and analysis are encrypted before leaving your device. \
417 The server sees row identifiers (opaque hashes) and \
418 change timestamps.",
419 )
420 .small()
421 .weak(),
422 );
423 ui.add_space(theme::space::SM);
424 // Warning banner: a typo in the next field permanently re-encrypts the
425 // cloud blob under a key no one will ever re-derive. The confirm field
426 // below is the only guard, so the copy needs to outweigh the form.
427 widgets::warning_banner(
428 ui,
429 "Remember this password. It cannot be recovered, and any data already in your cloud blob will be unreadable if you forget it.",
430 );
431 }
432
433 ui.add_space(theme::space::LG);
434 ui.horizontal(|ui| {
435 ui.label("Password:");
436 ui.add(
437 egui::TextEdit::singleline(&mut state.sync.encryption_input)
438 .password(true)
439 .desired_width(200.0),
440 );
441 });
442
443 // First-time setup: confirm field + length gate. The unlock path doesn't
444 // need confirmation — a typo there is recoverable (just re-enter).
445 let (can_submit, hint): (bool, Option<&str>) = if has_server_key {
446 (!state.sync.encryption_input.is_empty(), None)
447 } else {
448 ui.add_space(theme::space::SM);
449 ui.horizontal(|ui| {
450 ui.label("Confirm: ");
451 ui.add(
452 egui::TextEdit::singleline(&mut state.sync.encryption_confirm_input)
453 .password(true)
454 .desired_width(200.0),
455 );
456 });
457 let pw = &state.sync.encryption_input;
458 let confirm = &state.sync.encryption_confirm_input;
459 if pw.is_empty() {
460 (false, None)
461 } else if pw.len() < 8 {
462 (false, Some("Password must be at least 8 characters."))
463 } else if confirm.is_empty() {
464 (false, None)
465 } else if pw != confirm {
466 (false, Some("Passwords don't match."))
467 } else {
468 (true, None)
469 }
470 };
471
472 if let Some(msg) = hint {
473 ui.add_space(theme::space::XS);
474 ui.label(
475 egui::RichText::new(msg)
476 .small()
477 .color(theme::text_muted()),
478 );
479 }
480
481 ui.add_space(theme::space::MD);
482 let button_label = if has_server_key {
483 "Unlock"
484 } else {
485 "Set Password"
486 };
487 if ui
488 .add_enabled(can_submit, egui::Button::new(button_label))
489 .clicked()
490 {
491 let password = state.sync.encryption_input.clone();
492 state.sync.encryption_input.clear();
493 state.sync.encryption_confirm_input.clear();
494 sync.setup_encryption(password, !has_server_key);
495 }
496 }
497
498 /// Ready state: status display, controls, per-VFS sync toggles.
499 fn draw_ready(
500 ui: &mut egui::Ui,
501 state: &mut BrowserState,
502 sync: &SyncManager,
503 status: &SyncStatus,
504 ) {
505 // Status info
506 ui.horizontal(|ui| {
507 let state_label = match status.state {
508 SyncState::Syncing => "Syncing...",
509 _ => "Connected",
510 };
511 ui.label(state_label);
512
513 if status.state == SyncState::Syncing {
514 ui.spinner();
515 }
516 });
517
518 if let Some(ref last) = status.last_sync_at {
519 ui.label(
520 egui::RichText::new(format!("Last sync: {last}"))
521 .small()
522 .weak(),
523 );
524 }
525
526 if status.pending_changes > 0 {
527 ui.label(format!("{} pending changes", status.pending_changes));
528 }
529
530 ui.add_space(theme::space::MD);
531
532 // Sync Now button
533 let syncing = status.state == SyncState::Syncing;
534 if ui
535 .add_enabled(!syncing, egui::Button::new("Sync Now"))
536 .clicked()
537 {
538 sync.sync_now();
539 }
540
541 ui.add_space(theme::space::MD);
542 ui.separator();
543
544 // Auto-sync settings — collapsed by default so the Ready view reads as
545 // status-first; the user expands when they want to tune cadence (p-6).
546 egui::CollapsingHeader::new(egui::RichText::new("Auto-sync").strong())
547 .id_salt("sync_auto_section")
548 .default_open(false)
549 .show(ui, |ui| {
550 let mut auto_sync = status.auto_sync_enabled;
551 if ui.checkbox(&mut auto_sync, "Auto-sync").changed() {
552 sync.update_settings(Some(auto_sync), None);
553 }
554
555 if auto_sync {
556 ui.horizontal(|ui| {
557 ui.label("Interval:");
558 let intervals = [5u32, 15, 30, 60];
559 let current = status.sync_interval_minutes;
560 // If the persisted interval falls outside the canonical list (a
561 // legacy config or manual DB edit), render an extra pill marked
562 // active so the value is visible (m-15). Clicking a canonical pill
563 // replaces it as usual.
564 let custom = if !intervals.contains(&current) {
565 Some(current)
566 } else {
567 None
568 };
569 if let Some(c) = custom {
570 let label = format!("{c}m (custom)");
571 let _ = ui.selectable_label(true, label);
572 }
573 for mins in intervals {
574 let label = if mins == 60 {
575 "1h".to_string()
576 } else {
577 format!("{mins}m")
578 };
579 if ui
580 .selectable_label(current == mins, label)
581 .clicked()
582 {
583 sync.update_settings(None, Some(mins));
584 }
585 }
586 });
587 }
588 }); // end Auto-sync CollapsingHeader
589
590 // Audio file cloud sync — also collapsed by default. Subscription state
591 // and per-vault toggles read as a single configuration group rather than
592 // three separator-delimited slices (p-6).
593 egui::CollapsingHeader::new(egui::RichText::new("Audio file cloud sync").strong())
594 .id_salt("sync_audio_section")
595 .default_open(false)
596 .show(ui, |ui| {
597 ui.label(
598 egui::RichText::new("Metadata always syncs free. Audio file sync requires a subscription.")
599 .small()
600 .weak(),
601 );
602 ui.add_space(theme::space::SM);
603
604 draw_subscription_section(ui, state, sync);
605
606 ui.add_space(theme::space::MD);
607
608 // Per-VFS "Sync audio files" toggles (only show if subscribed)
609 let subscribed = sync
610 .status()
611 .subscription
612 .as_ref()
613 .is_some_and(|s| s.active);
614
615 if subscribed {
616 // Populate the per-VFS storage cache on the first frame the section
617 // renders. Queries are SQLite-cheap (single indexed SELECT each), but
618 // we still want to avoid running them every frame at 60Hz.
619 if !state.sync.vfs_storage_fetched {
620 for vfs in state.vfs_list.clone().iter() {
621 if let Ok(stats) = state.backend.vfs_storage_stats(vfs.id) {
622 state.sync.vfs_storage_cache.insert(vfs.id.as_i64(), stats);
623 }
624 }
625 state.sync.vfs_storage_fetched = true;
626 }
627 let vfs_list = state.vfs_list.clone();
628 for vfs in vfs_list.iter() {
629 let mut sync_files = vfs.sync_files;
630 if ui.checkbox(&mut sync_files, &vfs.name).changed() {
631 if let Err(e) = state.backend.set_vfs_sync_files(vfs.id, sync_files) {
632 warn!("Failed to update sync_files for VFS {}: {e}", vfs.name);
633 } else {
634 state.refresh_vfs_list();
635 }
636 }
637 // Size hint under each checkbox. Makes the choice concrete: a user
638 // toggling "Library" on now sees "12.4 GB across 4,820 samples"
639 // instead of agreeing to upload an abstract amount.
640 if let Some((count, bytes)) = state.sync.vfs_storage_cache.get(&vfs.id.as_i64()) {
641 let count_str = if *count == 1 { "1 sample".to_string() } else { format!("{count} samples") };
642 ui.label(
643 egui::RichText::new(format!(" {} across {}", widgets::format_bytes(*bytes), count_str))
644 .small()
645 .color(theme::text_muted()),
646 );
647 }
648 }
649 }
650 }); // end Audio file cloud sync CollapsingHeader
651
652 ui.add_space(theme::space::LG);
653 ui.separator();
654
655 // Disconnect button — always confirmed. Detail line surfaces pending
656 // changes (if any) and the encryption-password requirement on reconnect.
657 if widgets::danger_button(ui, "Disconnect").clicked() {
658 state.pending_confirm = Some(ConfirmAction::DisconnectSync {
659 pending_changes: status.pending_changes,
660 });
661 }
662 }
663
664 /// Draw just the storage-cap slider (GiB, logarithmic) plus a cap-size label,
665 /// clamping the working value to the pricing range. Returns the chosen cap in
666 /// bytes. Used by the subscribe view (one slider feeding two checkout buttons).
667 fn draw_cap_slider(ui: &mut egui::Ui, state: &mut BrowserState, pricing: &AppPricing) -> i64 {
668 let min_gib = (pricing.min_cap_bytes / GIB).max(1);
669 let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
670 state.sync.cap_picker_gib = state.sync.cap_picker_gib.clamp(min_gib, max_gib);
671 ui.add(
672 egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib)
673 .logarithmic(true)
674 .text("GiB"),
675 );
676 let cap_bytes = state.sync.cap_picker_gib * GIB;
677 ui.label(egui::RichText::new(format_cap(cap_bytes)).strong());
678 cap_bytes
679 }
680
681 /// Cap-picker widget: slider in GiB + live price preview + action button.
682 /// Used both for initial subscribe and for queuing a cap change on an active
683 /// subscription. The slider's working value lives on `BrowserState::sync` so
684 /// it survives frames; returns `Some(cap_bytes)` on the frame the button is
685 /// clicked so the caller can fire the action (the helper avoids touching
686 /// `state` further itself, sidestepping borrow conflicts with action closures).
687 fn draw_cap_picker(
688 ui: &mut egui::Ui,
689 state: &mut BrowserState,
690 pricing: &AppPricing,
691 interval: BillingInterval,
692 button_label: &str,
693 ) -> Option<i64> {
694 let min_gib = (pricing.min_cap_bytes / GIB).max(1);
695 let max_gib = (pricing.max_cap_bytes / GIB).max(min_gib);
696 if state.sync.cap_picker_gib < min_gib {
697 state.sync.cap_picker_gib = min_gib;
698 }
699 if state.sync.cap_picker_gib > max_gib {
700 state.sync.cap_picker_gib = max_gib;
701 }
702
703 ui.add(
704 egui::Slider::new(&mut state.sync.cap_picker_gib, min_gib..=max_gib)
705 .logarithmic(true)
706 .text("GiB"),
707 );
708
709 let cap_bytes = state.sync.cap_picker_gib * GIB;
710 let price_cents = pricing.quote_cents(cap_bytes, interval);
711 let interval_word = match interval {
712 BillingInterval::Monthly => "month",
713 BillingInterval::Annual => "year",
714 };
715 ui.label(format!(
716 "{}{}/{}",
717 format_cap(cap_bytes),
718 format_cents(price_cents),
719 interval_word,
720 ));
721
722 let loading = state.sync.checkout_loading;
723 if ui
724 .add_enabled_ui(!loading, |ui| widgets::primary_button(ui, button_label))
725 .inner
726 .clicked()
727 {
728 Some(cap_bytes)
729 } else {
730 None
731 }
732 }
733