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(