max / goingson
16 files changed,
+624 insertions,
-60 deletions
| @@ -2250,7 +2250,7 @@ | |||
| 2250 | 2250 | "makeover", | |
| 2251 | 2251 | "makeover-build", | |
| 2252 | 2252 | "makeover-geometry", | |
| 2253 | - | "makeover-layout 0.7.0", | |
| 2253 | + | "makeover-layout", | |
| 2254 | 2254 | "makeover-webview", | |
| 2255 | 2255 | "notify", | |
| 2256 | 2256 | "notify-debouncer-mini", | |
| @@ -2271,7 +2271,9 @@ | |||
| 2271 | 2271 | "synckit-config", | |
| 2272 | 2272 | "tauri", | |
| 2273 | 2273 | "tauri-build", | |
| 2274 | + | "tauri-plugin-biometric", | |
| 2274 | 2275 | "tauri-plugin-dialog", | |
| 2276 | + | "tauri-plugin-haptics", | |
| 2275 | 2277 | "tauri-plugin-notification", | |
| 2276 | 2278 | "tauri-plugin-opener", | |
| 2277 | 2279 | "tauri-plugin-process", | |
| @@ -3376,9 +3378,9 @@ | |||
| 3376 | 3378 | ||
| 3377 | 3379 | [[package]] | |
| 3378 | 3380 | name = "makeover-build" | |
| 3379 | - | version = "0.7.0" | |
| 3381 | + | version = "0.9.0" | |
| 3380 | 3382 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3381 | - | checksum = "6e0f93fd97d0ce7e3854c0dd8edddefac444de8197bbaa215dfced38cf7a03b6" | |
| 3383 | + | checksum = "0a6c9d5c42fda6f19adc4dd3e310bade2ef2f0ea1043c20857c913274f904667" | |
| 3382 | 3384 | dependencies = [ | |
| 3383 | 3385 | "makeover", | |
| 3384 | 3386 | "makeover-geometry", | |
| @@ -3387,51 +3389,45 @@ | |||
| 3387 | 3389 | ||
| 3388 | 3390 | [[package]] | |
| 3389 | 3391 | name = "makeover-geometry" | |
| 3390 | - | version = "0.6.0" | |
| 3392 | + | version = "0.6.2" | |
| 3391 | 3393 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3392 | - | checksum = "b2466b8ca54dba9ef62f3679b33c90a0554e507c23e7b725c178ce9d277f542e" | |
| 3394 | + | checksum = "c4e0cbec6b86c225f5d4a793437fee457b9bf8e93786ee3af0212d668ad8f132" | |
| 3393 | 3395 | ||
| 3394 | 3396 | [[package]] | |
| 3395 | 3397 | name = "makeover-layout" | |
| 3396 | - | version = "0.6.0" | |
| 3398 | + | version = "0.8.2" | |
| 3397 | 3399 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3398 | - | checksum = "ad745601b5220b2244d73a5da3f3854aefe4cdc955dd45c6d3aa5781ad645f5a" | |
| 3399 | - | ||
| 3400 | - | [[package]] | |
| 3401 | - | name = "makeover-layout" | |
| 3402 | - | version = "0.7.0" | |
| 3403 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3404 | - | checksum = "00d6235015ee1a15bc6b3936c5265229de3454524053eb67bf95fc6b741299b0" | |
| 3400 | + | checksum = "9883c75a9d26fce10be2b979c01a74f8c07513800a70ec6bf598de1e7d411f1b" | |
| 3405 | 3401 | ||
| 3406 | 3402 | [[package]] | |
| 3407 | 3403 | name = "makeover-touch" | |
| 3408 | - | version = "0.2.0" | |
| 3404 | + | version = "0.4.0" | |
| 3409 | 3405 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3410 | - | checksum = "064bb24217e6d49ccafbefbcc29c7c42572c164feb98e5876e9ca49e77d58808" | |
| 3406 | + | checksum = "369083d008157d6d95cd2ac0b8dbfee8689d80eb4c2883b48c2ef627a0116fdb" | |
| 3411 | 3407 | dependencies = [ | |
| 3412 | 3408 | "makeover-geometry", | |
| 3413 | - | "makeover-layout 0.7.0", | |
| 3409 | + | "makeover-layout", | |
| 3414 | 3410 | ] | |
| 3415 | 3411 | ||
| 3416 | 3412 | [[package]] | |
| 3417 | 3413 | name = "makeover-tui" | |
| 3418 | - | version = "0.7.0" | |
| 3414 | + | version = "0.8.0" | |
| 3419 | 3415 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3420 | - | checksum = "e0174c5f2e03f2fc856589d061f5718c2bce77e2d15d1ac6d4bdcb0f53134d63" | |
| 3416 | + | checksum = "fef64d9d88e91745b9d558ec2c12246f776d5a0b3a26092029f27efabe53c64e" | |
| 3421 | 3417 | dependencies = [ | |
| 3422 | 3418 | "makeover", | |
| 3423 | - | "makeover-layout 0.6.0", | |
| 3419 | + | "makeover-layout", | |
| 3424 | 3420 | "ratatui", | |
| 3425 | 3421 | ] | |
| 3426 | 3422 | ||
| 3427 | 3423 | [[package]] | |
| 3428 | 3424 | name = "makeover-webview" | |
| 3429 | - | version = "0.13.0" | |
| 3425 | + | version = "0.17.1" | |
| 3430 | 3426 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3431 | - | checksum = "ac7fbadaa7cf1e4a074a0b0c62a9ea3b1616f9de4e40990fab209560b807046f" | |
| 3427 | + | checksum = "c8cfb1a75dcdafc4eab17ca9d1e2ae59f66dea218832c604c1cde5c01a1a799c" | |
| 3432 | 3428 | dependencies = [ | |
| 3433 | 3429 | "makeover-geometry", | |
| 3434 | - | "makeover-layout 0.7.0", | |
| 3430 | + | "makeover-layout", | |
| 3435 | 3431 | "makeover-touch", | |
| 3436 | 3432 | ] | |
| 3437 | 3433 | ||
| @@ -6328,6 +6324,21 @@ | |||
| 6328 | 6324 | "walkdir", | |
| 6329 | 6325 | ] | |
| 6330 | 6326 | ||
| 6327 | + | [[package]] | |
| 6328 | + | name = "tauri-plugin-biometric" | |
| 6329 | + | version = "2.3.2" | |
| 6330 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6331 | + | checksum = "306175b744890b5e4aeb8add6aae7debec1b1504c5087bd0310ed7c9c6feae38" | |
| 6332 | + | dependencies = [ | |
| 6333 | + | "log", | |
| 6334 | + | "serde", | |
| 6335 | + | "serde_json", | |
| 6336 | + | "serde_repr", | |
| 6337 | + | "tauri", | |
| 6338 | + | "tauri-plugin", | |
| 6339 | + | "thiserror 2.0.19", | |
| 6340 | + | ] | |
| 6341 | + | ||
| 6331 | 6342 | [[package]] | |
| 6332 | 6343 | name = "tauri-plugin-dialog" | |
| 6333 | 6344 | version = "2.7.2" | |
| @@ -6370,6 +6381,20 @@ | |||
| 6370 | 6381 | "url", | |
| 6371 | 6382 | ] | |
| 6372 | 6383 | ||
| 6384 | + | [[package]] | |
| 6385 | + | name = "tauri-plugin-haptics" | |
| 6386 | + | version = "2.3.2" | |
| 6387 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6388 | + | checksum = "01b167d598ada05c599f4460595108c8d1aa636030a97d19796c1bcda97d881d" | |
| 6389 | + | dependencies = [ | |
| 6390 | + | "log", | |
| 6391 | + | "serde", | |
| 6392 | + | "serde_json", | |
| 6393 | + | "tauri", | |
| 6394 | + | "tauri-plugin", | |
| 6395 | + | "thiserror 2.0.19", | |
| 6396 | + | ] | |
| 6397 | + | ||
| 6373 | 6398 | [[package]] | |
| 6374 | 6399 | name = "tauri-plugin-notification" | |
| 6375 | 6400 | version = "2.3.3" | |
| @@ -8482,7 +8507,3 @@ | |||
| 8482 | 8507 | "syn 2.0.119", | |
| 8483 | 8508 | "winnow 1.0.4", | |
| 8484 | 8509 | ] | |
| 8485 | - | ||
| 8486 | - | [[patch.unused]] | |
| 8487 | - | name = "supernote-push" | |
| 8488 | - | version = "0.1.0" |
| @@ -78,12 +78,17 @@ | |||
| 78 | 78 | tauri-plugin-window-state = "2.4.1" | |
| 79 | 79 | tauri-plugin-updater = "2" | |
| 80 | 80 | tauri-plugin-process = "2" | |
| 81 | + | # Mobile-only plugins. Both ship a desktop stub whose commands error rather than | |
| 82 | + | # no-op, so they are gated to iOS/Android at the target level in src-tauri rather | |
| 83 | + | # than carried into the desktop binary and guarded at the call site. | |
| 84 | + | tauri-plugin-haptics = "2.3.2" | |
| 85 | + | tauri-plugin-biometric = "2.3.2" | |
| 81 | 86 | ||
| 82 | 87 | # Filesystem watching (db change notifications) | |
| 83 | 88 | notify = "8.2" | |
| 84 | 89 | notify-debouncer-mini = "0.7" | |
| 85 | - | makeover = "2.4.1" | |
| 86 | - | makeover-tui = "0.7.0" | |
| 90 | + | makeover = "2.4" | |
| 91 | + | makeover-tui = "0.8.0" | |
| 87 | 92 | toml = "1.1" | |
| 88 | 93 | ||
| 89 | 94 | # Enums |
| @@ -131,6 +131,15 @@ | |||
| 131 | 131 | workspace = true | |
| 132 | 132 | features = ["image-png", "tray-icon"] | |
| 133 | 133 | ||
| 134 | + | # === Mobile-only dependencies (no desktop backend) === | |
| 135 | + | ||
| 136 | + | # Mobile-only: plugins backed by iOS/Android system frameworks. The desktop half | |
| 137 | + | # of each crate exists but errors on every command, so gating the dependency is | |
| 138 | + | # what keeps a call on desktop a compile error instead of a runtime one. | |
| 139 | + | [target.'cfg(any(target_os = "ios", target_os = "android"))'.dependencies] | |
| 140 | + | tauri-plugin-haptics = { workspace = true } | |
| 141 | + | tauri-plugin-biometric = { workspace = true } | |
| 142 | + | ||
| 134 | 143 | # Android: bundle SQLite (system SQLite not accessible via NDK) | |
| 135 | 144 | [target.'cfg(target_os = "android")'.dependencies] | |
| 136 | 145 | libsqlite3-sys = { version = "0.37", features = ["bundled"] } |
| @@ -29,6 +29,10 @@ | |||
| 29 | 29 | See the TWO AXES section at the top of css/styles.css. --> | |
| 30 | 30 | <script src="js/bootstrap-uimode.js"></script> | |
| 31 | 31 | ||
| 32 | + | <!-- The app lock's pre-paint half, here for the same reason: the overlay | |
| 33 | + | has to be up before the content it hides is. --> | |
| 34 | + | <script src="js/bootstrap-applock.js"></script> | |
| 35 | + | ||
| 32 | 36 | <!-- Cascade layer order, declared before any stylesheet so it is a | |
| 33 | 37 | statement rather than an accident of link order. A layer's position is | |
| 34 | 38 | fixed where the name is FIRST seen, so without this the three generated | |
| @@ -53,6 +57,18 @@ | |||
| 53 | 57 | <link rel="stylesheet" href="css/styles.min.css"> | |
| 54 | 58 | </head> | |
| 55 | 59 | <body> | |
| 60 | + | <!-- Biometric app lock. First in the body so it paints before the header, | |
| 61 | + | and shown by `html.app-locked` rather than by JS creating it. See | |
| 62 | + | js/app-lock.js. --> | |
| 63 | + | <div class="app-lock" id="app-lock" role="dialog" aria-modal="true" aria-labelledby="app-lock-title"> | |
| 64 | + | <div class="app-lock-panel"> | |
| 65 | + | <h1 class="app-lock-title" id="app-lock-title">GoingsOn is locked</h1> | |
| 66 | + | <p class="app-lock-desc">Unlock with biometrics to continue.</p> | |
| 67 | + | <p class="app-lock-status" id="app-lock-status" role="status" aria-live="polite"></p> | |
| 68 | + | <button class="button button--primary" data-act="appLock.prompt">Unlock</button> | |
| 69 | + | </div> | |
| 70 | + | </div> | |
| 71 | + | ||
| 56 | 72 | <a href="#main-content" class="skip-link">Skip to main content</a> | |
| 57 | 73 | <header class="app-header"> | |
| 58 | 74 | <div class="header-content"> | |
| @@ -692,6 +708,7 @@ | |||
| 692 | 708 | <script src="js/router.js"></script> | |
| 693 | 709 | <script src="js/navigation.js"></script> | |
| 694 | 710 | <script src="js/snooze.js"></script> | |
| 711 | + | <script src="js/haptics.js"></script> | |
| 695 | 712 | <script src="js/touch.js"></script> | |
| 696 | 713 | <!-- Domain render modules (load before their parent domains) --> | |
| 697 | 714 | <script src="js/projects-render.js"></script> | |
| @@ -753,6 +770,7 @@ | |||
| 753 | 770 | ||
| 754 | 771 | <!-- Mobile interaction wiring (no-op on desktop) --> | |
| 755 | 772 | <script src="js/mobile.js"></script> | |
| 773 | + | <script src="js/app-lock.js"></script> | |
| 756 | 774 | ||
| 757 | 775 | <!-- OTA update notifications --> | |
| 758 | 776 | <script src="js/shared-updater.js"></script> |
| @@ -321,6 +321,7 @@ | |||
| 321 | 321 | // Preferences | |
| 322 | 322 | $crate::commands::get_preferences, | |
| 323 | 323 | $crate::commands::set_update_check_on_launch, | |
| 324 | + | $crate::commands::set_require_biometric_unlock, | |
| 324 | 325 | ] | |
| 325 | 326 | }; | |
| 326 | 327 | } | |
| @@ -344,6 +345,16 @@ | |||
| 344 | 345 | .plugin(tauri_plugin_window_state::Builder::new().build()); | |
| 345 | 346 | } | |
| 346 | 347 | ||
| 348 | + | // Mobile-only plugins. Haptics carries gesture confirmation the desktop | |
| 349 | + | // build answers with hover and focus states instead; biometric backs the | |
| 350 | + | // app lock, which is offered only where the OS has an enrolled biometry. | |
| 351 | + | #[cfg(any(target_os = "ios", target_os = "android"))] | |
| 352 | + | { | |
| 353 | + | builder = builder | |
| 354 | + | .plugin(tauri_plugin_haptics::init()) | |
| 355 | + | .plugin(tauri_plugin_biometric::init()); | |
| 356 | + | } | |
| 357 | + | ||
| 347 | 358 | builder | |
| 348 | 359 | .plugin(tauri_plugin_dialog::init()) | |
| 349 | 360 | .setup(|app| { |
| @@ -1095,8 +1095,7 @@ | |||
| 1095 | 1095 | border-bottom: none; | |
| 1096 | 1096 | } | |
| 1097 | 1097 | ||
| 1098 | - | .data-table tbody tr.selected, | |
| 1099 | - | .data-table tbody tr.keyboard-selected { | |
| 1098 | + | .data-table tbody tr.selected { | |
| 1100 | 1099 | background-color: color-mix(in srgb, var(--action) 25%, var(--surface-raised)); | |
| 1101 | 1100 | } | |
| 1102 | 1101 | ||
| @@ -1165,8 +1164,7 @@ | |||
| 1165 | 1164 | border-bottom: none; | |
| 1166 | 1165 | } | |
| 1167 | 1166 | ||
| 1168 | - | .task-row.selected, | |
| 1169 | - | .task-row.keyboard-selected { | |
| 1167 | + | .task-row.selected { | |
| 1170 | 1168 | background-color: color-mix(in srgb, var(--action) 25%, var(--surface-raised)); | |
| 1171 | 1169 | } | |
| 1172 | 1170 | ||
| @@ -2581,32 +2579,6 @@ | |||
| 2581 | 2579 | margin-bottom: var(--gap-section); | |
| 2582 | 2580 | } | |
| 2583 | 2581 | ||
| 2584 | - | /* Pending-key hint (top-level "go to" prompt) */ | |
| 2585 | - | .pending-key-hint { | |
| 2586 | - | position: fixed; | |
| 2587 | - | bottom: var(--gap-pane); | |
| 2588 | - | left: 50%; | |
| 2589 | - | transform: translateX(-50%); | |
| 2590 | - | padding: var(--gap-peer) var(--gap-section); | |
| 2591 | - | background: var(--surface-raised); | |
| 2592 | - | color: var(--content); | |
| 2593 | - | border: var(--border-width) solid var(--border); | |
| 2594 | - | border-radius: var(--radius-md); | |
| 2595 | - | box-shadow: var(--bevel-raised), var(--shadow-brutal-md); | |
| 2596 | - | z-index: 10000; | |
| 2597 | - | font-size: var(--font-size-sm); | |
| 2598 | - | white-space: nowrap; | |
| 2599 | - | } | |
| 2600 | - | .pending-key-hint-label { opacity: 0.7; } | |
| 2601 | - | .pending-key-hint kbd { | |
| 2602 | - | background: var(--surface-overlay); | |
| 2603 | - | border: var(--border-width) solid var(--border); | |
| 2604 | - | border-radius: var(--radius-xs); | |
| 2605 | - | padding: var(--step-hair) var(--step-snug); | |
| 2606 | - | font-family: inherit; | |
| 2607 | - | font-size: var(--font-size-sm); | |
| 2608 | - | } | |
| 2609 | - | ||
| 2610 | 2582 | /* Keyboard shortcuts overlay */ | |
| 2611 | 2583 | .shortcuts-overlay { | |
| 2612 | 2584 | position: fixed; | |
| @@ -9688,3 +9660,65 @@ | |||
| 9688 | 9660 | } | |
| 9689 | 9661 | } | |
| 9690 | 9662 | } | |
| 9663 | + | ||
| 9664 | + | @layer components { | |
| 9665 | + | /* 70. Biometric App Lock */ | |
| 9666 | + | ||
| 9667 | + | /* Hidden unless <html> carries .app-locked, which the pre-paint bootstrap sets | |
| 9668 | + | from the localStorage mirror and js/app-lock.js corrects once it has read the | |
| 9669 | + | real preference. `display: none` rather than an opacity fade: this covers | |
| 9670 | + | content the user asked not to be visible, and a transition is a window into | |
| 9671 | + | it. */ | |
| 9672 | + | .app-lock { | |
| 9673 | + | display: none; | |
| 9674 | + | } | |
| 9675 | + | ||
| 9676 | + | .app-locked .app-lock { | |
| 9677 | + | position: fixed; | |
| 9678 | + | inset: 0; | |
| 9679 | + | /* Above every other layer in the app, including the action sheet at 10001 | |
| 9680 | + | and the command palette at 5000. Nothing is allowed on top of this. */ | |
| 9681 | + | z-index: 100000; | |
| 9682 | + | display: flex; | |
| 9683 | + | align-items: center; | |
| 9684 | + | justify-content: center; | |
| 9685 | + | /* Opaque, not the translucent --overlay the modals use. A blurred or dimmed | |
| 9686 | + | task list is still a readable task list at arm's length. */ | |
| 9687 | + | background-color: var(--surface); | |
| 9688 | + | padding: var(--gap-section); | |
| 9689 | + | } | |
| 9690 | + | ||
| 9691 | + | /* The content behind is covered, but a screen reader walks the DOM rather than | |
| 9692 | + | the screen. Hiding the rest from the accessibility tree is what makes the | |
| 9693 | + | dialog modal in fact and not only in ARIA. */ | |
| 9694 | + | .app-locked body > *:not(.app-lock) { | |
| 9695 | + | visibility: hidden; | |
| 9696 | + | } | |
| 9697 | + | ||
| 9698 | + | .app-lock-panel { | |
| 9699 | + | display: flex; | |
| 9700 | + | flex-direction: column; | |
| 9701 | + | align-items: center; | |
| 9702 | + | gap: var(--gap-bound); | |
| 9703 | + | text-align: center; | |
| 9704 | + | max-width: 32ch; | |
| 9705 | + | } | |
| 9706 | + | ||
| 9707 | + | .app-lock-title { | |
| 9708 | + | font-size: var(--font-size-lg); | |
| 9709 | + | margin: 0; | |
| 9710 | + | } | |
| 9711 | + | ||
| 9712 | + | .app-lock-desc { | |
| 9713 | + | color: var(--content-muted); | |
| 9714 | + | margin: 0; | |
| 9715 | + | } | |
| 9716 | + | ||
| 9717 | + | /* Empty until a prompt fails, and it holds the row's height so the button does | |
| 9718 | + | not jump when the message appears. */ | |
| 9719 | + | .app-lock-status { | |
| 9720 | + | color: var(--danger); | |
| 9721 | + | margin: 0; | |
| 9722 | + | min-height: 1.5em; | |
| 9723 | + | } | |
| 9724 | + | } |
| @@ -376,6 +376,26 @@ | |||
| 376 | 376 | preferences: { | |
| 377 | 377 | get: () => invoke('get_preferences'), | |
| 378 | 378 | setUpdateCheckOnLaunch: (enabled) => invoke('set_update_check_on_launch', { enabled }), | |
| 379 | + | setRequireBiometricUnlock: (enabled) => invoke('set_require_biometric_unlock', { enabled }), | |
| 380 | + | }, | |
| 381 | + | ||
| 382 | + | // Haptics: tauri-plugin-haptics, mobile only. Callers go through | |
| 383 | + | // GoingsOn.haptics rather than here -- these are the raw commands, and the | |
| 384 | + | // module above them owns which feedback a given moment gets and the | |
| 385 | + | // swallow-and-disable policy. Every one of them rejects on desktop, where | |
| 386 | + | // the plugin is not built into the binary at all. | |
| 387 | + | haptics: { | |
| 388 | + | impact: (style) => invoke('plugin:haptics|impact_feedback', { style }), // light | medium | heavy | soft | rigid | |
| 389 | + | notification: (type) => invoke('plugin:haptics|notification_feedback', { type }), // success | warning | error | |
| 390 | + | selection: () => invoke('plugin:haptics|selection_feedback'), | |
| 391 | + | }, | |
| 392 | + | ||
| 393 | + | // Biometric: tauri-plugin-biometric, mobile only. `status` is the offer | |
| 394 | + | // test (is there enrolled biometry on this device); `authenticate` rejects | |
| 395 | + | // on failure or cancellation rather than resolving false. | |
| 396 | + | biometric: { | |
| 397 | + | status: () => invoke('plugin:biometric|status'), | |
| 398 | + | authenticate: (reason, options) => invoke('plugin:biometric|authenticate', { reason, ...(options || {}) }), | |
| 379 | 399 | }, | |
| 380 | 400 | ||
| 381 | 401 | // Import: native CSV/TSV import (entity type auto-detected from headers) |
| @@ -31,6 +31,7 @@ | |||
| 31 | 31 | let isDragging = false; | |
| 32 | 32 | let isHorizontal = null; | |
| 33 | 33 | let actions = null; | |
| 34 | + | let pastThreshold = false; | |
| 34 | 35 | ||
| 35 | 36 | // Phase 7 Tier 3 #11, peek-label affordance. As the user drags past | |
| 36 | 37 | // the threshold, surface the action that will fire ("Complete" / | |
| @@ -55,9 +56,17 @@ | |||
| 55 | 56 | const ratio = Math.min(1, Math.abs(currentX) / threshold); | |
| 56 | 57 | const peek = row.querySelector(currentX >= 0 ? '.swipe-peek--right' : '.swipe-peek--left'); | |
| 57 | 58 | const otherPeek = row.querySelector(currentX >= 0 ? '.swipe-peek--left' : '.swipe-peek--right'); | |
| 59 | + | const nowPast = Math.abs(currentX) >= threshold; | |
| 60 | + | // Paired with the peek label going ready, so the buzz and the word | |
| 61 | + | // arrive together rather than one describing a state the other has | |
| 62 | + | // already left. | |
| 63 | + | if (nowPast !== pastThreshold) { | |
| 64 | + | pastThreshold = nowPast; | |
| 65 | + | GoingsOn.haptics.select(); | |
| 66 | + | } | |
| 58 | 67 | if (peek) { | |
| 59 | 68 | peek.style.opacity = String(ratio); | |
| 60 | - | peek.classList.toggle('swipe-peek--ready', Math.abs(currentX) >= threshold); | |
| 69 | + | peek.classList.toggle('swipe-peek--ready', nowPast); | |
| 61 | 70 | } | |
| 62 | 71 | if (otherPeek) { | |
| 63 | 72 | otherPeek.style.opacity = '0'; | |
| @@ -82,6 +91,7 @@ | |||
| 82 | 91 | currentX = 0; | |
| 83 | 92 | isDragging = true; | |
| 84 | 93 | isHorizontal = null; | |
| 94 | + | pastThreshold = false; | |
| 85 | 95 | activeRow.style.transition = 'none'; | |
| 86 | 96 | attachPeekLabels(activeRow, actions); | |
| 87 | 97 | }, { passive: true }); | |
| @@ -127,8 +137,10 @@ | |||
| 127 | 137 | ||
| 128 | 138 | if (Math.abs(currentX) >= threshold) { | |
| 129 | 139 | if (currentX < 0 && actions?.left?.action) { | |
| 140 | + | GoingsOn.haptics.commit(); | |
| 130 | 141 | actions.left.action(); | |
| 131 | 142 | } else if (currentX > 0 && actions?.right?.action) { | |
| 143 | + | GoingsOn.haptics.commit(); | |
| 132 | 144 | actions.right.action(); | |
| 133 | 145 | } | |
| 134 | 146 | } | |
| @@ -139,6 +151,7 @@ | |||
| 139 | 151 | actions = null; | |
| 140 | 152 | currentX = 0; | |
| 141 | 153 | isHorizontal = null; | |
| 154 | + | pastThreshold = false; | |
| 142 | 155 | } | |
| 143 | 156 | ||
| 144 | 157 | container.addEventListener('touchend', onEnd, { passive: true }); | |
| @@ -177,6 +190,7 @@ | |||
| 177 | 190 | ev.preventDefault(); | |
| 178 | 191 | ev.stopPropagation(); | |
| 179 | 192 | }, { once: true, capture: true }); | |
| 193 | + | GoingsOn.haptics.commit(); | |
| 180 | 194 | onLongPress(activeRow); | |
| 181 | 195 | }, DURATION); | |
| 182 | 196 | }, { passive: true }); |
| @@ -329,6 +329,22 @@ | |||
| 329 | 329 | } | |
| 330 | 330 | } | |
| 331 | 331 | ||
| 332 | + | /** | |
| 333 | + | * Enabling does not lock on the spot. The user is holding the device they | |
| 334 | + | * just unlocked to reach Settings, so prompting them again proves nothing; | |
| 335 | + | * the setting takes effect at the next launch or the next return from the | |
| 336 | + | * background, which is where it matters. | |
| 337 | + | */ | |
| 338 | + | async function setRequireBiometricUnlock(enabled) { | |
| 339 | + | try { | |
| 340 | + | await GoingsOn.api.preferences.setRequireBiometricUnlock(enabled); | |
| 341 | + | GoingsOn.appLock.setMirror(enabled); | |
| 342 | + | GoingsOn.ui.showToast(enabled ? 'App lock enabled' : 'App lock disabled'); | |
| 343 | + | } catch (err) { | |
| 344 | + | GoingsOn.ui.showToast('Failed to save preference: ' + GoingsOn.utils.getErrorMessage(err), 'error'); | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 332 | 348 | async function renderAbout(container) { | |
| 333 | 349 | let appVersion = 'unknown'; | |
| 334 | 350 | try { appVersion = await window.__TAURI__.app.getVersion(); } catch (_) {} | |
| @@ -336,11 +352,30 @@ | |||
| 336 | 352 | const platform = (navigator.userAgentData?.platform || navigator.platform || 'unknown'); | |
| 337 | 353 | ||
| 338 | 354 | let updateCheckOnLaunch = true; | |
| 355 | + | let requireBiometricUnlock = false; | |
| 339 | 356 | try { | |
| 340 | 357 | const prefs = await GoingsOn.api.preferences.get(); | |
| 341 | 358 | updateCheckOnLaunch = prefs.updateCheckOnLaunch !== false; | |
| 359 | + | requireBiometricUnlock = prefs.requireBiometricUnlock === true; | |
| 342 | 360 | } catch (_) {} | |
| 343 | 361 | ||
| 362 | + | // The row exists only where the OS can answer the prompt: mobile, with | |
| 363 | + | // biometry enrolled. Offering a toggle that cannot work is worse than | |
| 364 | + | // not offering one, because the user turns it on and nothing happens. | |
| 365 | + | const biometricAvailable = await GoingsOn.appLock.available(); | |
| 366 | + | const appLockRow = biometricAvailable ? ` | |
| 367 | + | <div class="settings-toggle-row"> | |
| 368 | + | <div> | |
| 369 | + | <p class="settings-subheading">Require unlock</p> | |
| 370 | + | <p class="settings-desc">Hide the app behind Face ID, Touch ID or fingerprint on launch and when returning from the background. Your data is not encrypted by this.</p> | |
| 371 | + | </div> | |
| 372 | + | <label class="toggle-switch"> | |
| 373 | + | <input type="checkbox" ${requireBiometricUnlock ? 'checked' : ''} | |
| 374 | + | data-change="settings.setRequireBiometricUnlock" data-a1="@checked"> | |
| 375 | + | <span class="toggle-slider"></span> | |
| 376 | + | </label> | |
| 377 | + | </div>` : ''; | |
| 378 | + | ||
| 344 | 379 | // No physical keyboard, no reason to offer the shortcuts sheet. | |
| 345 | 380 | const shortcutsButton = GoingsOn.touch?.isTouchDevice | |
| 346 | 381 | ? '' | |
| @@ -372,6 +407,7 @@ | |||
| 372 | 407 | <span class="toggle-slider"></span> | |
| 373 | 408 | </label> | |
| 374 | 409 | </div> | |
| 410 | + | ${appLockRow} | |
| 375 | 411 | ||
| 376 | 412 | <div class="settings-actions-row"> | |
| 377 | 413 | <button class="button button--secondary" data-act="settings.openGettingStarted">Getting Started</button> | |
| @@ -435,6 +471,7 @@ | |||
| 435 | 471 | onEventLeadTimeChange, | |
| 436 | 472 | onWorkHoursChange, | |
| 437 | 473 | setUpdateCheckOnLaunch, | |
| 474 | + | setRequireBiometricUnlock, | |
| 438 | 475 | }; | |
| 439 | 476 | ||
| 440 | 477 | })(); |
| @@ -38,6 +38,10 @@ | |||
| 38 | 38 | timer = null; | |
| 39 | 39 | // Prevent the subsequent click/tap | |
| 40 | 40 | element.addEventListener('click', preventClick, { once: true, capture: true }); | |
| 41 | + | // Before the callback, not after: a long press has no visual | |
| 42 | + | // build-up, so the buzz is the only thing telling the user the | |
| 43 | + | // hold was long enough, and it should not wait on the work. | |
| 44 | + | GoingsOn.haptics.commit(); | |
| 41 | 45 | callback(e); | |
| 42 | 46 | }, duration); | |
| 43 | 47 | } | |
| @@ -100,6 +104,9 @@ | |||
| 100 | 104 | let currentX = 0; | |
| 101 | 105 | let isDragging = false; | |
| 102 | 106 | let isHorizontal = null; | |
| 107 | + | // Edge-triggered, so dragging back and forth across the threshold ticks | |
| 108 | + | // on each crossing rather than on every touchmove past it. | |
| 109 | + | let pastThreshold = false; | |
| 103 | 110 | ||
| 104 | 111 | function onTouchStart(e) { | |
| 105 | 112 | const touch = e.touches[0]; | |
| @@ -108,6 +115,7 @@ | |||
| 108 | 115 | currentX = 0; | |
| 109 | 116 | isDragging = true; | |
| 110 | 117 | isHorizontal = null; | |
| 118 | + | pastThreshold = false; | |
| 111 | 119 | element.style.transition = 'none'; | |
| 112 | 120 | } | |
| 113 | 121 | ||
| @@ -143,6 +151,12 @@ | |||
| 143 | 151 | currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe); | |
| 144 | 152 | } | |
| 145 | 153 | ||
| 154 | + | const nowPast = Math.abs(currentX) >= threshold; | |
| 155 | + | if (nowPast !== pastThreshold) { | |
| 156 | + | pastThreshold = nowPast; | |
| 157 | + | GoingsOn.haptics.select(); | |
| 158 | + | } | |
| 159 | + | ||
| 146 | 160 | element.style.transform = `translateX(${currentX}px)`; | |
| 147 | 161 | } | |
| 148 | 162 | ||
| @@ -153,8 +167,10 @@ | |||
| 153 | 167 | if (Math.abs(currentX) >= threshold) { | |
| 154 | 168 | // Fire action | |
| 155 | 169 | if (currentX < 0 && config.left?.action) { | |
| 170 | + | GoingsOn.haptics.commit(); | |
| 156 | 171 | config.left.action(); | |
| 157 | 172 | } else if (currentX > 0 && config.right?.action) { | |
| 173 | + | GoingsOn.haptics.commit(); | |
| 158 | 174 | config.right.action(); | |
| 159 | 175 | } | |
| 160 | 176 | } | |
| @@ -163,6 +179,7 @@ | |||
| 163 | 179 | element.style.transform = 'translateX(0)'; | |
| 164 | 180 | currentX = 0; | |
| 165 | 181 | isHorizontal = null; | |
| 182 | + | pastThreshold = false; | |
| 166 | 183 | } | |
| 167 | 184 | ||
| 168 | 185 | element.addEventListener('touchstart', onTouchStart, { passive: true }); | |
| @@ -193,6 +210,9 @@ | |||
| 193 | 210 | let startY = 0; | |
| 194 | 211 | let pulling = false; | |
| 195 | 212 | let indicator = null; | |
| 213 | + | // Same edge-trigger as the swipe threshold: the tick marks entering the | |
| 214 | + | // release zone, not being in it. | |
| 215 | + | let pastThreshold = false; | |
| 196 | 216 | ||
| 197 | 217 | function getOrCreateIndicator() { | |
| 198 | 218 | if (!indicator) { | |
| @@ -208,6 +228,7 @@ | |||
| 208 | 228 | if (container.scrollTop > 0) return; | |
| 209 | 229 | startY = e.touches[0].clientY; | |
| 210 | 230 | pulling = true; | |
| 231 | + | pastThreshold = false; | |
| 211 | 232 | } | |
| 212 | 233 | ||
| 213 | 234 | function onTouchMove(e) { | |
| @@ -228,11 +249,18 @@ | |||
| 228 | 249 | ind.textContent = 'Pull to refresh...'; | |
| 229 | 250 | ind.classList.add('visible'); | |
| 230 | 251 | } | |
| 252 | + | ||
| 253 | + | const nowPast = dy > pullThreshold; | |
| 254 | + | if (nowPast !== pastThreshold) { | |
| 255 | + | pastThreshold = nowPast; | |
| 256 | + | GoingsOn.haptics.select(); | |
| 257 | + | } | |
| 231 | 258 | } | |
| 232 | 259 | ||
| 233 | 260 | async function onTouchEnd(e) { | |
| 234 | 261 | if (!pulling) return; | |
| 235 | 262 | pulling = false; | |
| 263 | + | pastThreshold = false; | |
| 236 | 264 | ||
| 237 | 265 | const dy = e.changedTouches[0].clientY - startY; | |
| 238 | 266 | if (indicator) { | |
| @@ -244,8 +272,16 @@ | |||
| 244 | 272 | indicator.textContent = 'Refreshing...'; | |
| 245 | 273 | indicator.classList.add('visible'); | |
| 246 | 274 | } | |
| 275 | + | GoingsOn.haptics.commit(); | |
| 247 | 276 | try { | |
| 248 | 277 | await callback(); | |
| 278 | + | // The one place an outcome haptic earns its keep: a refresh | |
| 279 | + | // that finds nothing new leaves the list looking untouched, | |
| 280 | + | // so without this the gesture has no completion at all. | |
| 281 | + | GoingsOn.haptics.success(); | |
| 282 | + | } catch (err) { | |
| 283 | + | GoingsOn.haptics.failure(); | |
| 284 | + | throw err; | |
| 249 | 285 | } finally { | |
| 250 | 286 | if (indicator) { | |
| 251 | 287 | indicator.classList.remove('visible'); | |
| @@ -351,6 +387,7 @@ | |||
| 351 | 387 | ||
| 352 | 388 | if (currentY >= threshold) { | |
| 353 | 389 | element.style.transform = `translateY(100%)`; | |
| 390 | + | GoingsOn.haptics.commit(); | |
| 354 | 391 | onDismiss(); | |
| 355 | 392 | } else { | |
| 356 | 393 | element.style.transform = 'translateY(0)'; |
| @@ -1,1 +1,1 @@ | |||
| 1 | - | {"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main","compose-*"],"permissions":["core:default","core:window:allow-create","core:window:allow-close","core:window:allow-center","dialog:default"]},"desktop":{"identifier":"desktop","description":"Desktop-only capabilities (notifications, OTA updater). External URLs are opened via the validated open_external_url command, not a JS opener binding.","local":true,"windows":["main","compose-*"],"permissions":["notification:default","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}} | |
| 1 | + | {"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main","compose-*"],"permissions":["core:default","core:window:allow-create","core:window:allow-close","core:window:allow-center","dialog:default"]},"desktop":{"identifier":"desktop","description":"Desktop-only capabilities (notifications, OTA updater). External URLs are opened via the validated open_external_url command, not a JS opener binding.","local":true,"windows":["main","compose-*"],"permissions":["notification:default","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]},"mobile":{"identifier":"mobile","description":"Mobile-only capabilities (haptic feedback, biometric app lock). Granted command by command rather than through the plugins' default sets: vibrate is not granted because nothing calls raw vibration, and a grant nothing uses is a grant nobody notices going wrong.","local":true,"windows":["main"],"permissions":["haptics:allow-impact-feedback","haptics:allow-notification-feedback","haptics:allow-selection-feedback","biometric:allow-status","biometric:allow-authenticate"],"platforms":["iOS","android"]}} |