diff --git a/.devcontainer/on-start.sh b/.devcontainer/on-start.sh
index c3e443e0e..9bdf54a7b 100755
--- a/.devcontainer/on-start.sh
+++ b/.devcontainer/on-start.sh
@@ -9,3 +9,6 @@ sudo /usr/local/bin/init-firewall.sh
# Configure git to use gh for authentication (if logged in)
gh auth setup-git 2>/dev/null || true
+
+# Install lefthook git hooks
+lefthook install
diff --git a/TODO-flipper-dumps.md b/TODO-flipper-dumps.md
deleted file mode 100644
index c1f47442b..000000000
--- a/TODO-flipper-dumps.md
+++ /dev/null
@@ -1,282 +0,0 @@
-# Flipper Dump TODO
-
-Card dumps serve two purposes:
-1. **Integration tests** — verify Metrodroid port correctness (full pipeline: dump → RawCard → Card → TransitInfo)
-2. **Sample cards in Explore tab** — tapping a card in the Explore tab loads a dump and shows parsed transit info
-
-Dumps live in `farebot-app/src/commonTest/resources/` (test) and will also be embedded as app resources for Explore tab samples.
-
----
-
-## Already have dumps with integration tests
-
-### Samples + Tests (`farebot-app/src/commonTest/resources/` and `composeResources/files/samples/`)
-
-All cards below have both Explore screen samples and `SampleDumpIntegrationTest` coverage.
-
-**Data source key:**
-- **Real** = from an actual card scan (Flipper, Metrodroid export, or MFC binary)
-- **Synthetic** = hand-constructed from unit test data or code constants
-- **Needs scan** = a real Flipper/phone scan would improve the sample (more realistic data, actual trips/balances)
-
-| Card | Type | Format | Data source | Needs scan? | Test assertions |
-|------|------|--------|-------------|-------------|----------------|
-| Clipper | DESFire | Flipper | Real | No | 16 trips, $2.25 balance |
-| ORCA | DESFire | Flipper | Real | No | 0 trips, $26.25 balance |
-| Suica | FeliCa | Flipper | Real | No | 20 trips, 870 JPY balance |
-| PASMO | FeliCa | Flipper | Real | No | 11 trips, 500 JPY balance |
-| ICOCA | FeliCa | Flipper | Real | No | 20 trips, 827 JPY balance |
-| Opal | DESFire | Metrodroid JSON | Real | No | -$1.82 AUD, serial |
-| HSL v2 | DESFire | Metrodroid JSON | Real | No | €0.40, 2 trips, 2 subs |
-| HSL UL | Ultralight | Metrodroid JSON | Real | No | 1 trip, 1 subscription |
-| Troika UL | Ultralight | Metrodroid JSON | Real | No | trips + subscriptions |
-| T-Money | ISO7816 | Metrodroid JSON | Real | No | 17,650 KRW, 5 trips |
-| EZ-Link | CEPAS | Metrodroid JSON | Real | No | $8.97 SGD, trips |
-| Holo | DESFire | Metrodroid JSON | Real | No | serial-only |
-| Mobib | ISO7816 | Metrodroid JSON | Real | No | blank card, 0 trips |
-| Ventra | Ultralight | Metrodroid JSON | Real | No | $8.44, 2 trips |
-| EasyCard | Classic | Raw MFC | Real | No | 245 TWD, 3 trips |
-| Compass | Ultralight | Metrodroid JSON | Synthetic | **Yes** — Flipper UL scan | serial, trips |
-| SEQ Go | Classic | Metrodroid JSON | Synthetic | **Yes** — Flipper Classic scan (needs keys) | serial, AUD balance |
-| LAX TAP | Classic | Metrodroid JSON | Synthetic | **Yes** — Flipper Classic scan (needs keys) | serial, USD balance |
-| MSP GoTo | Classic | Metrodroid JSON | Synthetic | **Yes** — Flipper Classic scan (needs keys) | serial, USD balance |
-| Myki | DESFire | Metrodroid JSON | Synthetic | **Yes** — Flipper DESFire scan | serial 308425123456780 |
-| Octopus | FeliCa | Metrodroid JSON | Synthetic | **Yes** — Flipper FeliCa scan | -HKD 14.40 balance |
-| TriMet Hop | DESFire | Metrodroid JSON | Synthetic | **Yes** — Flipper DESFire scan | serial-only, serial + issue date |
-| Bilhete Unico | Classic | Metrodroid JSON | Synthetic | **Yes** — needs proper scan (no trips, zero counters) | R$24.00 balance, no trips |
-
-**Real scans: 15** | **Synthetic (could use real scan): 8** | **Total: 23**
-
-### Metrodroid test assets (reference only — `metrodroid/src/commonTest/assets/`)
-
-| Card | Type | Path | Notes |
-|------|------|------|-------|
-| Selecta | Classic | `selecta/selecta.json` | Vending machine, not transit |
-
-The `metrodroid/src/commonTest/assets/farebot/` directory has format-test dumps (Opal, CEPAS, FeliCa, Classic, Ultralight, DESFire) for testing import compatibility.
-
-The `metrodroid/src/commonTest/assets/parsed/` directory has expected *parse results* (not raw dumps): Rejsekort, Bilhete Unico, EasyCard, HSL v2, HSL UL, Opal, Troika UL, T-Money, CEPAS, Mobib, Holo, Selecta.
-
----
-
-## Dumps available on GitHub (not yet downloaded)
-
-These dumps were found in Metrodroid/FareBot issue trackers and can be downloaded.
-
-### High priority — complete Metrodroid JSON dumps, directly downloadable
-
-| Card | Type | Source | Files | Notes |
-|------|------|--------|-------|-------|
-| **Venezia Unica UL** | Ultralight | [metrodroid PR#869](https://github.com/metrodroid/metrodroid/pull/869) | 12 JSON files (4 cards × 3 reads) | Before/after transaction snapshots. UID pattern `05xxxxxxxx64e9`. |
-| **Andante Blue** | Ultralight | [metrodroid#887](https://github.com/metrodroid/metrodroid/issues/887) | 4 JSON files (4 different cards) | Porto, Portugal. 20-page MFU. New system — not yet in FareBot. |
-| **Riga E-talons** | Calypso/ISO7816 | [metrodroid#896](https://github.com/metrodroid/metrodroid/issues/896) | 2 JSON files (active + expired) | Latvia. Period tickets and 90-min tickets. New system — not yet in FareBot. |
-| **Mexico City Movilidad Integrada** | Calypso/ISO7816 | [metrodroid#707](https://github.com/metrodroid/metrodroid/issues/707) | ZIP with 3 JSON files | Calypso, country code 0x484. New system — not yet in FareBot. |
-
-### Medium priority — partial data or non-standard format
-
-| Card | Type | Source | Data | Notes |
-|------|------|--------|------|-------|
-| **Zaragoza Tarjeta Bus** | Classic | [metrodroid#756](https://github.com/metrodroid/metrodroid/issues/756) | Google Drive link with MCT dumps | Spain. 16-sector MFC, static keys, before/after each trip. New system — not yet in FareBot. External link may be dead. |
-| **Pittsburgh ConnecTix** | Ultralight | [farebot#64](https://github.com/codebutler/farebot/issues/64) | Inline hex (16 pages) | Ten Trip ticket, 2 admissions remaining. 2013 data. New system — not in FareBot. |
-
-### Low priority — insufficient data or serial-only
-
-| Card | Type | Source | Notes |
-|------|------|--------|-------|
-| NY/NJ PATH SmartLink | DESFire | [farebot#63](https://github.com/codebutler/farebot/issues/63) | Card fully locked, no readable data. |
-| E-Go Luxembourg | ISO7816 (VDV) | [farebot#72](https://github.com/codebutler/farebot/issues/72) | Only scan metadata, no file contents. |
-
-### Dumps offered privately (not publicly downloadable)
-
-| Card | Type | Source | Notes |
-|------|------|--------|-------|
-| Tehran Ezpay | Classic | [metrodroid#660](https://github.com/metrodroid/metrodroid/issues/660) | Full 16-sector, static keys. Sent to devs privately. |
-| GoExplore (Gold Coast) | Classic | [metrodroid#813](https://github.com/metrodroid/metrodroid/issues/813) | Sent privately. |
-| OPUS Quebec disposable | Ultralight | [metrodroid#754](https://github.com/metrodroid/metrodroid/issues/754) | Sent privately. |
-| CharlieCard | Classic | [farebot#68](https://github.com/codebutler/farebot/issues/68) | Some data emailed privately. |
-| KoriGo / Bibus (Brittany) | Calypso | [metrodroid#837](https://github.com/metrodroid/metrodroid/issues/837) | Offered but not posted. |
-
----
-
-## Dumps still needed — full implementations
-
-Cards with actual trip/balance/subscription parsing. These are the most valuable to get dump data for.
-
-### DESFire (Flipper can read directly)
-
-| Card | Module | Priority | Needs scan? | Notes |
-|------|--------|----------|-------------|-------|
-| **HSL v1** | `farebot-transit-hsl` | High | Yes — Flipper | Helsinki, old format APP_ID 0x1120ef. Full rewrite, no test coverage. |
-| **Waltti** | `farebot-transit-hsl` | High | Yes — Flipper | Oulu/Lahti/etc, APP_ID 0x10ab. Shares HSL module. |
-| **Tampere** | `farebot-transit-tampere` | High | Yes — Flipper | Shares HSL-family code. |
-| **Leap** | `farebot-transit-tfi-leap` | Medium | Yes — Flipper | Dublin, Ireland. EN1545-based. |
-| **Adelaide Metrocard** | `farebot-transit-adelaide` | Medium | Yes — Flipper | Adelaide, Australia. |
-| **Hafilat** | `farebot-transit-hafilat` | Low | Yes — Flipper | Abu Dhabi. |
-
-### Mifare Classic — no keys needed (Flipper can read directly)
-
-These Classic cards don't use encrypted sectors for their transit data, so Flipper can read them like any other card.
-
-| Card | Module | Needs scan? | Notes |
-|------|--------|-------------|-------|
-| **Bip** | `farebot-transit-bip` | Yes — Flipper | Santiago, Chile. |
-| **Bonobus** | `farebot-transit-bonobus` | Yes — Flipper | Cadiz, Spain. |
-| **Ricaricami** | `farebot-transit-ricaricami` | Yes — Flipper | Milan, Italy. |
-| **Metromoney** | `farebot-transit-metromoney` | Yes — Flipper | Tbilisi, Georgia. |
-| **Kyiv Metro** | `farebot-transit-kiev` | Yes — Flipper | Kyiv, Ukraine. |
-| **Kyiv Digital** | `farebot-transit-kiev` | Yes — Flipper | Kyiv, Ukraine. Variant. |
-| **Metro Q** | `farebot-transit-metroq` | Yes — Flipper | Qatar. |
-| **Gautrain** | `farebot-transit-gautrain` | Yes — Flipper | Gauteng, South Africa. |
-| **Touch n Go** | `farebot-transit-touchngo` | Yes — Flipper | Malaysia. |
-| **KomuterLink** | `farebot-transit-komuterlink` | Yes — Flipper | Malaysia. |
-| **SmartRider** | `farebot-transit-smartrider` | Yes — Flipper | Perth, Australia. |
-| **Otago GoCard** | `farebot-transit-otago` | Yes — Flipper | Otago, NZ. |
-| **Tartu Bus** | `farebot-transit-pilet` | Yes — Flipper | Tartu, Estonia. |
-| **YarGor** | `farebot-transit-yargor` | Yes — Flipper | Yaroslavl, Russia. |
-
-### Mifare Classic — keys required (Flipper needs key dictionary)
-
-These cards encrypt their transit sectors. Flipper can crack some keys with `mfkey32` or use a known dictionary, but it's more effort.
-
-**Note on Charlie Card:** `check()` uses salted MD5 key hashes. Our JSON/Flipper parsers don't currently extract keys from the trailer block, so `DataClassicSector.keyA`/`keyB` are always null. Fix: either add key extraction to `RawClassicSector.parse()` (reads bytes 0-5 and 10-15 from trailer block), or add explicit key fields to the JSON format. Once fixed, a Flipper scan with MBTA keys (publicly documented) would work.
-
-| Card | Module | Needs scan? | Notes |
-|------|--------|-------------|-------|
-| **OV-chipkaart** | `farebot-transit-ovc` | Yes — Flipper + keys | Full EN1545 rewrite, trip dedup, subscriptions, autocharge. 4K card. |
-| **Oyster** | `farebot-transit-oyster` | Yes — Flipper + keys | London. Complex trip parsing. |
-| **Charlie Card** | `farebot-transit-charlie` | Yes — Flipper + keys + key extraction fix | Boston. MBTA keys are public. See note above. |
-| **Podorozhnik** | `farebot-transit-podorozhnik` | Yes — Flipper + keys | Saint Petersburg. |
-| **Manly Fast Ferry** | `farebot-transit-manly` | Yes — Flipper + keys | Sydney, Australia. |
-| **Warsaw** | `farebot-transit-warsaw` | Yes — Flipper + keys | Warsaw, Poland. |
-| **Kazan** | `farebot-transit-kazan` | Yes — Flipper + keys | Kazan, Russia. |
-| **Christchurch Metrocard** | `farebot-transit-chc-metrocard` | Yes — Flipper + keys | Christchurch, NZ. |
-
-### FeliCa (Flipper can read directly)
-
-| Card | Module | Priority | Needs scan? | Notes |
-|------|--------|----------|-------------|-------|
-| **Edy** | `farebot-transit-edy` | Medium | Yes — Flipper | Japan e-money. |
-| **KMT** | `farebot-transit-kmt` | Medium | Yes — Flipper | Jakarta. FeliCa variant. |
-
-### Ultralight (Flipper can read directly)
-
-| Card | Module | Priority | Needs scan? | Notes |
-|------|--------|----------|-------------|-------|
-| **OV-chipkaart UL** | `farebot-transit-ovc` | High | Yes — Flipper | Dutch disposable. Part of OVC rewrite. |
-
-### ISO7816 / Calypso (Flipper CANNOT read — need Android NFC dump)
-
-Flipper Zero does not support ISO 14443-4 / ISO 7816 protocol reads. These require an Android phone running FareBot/Metrodroid to capture the dump, then export as JSON.
-
-| Card | Module | Priority | Needs scan? | Notes |
-|------|--------|----------|-------------|-------|
-| **Navigo** | `farebot-transit-calypso` | Medium | Yes — Android phone | Paris. EN1545/Intercode. |
-| **Opus** | `farebot-transit-calypso` | Medium | Yes — Android phone | Montreal. EN1545/Intercode. |
-| **RavKav** | `farebot-transit-calypso` | Medium | Yes — Android phone | Israel. EN1545. |
-| **Lisboa Viva** | `farebot-transit-calypso` | Medium | Yes — Android phone | Lisbon. EN1545. |
-| **Venezia Unica** | `farebot-transit-calypso` | Medium | Yes — Android phone | Venice. EN1545. Note: UL variant dumps available in metrodroid PR#869. |
-| **Snapper** | `farebot-transit-snapper` | Medium | Yes — Android phone | Wellington, NZ. KSX6924. |
-| **Beijing** | `farebot-transit-china` | Low | Yes — Android phone | China T-Union. |
-| **Shanghai** | `farebot-transit-china` | Low | Yes — Android phone | China T-Union. |
-| **Shenzhen Tong** | `farebot-transit-china` | Low | Yes — Android phone | China T-Union. |
-| **Wuhan Tong** | `farebot-transit-china` | Low | Yes — Android phone | China T-Union. |
-| **T-Union** | `farebot-transit-china` | Low | Yes — Android phone | China T-Union. |
-| **City Union** | `farebot-transit-china` | Low | Yes — Android phone | China T-Union. |
-
-### CEPAS (Flipper CANNOT read — need Android NFC dump)
-
-| Card | Module | Priority | Needs scan? | Notes |
-|------|--------|----------|-------------|-------|
-| **NETS FlashPay** | `farebot-transit-ezlink` | Medium | Yes — Android phone | Singapore. Shares EZ-Link module. |
-
----
-
-## Dumps still needed — serial-only and preview (low priority)
-
-These cards only show a card name and serial number (no trip/balance parsing), or are `preview = true` (keysRequired, not fully functional). A dump is nice for Explore screen completeness but doesn't exercise much parsing logic.
-
-### Serial-only (identification only)
-
-| Card | Type | Module | Needs scan? | Notes |
-|------|------|--------|-------------|-------|
-| Nol | DESFire | serialonly | Yes — Flipper | Dubai, UAE. |
-| Istanbul Kart | DESFire | serialonly | Yes — Flipper | Istanbul, Turkey. |
-| AT HOP | DESFire | serialonly | Yes — Flipper | Auckland, NZ. |
-| Presto | DESFire | serialonly | Yes — Flipper | Ontario, Canada. |
-| TPF | DESFire | serialonly | Yes — Flipper | Fribourg, Switzerland. |
-| Sun Card | Classic | serialonly | Yes — Flipper | Orlando, FL. |
-| Strelka | Classic | serialonly | Yes — Flipper | Moscow region. |
-
-### Preview cards (keysRequired + preview, not fully functional)
-
-| Card | Type | Module | Needs scan? | Notes |
-|------|------|--------|-------------|-------|
-| SLAccess | Classic | `farebot-transit-rkf` | Yes — Flipper + keys | Stockholm. |
-| Rejsekort | Classic | `farebot-transit-rkf` | Yes — Flipper + keys | Denmark. |
-| Vasttrafik | Classic | `farebot-transit-rkf` | Yes — Flipper + keys | Gothenburg. |
-| Umarsh variants (8) | Classic | `farebot-transit-umarsh` | Yes — Flipper + keys | Yoshkar-Ola, Strizh, Barnaul, Vladimir, Kirov, Siticard, Omka, Penza. |
-| Zolotaya Korona variants (5) | Classic | `farebot-transit-zolotayakorona` | Yes — Flipper + keys | Krasnodar, Orenburg, Samara, Yaroslavl. |
-| Ekarta | Classic | `farebot-transit-zolotayakorona` | Yes — Flipper + keys | Yekaterinburg. |
-| Crimea variants (2) | Classic | — | Yes — Flipper + keys | Trolleybus, Parus school. |
-| Pastel | ISO7816 | `farebot-transit-calypso` | Yes — Android phone | Toulouse. |
-| Pass Pass | ISO7816 | `farebot-transit-calypso` | Yes — Android phone | Hauts-de-France. |
-| TransGironde | ISO7816 | `farebot-transit-calypso` | Yes — Android phone | Gironde. |
-| BusIt | Classic | `farebot-transit-nextfare` | Yes — Flipper + keys | Waikato, NZ. |
-| SmartRide | Classic | `farebot-transit-nextfare` | Yes — Flipper + keys | Rotorua, NZ. |
-
-### Suica-compatible IC cards (same parser, different branding)
-
-These all use the Suica FeliCa parser — a scan just confirms detection, doesn't test new parsing logic.
-
-| Card | Needs scan? | Notes |
-|------|-------------|-------|
-| TOICA | Yes — Flipper | Nagoya. |
-| manaca | Yes — Flipper | Nagoya. |
-| PiTaPa | Yes — Flipper | Kansai. |
-| Kitaca | Yes — Flipper | Hokkaido. |
-| SUGOCA | Yes — Flipper | Fukuoka. |
-| nimoca | Yes — Flipper | Fukuoka. |
-| hayakaken | Yes — Flipper | Fukuoka City. |
-
-### Calypso/Intercode low-priority (full impl but less common)
-
-| Card | Needs scan? | Notes |
-|------|-------------|-------|
-| Oura | Yes — Android phone | Grenoble. |
-| TaM | Yes — Android phone | Montpellier. |
-| Korrigo | Yes — Android phone | Brittany. |
-| Envibus | Yes — Android phone | Sophia Antipolis. |
-| Carta Mobile | Yes — Android phone | Pisa. |
-
-
----
-
-## Summary
-
-| Category | Have (with tests) | Synthetic (need real scan) | On GitHub (not downloaded) | Need scan: no keys | Need scan: keys required | Need scan: serial/preview |
-|----------|------------------|---------------------------|---------------------------|--------------------|--------------------------|---------------------------|
-| DESFire | 8 | 2 (Myki, TriMet Hop) | 0 | 6 | — | 5 |
-| Classic (no keys) | 7 | 4 (SEQ Go, LAX TAP, MSP GoTo, Bilhete Unico) | 1 (Zaragoza) | 14 | — | 2 serial |
-| Classic (keys) | — | — | — | — | 8 | ~18 preview |
-| FeliCa | 4 | 1 (Octopus) | 0 | 2 | — | 7 Suica variants |
-| Ultralight | 4 | 1 (Compass) | 2 (Venezia UL, Andante) | 1 | — | 0 |
-| ISO7816 | 2 | 0 | 2 (Riga, Mexico City) | 12 | — | 8 |
-| CEPAS | 1 | 0 | 0 | 1 | — | 0 |
-| **Total** | **23** (15 real + 8 synthetic) | **8** | **5** | **36 easy** | **8 need keys** | **~40 low-pri** |
-
-† Synthetic dump — works for tests but a real Flipper/phone scan would provide more realistic data.
-\* Troika Classic has programmatic test data only (not a sample file).
-
-## Dump format notes
-
-- **Flipper `.nfc`** — Flipper Zero native format. Supported for DESFire, Classic, FeliCa, Ultralight.
-- **`.mfc`** — Raw Mifare Classic binary dump (1K = 1024 bytes, 4K = 4096 bytes).
-- **FareBot/Metrodroid JSON** — Exported from Android app. Required for ISO7816/CEPAS cards that Flipper can't read.
-- All formats are supported by `CardImporter` / `FlipperNfcParser`.
-
-## Known issues
-
-### Classic card key extraction
-Our JSON and Flipper parsers don't extract keyA/keyB from the MIFARE Classic trailer block when parsing. This means `DataClassicSector.keyA` and `keyB` are always null. Cards that use `checkKeyHash()` for detection (e.g., Charlie Card) won't work with dumps until this is fixed.
-
-**Fix:** In `RawClassicSector.parse()`, detect the trailer block (last block of the sector) and extract bytes 0-5 as keyA and bytes 10-15 as keyB, then pass to `DataClassicSector.create()`.
diff --git a/app/src/commonMain/composeResources/values/strings.xml b/app/src/commonMain/composeResources/values/strings.xml
index dd5df1a68..2b793630f 100644
--- a/app/src/commonMain/composeResources/values/strings.xml
+++ b/app/src/commonMain/composeResources/values/strings.xml
@@ -13,6 +13,7 @@
Sample card
Card Type
Copy
+ Copied to clipboard
Delete
Delete %1$d selected cards?
Delete %1$d selected keys?
@@ -33,6 +34,7 @@
Locked Card
Menu
%1$d selected
+ Select all
NFC
NFC is disabled
Ready to scan
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt
index f009f49c1..5825be317 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/App.kt
@@ -187,6 +187,7 @@ fun FareBotApp(
},
onToggleSelection = { itemId -> historyViewModel.toggleSelection(itemId) },
onClearSelection = { historyViewModel.clearSelection() },
+ onSelectAll = { historyViewModel.selectAll() },
onDeleteSelected = { historyViewModel.deleteSelected() },
supportedCards = supportedCards,
supportedCardTypes = supportedCardTypes,
@@ -294,6 +295,7 @@ fun FareBotApp(
onDeleteKey = { keyId -> viewModel.deleteKey(keyId) },
onToggleSelection = { keyId -> viewModel.toggleSelection(keyId) },
onClearSelection = { viewModel.clearSelection() },
+ onSelectAll = { viewModel.selectAll() },
onDeleteSelected = { viewModel.deleteSelected() },
)
}
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardScreen.kt
index a7ef48d54..7c257e6b7 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardScreen.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardScreen.kt
@@ -1,15 +1,23 @@
package com.codebutler.farebot.shared.ui.screen
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -19,10 +27,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -32,25 +40,40 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.codebutler.farebot.transit.Trip
import farebot.app.generated.resources.Res
import farebot.app.generated.resources.advanced
import farebot.app.generated.resources.back
-import farebot.app.generated.resources.balance
+import farebot.app.generated.resources.copied_to_clipboard
import farebot.app.generated.resources.delete
import farebot.app.generated.resources.ic_transaction_banned_32dp
import farebot.app.generated.resources.ic_transaction_bus_32dp
@@ -78,7 +101,7 @@ import farebot.app.generated.resources.trip_mode_toll_road
import farebot.app.generated.resources.trip_mode_train
import farebot.app.generated.resources.trip_mode_tram
import farebot.app.generated.resources.trip_mode_trolleybus
-import farebot.app.generated.resources.unknown_card
+import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@@ -96,117 +119,301 @@ fun CardScreen(
onShowScanHistory: () -> Unit = {},
onNavigateToScan: (String) -> Unit = {},
) {
- var menuExpanded by remember { mutableStateOf(false) }
+ when {
+ uiState.isLoading -> {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ uiState.error != null -> {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(Res.string.back),
+ )
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(padding),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = uiState.error,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ }
+ }
+ else -> {
+ CardContentScreen(
+ uiState = uiState,
+ onBack = onBack,
+ onNavigateToAdvanced = onNavigateToAdvanced,
+ onNavigateToTripMap = onNavigateToTripMap,
+ onExportShare = onExportShare,
+ onExportSave = onExportSave,
+ onDelete = onDelete,
+ onShowScanHistory = onShowScanHistory,
+ )
+ }
+ }
- Scaffold(
- topBar = {
- TopAppBar(
- title = {
- Column {
- Text(
- text = uiState.cardName ?: stringResource(Res.string.unknown_card),
- )
- if (uiState.serialNumber != null) {
+ // Scan history bottom sheet (overlays everything)
+ if (uiState.showScanHistory && uiState.scanHistory.isNotEmpty()) {
+ ModalBottomSheet(
+ onDismissRequest = onShowScanHistory,
+ sheetState = rememberModalBottomSheetState(),
+ ) {
+ Column(modifier = Modifier.padding(bottom = 24.dp)) {
+ Text(
+ text = stringResource(Res.string.scan_history),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ for (entry in uiState.scanHistory) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ if (!entry.isCurrent) {
+ onNavigateToScan(entry.savedCardId)
+ }
+ }.padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
Text(
- text = uiState.serialNumber,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- style = MaterialTheme.typography.bodySmall,
- fontFamily = FontFamily.Monospace,
+ text = entry.scannedDate,
+ style = MaterialTheme.typography.bodyMedium,
)
+ if (entry.scannedTime.isNotEmpty()) {
+ Text(
+ text = entry.scannedTime,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
+ RadioButton(
+ selected = entry.isCurrent,
+ onClick = {
+ if (!entry.isCurrent) {
+ onNavigateToScan(entry.savedCardId)
+ }
+ },
+ )
}
- },
- navigationIcon = {
- IconButton(onClick = onBack) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
- }
- },
- actions = {
- if (uiState.currentScanLabel != null) {
- Box(
- modifier =
- Modifier
- .padding(end = 4.dp)
- .background(
- color = MaterialTheme.colorScheme.secondaryContainer,
- shape = RoundedCornerShape(12.dp),
- ).clickable(onClick = onShowScanHistory)
- .padding(horizontal = 10.dp, vertical = 4.dp),
- ) {
- Text(
- text = uiState.currentScanLabel,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSecondaryContainer,
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+private fun CardContentScreen(
+ uiState: CardUiState,
+ onBack: () -> Unit,
+ onNavigateToAdvanced: () -> Unit,
+ onNavigateToTripMap: (String) -> Unit,
+ onExportShare: () -> Unit,
+ onExportSave: () -> Unit,
+ onDelete: (() -> Unit)?,
+ onShowScanHistory: () -> Unit,
+) {
+ var menuExpanded by remember { mutableStateOf(false) }
+
+ val brandBgColor =
+ remember(uiState.brandColor) {
+ uiState.brandColor?.let { colorInt ->
+ Color(
+ red = (colorInt shr 16 and 0xFF) / 255f,
+ green = (colorInt shr 8 and 0xFF) / 255f,
+ blue = (colorInt and 0xFF) / 255f,
+ )
+ }
+ }
+ val fallbackColor = MaterialTheme.colorScheme.primaryContainer
+ val backgroundColor = brandBgColor ?: fallbackColor
+ val textColor = remember(backgroundColor) { contrastingTextColor(backgroundColor) }
+
+ BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
+ val hasSheetContent =
+ uiState.warning != null || uiState.infoItems.isNotEmpty() || uiState.transactions.isNotEmpty()
+ val sheetPeekHeight = if (hasSheetContent) maxHeight * 0.30f else 0.dp
+ var wasExpanded by rememberSaveable { mutableStateOf(false) }
+ val scaffoldState =
+ rememberBottomSheetScaffoldState(
+ bottomSheetState =
+ rememberStandardBottomSheetState(
+ initialValue = if (wasExpanded) SheetValue.Expanded else SheetValue.PartiallyExpanded,
+ ),
+ )
+ LaunchedEffect(scaffoldState.bottomSheetState.currentValue) {
+ wasExpanded = scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded
+ }
+ val clipboardManager = LocalClipboardManager.current
+ val scope = rememberCoroutineScope()
+ val copiedMessage = stringResource(Res.string.copied_to_clipboard)
+ val copyToClipboard: (String) -> Unit = { text ->
+ clipboardManager.setText(AnnotatedString(text))
+ scope.launch { scaffoldState.snackbarHostState.showSnackbar(copiedMessage) }
+ }
+ val targetExpanded = scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded
+ val cornerRadius by animateDpAsState(
+ targetValue = if (targetExpanded) 0.dp else 24.dp,
+ animationSpec = tween(durationMillis = 300),
+ )
+ val contentScale by animateFloatAsState(
+ targetValue = if (targetExpanded) 1f else (maxWidth.value - 24f) / maxWidth.value,
+ animationSpec = tween(durationMillis = 300),
+ )
+
+ BottomSheetScaffold(
+ scaffoldState = scaffoldState,
+ sheetPeekHeight = sheetPeekHeight,
+ sheetShape = RectangleShape,
+ sheetContainerColor = Color.Transparent,
+ sheetDragHandle = null,
+ sheetShadowElevation = 8.dp,
+ sheetTonalElevation = 0.dp,
+ containerColor = backgroundColor,
+ topBar = {
+ TopAppBar(
+ title = {},
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent,
+ navigationIconContentColor = textColor,
+ actionIconContentColor = textColor,
+ ),
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(Res.string.back),
)
}
- }
- if (!uiState.isSample || uiState.hasAdvancedData) {
- IconButton(onClick = { menuExpanded = true }) {
- Icon(Icons.Default.MoreVert, contentDescription = stringResource(Res.string.menu))
- }
- DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
- if (!uiState.isSample) {
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.share)) },
- onClick = {
- menuExpanded = false
- onExportShare()
- },
- )
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.save)) },
- onClick = {
- menuExpanded = false
- onExportSave()
- },
+ },
+ actions = {
+ if (uiState.currentScanLabel != null) {
+ Box(
+ modifier =
+ Modifier
+ .padding(end = 4.dp)
+ .background(
+ color = textColor.copy(alpha = 0.15f),
+ shape = RoundedCornerShape(12.dp),
+ ).clickable(onClick = onShowScanHistory)
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ ) {
+ Text(
+ text = uiState.currentScanLabel,
+ style = MaterialTheme.typography.labelSmall,
+ color = textColor,
)
}
- if (uiState.hasAdvancedData) {
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.advanced)) },
- onClick = {
- menuExpanded = false
- onNavigateToAdvanced()
- },
+ }
+ if (!uiState.isSample || uiState.hasAdvancedData) {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(
+ Icons.Default.MoreVert,
+ contentDescription = stringResource(Res.string.menu),
)
}
- if (onDelete != null) {
- DropdownMenuItem(
- text = { Text(stringResource(Res.string.delete)) },
- onClick = {
- menuExpanded = false
- onDelete()
- },
- )
+ DropdownMenu(
+ expanded = menuExpanded,
+ onDismissRequest = { menuExpanded = false },
+ ) {
+ if (!uiState.isSample) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.share)) },
+ onClick = {
+ menuExpanded = false
+ onExportShare()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.save)) },
+ onClick = {
+ menuExpanded = false
+ onExportSave()
+ },
+ )
+ }
+ if (uiState.hasAdvancedData) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.advanced)) },
+ onClick = {
+ menuExpanded = false
+ onNavigateToAdvanced()
+ },
+ )
+ }
+ if (onDelete != null) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.delete)) },
+ onClick = {
+ menuExpanded = false
+ onDelete()
+ },
+ )
+ }
}
}
+ },
+ )
+ },
+ sheetContent = {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .graphicsLayer {
+ scaleX = contentScale
+ transformOrigin = TransformOrigin(0.5f, 0f)
+ }.clip(RoundedCornerShape(topStart = cornerRadius, topEnd = cornerRadius))
+ .background(MaterialTheme.colorScheme.surface),
+ ) {
+ // Drag handle
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(width = 32.dp, height = 4.dp)
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ RoundedCornerShape(2.dp),
+ ),
+ )
}
- },
- )
- },
- ) { padding ->
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .padding(padding),
- ) {
- when {
- uiState.isLoading -> {
- CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
- }
- uiState.error != null -> {
- Text(
- text = uiState.error,
- style = MaterialTheme.typography.bodyLarge,
+ LazyColumn(
modifier =
Modifier
- .align(Alignment.Center)
- .padding(16.dp),
- )
- }
- else -> {
- LazyColumn(modifier = Modifier.fillMaxSize()) {
+ .fillMaxWidth()
+ .weight(1f),
+ ) {
// Warning banner
if (uiState.warning != null) {
item {
@@ -214,104 +421,91 @@ fun CardScreen(
}
}
- // Empty state message (e.g., serial-only cards)
- if (uiState.emptyStateMessage != null &&
- uiState.balances.isEmpty() &&
- uiState.transactions.isEmpty()
- ) {
- item {
- Box(
- modifier =
- Modifier
- .fillParentMaxHeight(0.6f)
- .fillMaxWidth(),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = uiState.emptyStateMessage,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(horizontal = 32.dp),
- )
- }
- }
- }
-
- // Balances
- if (uiState.balances.isNotEmpty()) {
- item {
- ElevatedCard(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
- ) {
- Column(
- modifier = Modifier.padding(16.dp),
- ) {
- Text(
- text = stringResource(Res.string.balance),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- for (balanceItem in uiState.balances) {
- if (balanceItem.name != null) {
- Text(
- text = balanceItem.name,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Text(
- text = balanceItem.balance,
- style = MaterialTheme.typography.headlineMedium,
- )
- }
- }
- }
- }
- }
-
// Info items
if (uiState.infoItems.isNotEmpty()) {
item {
SectionHeaderRow(TransactionItem.SectionHeader("Info"))
}
items(uiState.infoItems) { infoItem ->
- InfoItemRow(infoItem)
+ InfoItemRow(
+ item = infoItem,
+ onLongClick = {
+ val text = listOfNotNull(infoItem.title, infoItem.value).joinToString(": ")
+ copyToClipboard(text)
+ },
+ )
}
item {
HorizontalDivider()
}
}
- uiState.transactions.forEach { item ->
- when (item) {
+ // Transactions (with sticky headers)
+ uiState.transactions.forEach { txnItem ->
+ when (txnItem) {
is TransactionItem.DateHeader -> {
- stickyHeader(key = item.date) {
- DateHeaderRow(item)
+ stickyHeader(key = txnItem.date) {
+ DateHeaderRow(txnItem)
}
}
is TransactionItem.SectionHeader -> {
- stickyHeader(key = item.title) {
- SectionHeaderRow(item)
+ stickyHeader(key = txnItem.title) {
+ SectionHeaderRow(txnItem)
}
}
is TransactionItem.TripItem -> {
item {
- TripRow(item, onNavigateToTripMap)
+ TripRow(
+ trip = txnItem,
+ onNavigateToTripMap = onNavigateToTripMap,
+ onLongClick = {
+ val text =
+ listOfNotNull(
+ txnItem.agency,
+ txnItem.route,
+ txnItem.stations,
+ txnItem.fare,
+ txnItem.time,
+ ).joinToString(" · ")
+ copyToClipboard(text)
+ },
+ )
HorizontalDivider()
}
}
is TransactionItem.RefillItem -> {
item {
- RefillRow(item)
+ RefillRow(
+ refill = txnItem,
+ onLongClick = {
+ val text =
+ listOfNotNull(
+ txnItem.agency,
+ txnItem.amount,
+ txnItem.time,
+ ).joinToString(" · ")
+ copyToClipboard(text)
+ },
+ )
HorizontalDivider()
}
}
is TransactionItem.SubscriptionItem -> {
item {
- SubscriptionRow(item)
+ SubscriptionRow(
+ sub = txnItem,
+ onLongClick = {
+ val text =
+ listOfNotNull(
+ txnItem.name,
+ txnItem.agency,
+ txnItem.validRange,
+ txnItem.remainingTrips,
+ txnItem.state,
+ ).joinToString(" · ")
+ copyToClipboard(text)
+ },
+ )
HorizontalDivider()
}
}
@@ -319,63 +513,121 @@ fun CardScreen(
}
}
}
- }
- }
- }
+ },
+ ) { padding ->
+ // Hero area: card image, serial, balance — centered above the sheet
+ var showCardDetailSheet by remember { mutableStateOf(false) }
- // Scan history bottom sheet
- if (uiState.showScanHistory && uiState.scanHistory.isNotEmpty()) {
- ModalBottomSheet(
- onDismissRequest = onShowScanHistory,
- sheetState = rememberModalBottomSheetState(),
- ) {
- Column(modifier = Modifier.padding(bottom = 24.dp)) {
- Text(
- text = stringResource(Res.string.scan_history),
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- )
- for (entry in uiState.scanHistory) {
- Row(
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ // Card image (tappable to show card detail sheet)
+ val cardImageRes = uiState.cardInfo?.imageRes
+ if (cardImageRes != null) {
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ shadowElevation = 8.dp,
modifier =
Modifier
.fillMaxWidth()
- .clickable {
- if (!entry.isCurrent) {
- onNavigateToScan(entry.savedCardId)
- }
- }.padding(horizontal = 16.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
+ .aspectRatio(1.586f)
+ .clickable { showCardDetailSheet = true },
) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = entry.scannedDate,
- style = MaterialTheme.typography.bodyMedium,
- )
- if (entry.scannedTime.isNotEmpty()) {
+ Image(
+ painter = painterResource(cardImageRes),
+ contentDescription = uiState.cardName,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ if (uiState.serialNumber != null) {
+ Text(
+ text = uiState.serialNumber,
+ style = MaterialTheme.typography.bodyMedium,
+ color = textColor.copy(alpha = 0.7f),
+ fontFamily = FontFamily.Monospace,
+ modifier =
+ Modifier.combinedClickable(
+ onLongClick = { copyToClipboard(uiState.serialNumber) },
+ onClick = {},
+ ),
+ )
+ }
+ if (uiState.balances.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(16.dp))
+ for (balanceItem in uiState.balances) {
+ Column(
+ modifier =
+ Modifier.combinedClickable(
+ onLongClick = { copyToClipboard(balanceItem.balance) },
+ onClick = {},
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (balanceItem.name != null) {
Text(
- text = entry.scannedTime,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = balanceItem.name,
+ style = MaterialTheme.typography.labelMedium,
+ color = textColor.copy(alpha = 0.7f),
)
}
+ Text(
+ text = balanceItem.balance,
+ style = MaterialTheme.typography.displayMedium,
+ color = textColor,
+ )
}
- RadioButton(
- selected = entry.isCurrent,
- onClick = {
- if (!entry.isCurrent) {
- onNavigateToScan(entry.savedCardId)
- }
- },
- )
}
- HorizontalDivider()
+ }
+ if (uiState.emptyStateMessage != null &&
+ uiState.balances.isEmpty() &&
+ uiState.transactions.isEmpty()
+ ) {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = uiState.emptyStateMessage,
+ style = MaterialTheme.typography.bodyLarge,
+ color = textColor.copy(alpha = 0.7f),
+ )
+ }
+ }
+
+ // Card detail bottom sheet (same as Explore tab)
+ val cardInfo = uiState.cardInfo
+ if (showCardDetailSheet && cardInfo != null) {
+ val cardName = stringResource(cardInfo.nameRes)
+ val cardLocation = stringResource(cardInfo.locationRes)
+ ModalBottomSheet(
+ onDismissRequest = { showCardDetailSheet = false },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ CardDetailSheet(
+ card = cardInfo,
+ cardName = cardName,
+ cardLocation = cardLocation,
+ isSupported = true,
+ isKeysRequired = cardInfo.keysRequired,
+ showImage = false,
+ )
}
}
}
}
}
+private fun contrastingTextColor(color: Color): Color {
+ val luminance = 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
+ return if (luminance > 0.5) Color.Black else Color.White
+}
+
@Composable
private fun WarningBanner(warning: String) {
Surface(
@@ -434,7 +686,10 @@ private fun SectionHeaderRow(header: TransactionItem.SectionHeader) {
}
@Composable
-private fun InfoItemRow(item: InfoItem) {
+private fun InfoItemRow(
+ item: InfoItem,
+ onLongClick: () -> Unit = {},
+) {
if (item.isHeader) {
ListItem(
headlineContent = {
@@ -447,8 +702,13 @@ private fun InfoItemRow(item: InfoItem) {
)
} else {
ListItem(
- headlineContent = { Text(text = item.title ?: "") },
- supportingContent =
+ headlineContent = {
+ Text(
+ text = item.title ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ trailingContent =
if (item.value != null) {
{
Text(
@@ -460,6 +720,11 @@ private fun InfoItemRow(item: InfoItem) {
} else {
null
},
+ modifier =
+ Modifier.combinedClickable(
+ onLongClick = onLongClick,
+ onClick = {},
+ ),
)
}
}
@@ -468,6 +733,7 @@ private fun InfoItemRow(item: InfoItem) {
private fun TripRow(
trip: TransactionItem.TripItem,
onNavigateToTripMap: (String) -> Unit,
+ onLongClick: () -> Unit = {},
) {
val agencyRoute = listOfNotNull(trip.agency, trip.route).joinToString(" ").ifEmpty { null }
// If no agency/route, promote stations to headline so title isn't blank.
@@ -528,18 +794,22 @@ private fun TripRow(
null
},
modifier =
- Modifier.let { mod ->
- if (trip.hasLocation && trip.tripKey != null) {
- mod.clickable { onNavigateToTripMap(trip.tripKey) }
- } else {
- mod
- }
- },
+ Modifier.combinedClickable(
+ onLongClick = onLongClick,
+ onClick = {
+ if (trip.hasLocation && trip.tripKey != null) {
+ onNavigateToTripMap(trip.tripKey)
+ }
+ },
+ ),
)
}
@Composable
-private fun RefillRow(refill: TransactionItem.RefillItem) {
+private fun RefillRow(
+ refill: TransactionItem.RefillItem,
+ onLongClick: () -> Unit = {},
+) {
ListItem(
headlineContent = { Text(text = stringResource(Res.string.refill)) },
supportingContent =
@@ -567,11 +837,19 @@ private fun RefillRow(refill: TransactionItem.RefillItem) {
}
}
},
+ modifier =
+ Modifier.combinedClickable(
+ onLongClick = onLongClick,
+ onClick = {},
+ ),
)
}
@Composable
-private fun SubscriptionRow(sub: TransactionItem.SubscriptionItem) {
+private fun SubscriptionRow(
+ sub: TransactionItem.SubscriptionItem,
+ onLongClick: () -> Unit = {},
+) {
val supportingParts =
buildList {
if (sub.agency != null) add(sub.agency)
@@ -592,6 +870,11 @@ private fun SubscriptionRow(sub: TransactionItem.SubscriptionItem) {
} else {
null
},
+ modifier =
+ Modifier.combinedClickable(
+ onLongClick = onLongClick,
+ onClick = {},
+ ),
)
}
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardUiState.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardUiState.kt
index 8ee096eaf..9da7dc7a8 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardUiState.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/CardUiState.kt
@@ -2,6 +2,7 @@ package com.codebutler.farebot.shared.ui.screen
import com.codebutler.farebot.base.ui.FareBotUiTree
import com.codebutler.farebot.base.util.FormattedString
+import com.codebutler.farebot.transit.CardInfo
import com.codebutler.farebot.transit.Trip
data class CardUiState(
@@ -20,6 +21,8 @@ data class CardUiState(
val currentScanLabel: String? = null,
val scanHistory: List = emptyList(),
val showScanHistory: Boolean = false,
+ val brandColor: Int? = null,
+ val cardInfo: CardInfo? = null,
)
data class ScanHistoryEntry(
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HelpScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HelpScreen.kt
index d08e27afc..23e0f40ae 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HelpScreen.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HelpScreen.kt
@@ -471,7 +471,7 @@ private fun CardImageTile(
@OptIn(ExperimentalLayoutApi::class)
@Composable
-private fun CardDetailSheet(
+internal fun CardDetailSheet(
card: CardInfo,
cardName: String,
cardLocation: String,
@@ -479,6 +479,7 @@ private fun CardDetailSheet(
isKeysRequired: Boolean,
onStatusChipTap: (String) -> Unit = {},
onSampleCardTap: (() -> Unit)? = null,
+ showImage: Boolean = true,
) {
Column(
modifier =
@@ -487,40 +488,41 @@ private fun CardDetailSheet(
.padding(horizontal = 16.dp),
) {
// Card image
- Box(
- modifier =
- Modifier
- .fillMaxWidth()
- .aspectRatio(1.586f)
- .clip(RoundedCornerShape(12.dp)),
- ) {
- val imageRes = card.imageRes
- if (imageRes != null) {
- Image(
- painter = painterResource(imageRes),
- contentDescription = cardName,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
- } else {
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.surfaceVariant),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = cardName,
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ if (showImage) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(1.586f)
+ .clip(RoundedCornerShape(12.dp)),
+ ) {
+ val imageRes = card.imageRes
+ if (imageRes != null) {
+ Image(
+ painter = painterResource(imageRes),
+ contentDescription = cardName,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
)
+ } else {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = cardName,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
+ Spacer(Modifier.height(12.dp))
}
- Spacer(Modifier.height(12.dp))
-
// Card name
Text(
text = cardName,
@@ -669,7 +671,7 @@ private fun CardDetailSheet(
}
@Composable
-private fun NonInteractiveChip(content: @Composable () -> Unit) {
+internal fun NonInteractiveChip(content: @Composable () -> Unit) {
Box(
modifier =
Modifier.pointerInput(Unit) {
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt
index f809c5fc7..b82dfd20a 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/HomeScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.Button
@@ -105,6 +106,7 @@ import farebot.app.generated.resources.ok
import farebot.app.generated.resources.reading_card
import farebot.app.generated.resources.scan
import farebot.app.generated.resources.search_supported_cards
+import farebot.app.generated.resources.select_all
import farebot.app.generated.resources.show
import farebot.app.generated.resources.show_all_scans
import farebot.app.generated.resources.show_experimental_cards
@@ -130,6 +132,7 @@ fun HomeScreen(
onImportFile: () -> Unit,
onToggleSelection: (String) -> Unit,
onClearSelection: () -> Unit,
+ onSelectAll: () -> Unit,
onDeleteSelected: () -> Unit,
supportedCards: List,
supportedCardTypes: Set,
@@ -255,6 +258,9 @@ fun HomeScreen(
}
},
actions = {
+ IconButton(onClick = onSelectAll) {
+ Icon(Icons.Default.SelectAll, contentDescription = stringResource(Res.string.select_all))
+ }
IconButton(onClick = { showDeleteConfirmation = true }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(Res.string.delete))
}
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/KeysScreen.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/KeysScreen.kt
index bf63acbd0..8230af4f3 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/KeysScreen.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/ui/screen/KeysScreen.kt
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
@@ -47,6 +48,7 @@ import farebot.app.generated.resources.delete_selected_keys
import farebot.app.generated.resources.keys
import farebot.app.generated.resources.n_selected
import farebot.app.generated.resources.no_keys
+import farebot.app.generated.resources.select_all
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@@ -58,6 +60,7 @@ fun KeysScreen(
onDeleteKey: (String) -> Unit,
onToggleSelection: (String) -> Unit = {},
onClearSelection: () -> Unit = {},
+ onSelectAll: () -> Unit = {},
onDeleteSelected: () -> Unit = {},
) {
var showDeleteConfirmation by remember { mutableStateOf(false) }
@@ -94,6 +97,9 @@ fun KeysScreen(
}
},
actions = {
+ IconButton(onClick = onSelectAll) {
+ Icon(Icons.Default.SelectAll, contentDescription = stringResource(Res.string.select_all))
+ }
IconButton(onClick = { showDeleteConfirmation = true }) {
Icon(Icons.Default.Delete, contentDescription = stringResource(Res.string.delete))
}
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt
index 5d75e650f..0247f0202 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/CardViewModel.kt
@@ -76,6 +76,8 @@ class CardViewModel(
viewModelScope.launch {
try {
val card = rawCard.parse()
+ val brandColor = transitFactoryRegistry.findBrandColor(card)
+ val cardInfo = transitFactoryRegistry.findCardInfo(card)
val transitInfo = transitFactoryRegistry.parseTransitInfo(card)
// Build scan history entries
@@ -121,6 +123,8 @@ class CardViewModel(
scanCount = currentScanIds.size.coerceAtLeast(1),
currentScanLabel = currentScanLabel,
scanHistory = scanHistory,
+ brandColor = brandColor,
+ cardInfo = cardInfo,
)
} else {
val tagIdHex =
@@ -145,6 +149,8 @@ class CardViewModel(
scanCount = currentScanIds.size.coerceAtLeast(1),
currentScanLabel = currentScanLabel,
scanHistory = scanHistory,
+ brandColor = brandColor,
+ cardInfo = cardInfo,
)
}
} catch (ex: Exception) {
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HistoryViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HistoryViewModel.kt
index ac69914d0..f8db4c582 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HistoryViewModel.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/HistoryViewModel.kt
@@ -183,6 +183,16 @@ class HistoryViewModel(
)
}
+ fun selectAll() {
+ val current = _uiState.value
+ val allIds = current.items.map { it.id }.toSet()
+ _uiState.value =
+ current.copy(
+ selectedIds = allIds,
+ isSelectionMode = allIds.isNotEmpty(),
+ )
+ }
+
fun clearSelection() {
_uiState.value =
_uiState.value.copy(
diff --git a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/KeysViewModel.kt b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/KeysViewModel.kt
index 7fbc81507..5b78e9f6b 100644
--- a/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/KeysViewModel.kt
+++ b/app/src/commonMain/kotlin/com/codebutler/farebot/shared/viewmodel/KeysViewModel.kt
@@ -59,6 +59,16 @@ class KeysViewModel(
)
}
+ fun selectAll() {
+ val current = _uiState.value
+ val allIds = current.keys.map { it.id }.toSet()
+ _uiState.value =
+ current.copy(
+ selectedIds = allIds,
+ isSelectionMode = allIds.isNotEmpty(),
+ )
+ }
+
fun clearSelection() {
_uiState.value =
_uiState.value.copy(