diff --git a/.gitignore b/.gitignore index c7d32c2..81aef0b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ AGENTS.md # Zephyr # ---------------------------- firmware/app/build/ +firmware/app/build_*/ +firmware/diagnostic external/ west-manifest/ .venv/ @@ -66,4 +68,5 @@ _autosave-* # ---------------------------- # Divers # ---------------------------- -tools/ \ No newline at end of file +tools/ +*.xcf \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8229a44..e342c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ The format is based on Keep a Changelog and this project follows Semantic Versio ### Added +- One-second white RGB indication when firmware initialization completes and + the keypad loop is ready. +- Branded Android launcher icon and native splash screen using the project + assets. +- French and English mobile UI with automatic phone-language selection and a + manual override. +- Dark mobile dashboard, shortcut bar, styled modals, code limits, opening + history, phone-side NFC UID scanning, and administrator/guest role controls. +- Secure saved-box records, automatic BLE reconnection, invitation QR + generation/scanning, and guest revocation. +- AES-256-GCM password-protected mobile backup and restore using a + PBKDF2-SHA256 derived key. +- Persistent box names and administrator factory reset with BLE bond removal. +- Persistent seven-day opening history for permanent codes, temporary codes, + named NFC tags, and named mobile identities. +- Fallback time `2026-06-01T00:00:00Z` and reboot continuity from the newest + persisted opening event. +- Eight permanent and twenty temporary access-code slots. +- BLE LE Secure Connections, AES-CCM encrypted GATT access, persistent bonding, + and 128-bit administrator/guest application identities. +- First-phone administrator provisioning without exposing the stored key. +- App-triggered NFC enrollment with persistent tag names. - Initial project structure. - Zephyr RTOS firmware baseline. - Standardized project architecture. @@ -27,6 +49,13 @@ The format is based on Keep a Changelog and this project follows Semantic Versio - Persistent six-digit access code storage. - Persistent NFC tag UID storage with default development UID `60:4F:E2:B5`. - NFC scan-mode application flow triggered by keypad activity. +- Flutter mobile application shell in `mobile-app/app`. +- Mobile application use of the root `images/` logo and `background.jpg` assets. +- Mobile BLE scan/connect flow targeting the OpenParcelBox XIAO service. +- Mobile commands for XIAO clock synchronization, direct lock opening, access-code management, and NFC tag management. +- Firmware BLE administration service with JSON command writes and stored-state readback. +- Volatile phone-synchronized firmware clock for lock-opening log timestamps. +- One-time access codes that are removed after first successful keypad use. - Lock control pulse on XIAO pin `D9`. - Lock state feedback detection using KR-S79 `COM/NC` on XIAO `D7` / `D8`. - Long buzzer success beep for accepted unlock codes. @@ -48,10 +77,62 @@ The format is based on Keep a Changelog and this project follows Semantic Versio - Updated keypad unlock user feedback with off-at-rest LEDs, green-open indication, red invalid-code feedback, and `B` entry cancellation. - Updated runtime LED feedback with blue NFC scan-mode indication. - Updated open-lock reminder beep interval to 2 seconds. +- Updated the mobile application to reload access codes and NFC tags from the firmware BLE state characteristic. - Corrected NFC hardware documentation: the nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware, while passive badge UID reading requires a dedicated NFC reader circuit. ### Fixed +- Removed oversized Settings callback allocations that exceeded the 1024-byte + main stack once opening-history or application-identity records existed. +- Moved Settings/NVS loading to a dedicated 4096-byte services thread and + increased the main, system-workqueue, and Bluetooth RX stack margins. +- Kept the keypad fallback code and default clock available from RAM before any + flash or Bluetooth operation can run. +- Added bounded retries when Bluetooth advertising temporarily returns + `-EAGAIN`, and no longer abort advertising solely because stored Bluetooth + settings report a load error. +- Prevented invalid persisted box settings from overwriting the active default + configuration before validation. +- Kept opening-history timestamps monotonic after an older phone clock sync so + one descending event cannot invalidate the complete table on reboot. +- Provided Flutter's Material localization delegates so input fields and + registration dialogs work when the phone language is French. +- Prevented incompatible persistent development-table layouts from blocking + firmware startup before keypad initialization; affected tables now recover + through their safe defaults. +- Made Bluetooth initialization asynchronous so keypad operation and USB + availability no longer wait for BLE controller startup. +- Removed the silent early exit when RGB LED initialization fails. +- Kept keypad access available with in-memory default credentials when + persistent settings fail, and added background keypad initialization retries. +- Fixed secure BLE startup ordering: the persisted `bt/*` settings and identity + are now loaded after `bt_enable()` and before advertising, preventing + `BLE advertising failed: -11`. +- Moved secure BLE initialization to a dedicated thread started after the local + keypad-ready indication, so Bluetooth or bond restoration can never block + keypad access, lock control, or the main application loop. +- Removed the interactive Zephyr/I2C shell and its competing serial backend; + USB CDC is now owned exclusively by the firmware console, matching the + validated standalone diagnostic configuration. +- Restricted guest BLE sessions to authentication, clock synchronization, and + lock opening, and replaced oversized full-state notifications with a compact + change marker. +- Added strict validation of persisted identity and opening-history records + before they can be used or serialized. +- Removed automatic NVS writes and migrations from the boot path. Default + tables are now available immediately in RAM and are persisted only after a + real credential, identity, tag, box-name, or history change. +- Replaced the coupled application loop with a resilient local-first entry + point: keypad input, code validation, lock control, buzzer, LEDs, and door + feedback run independently from BLE and NFC processing. +- Temporarily removed NFC polling from the production main loop while retaining + NFC storage and BLE management initialization; it will be restored after the + validated keypad/BLE baseline is confirmed on hardware. + +- Fixed Flutter inherited-widget assertions when closing input modals by keeping + text controllers alive until their dialog widgets are actually unmounted. +- Changed encrypted-backup restoration to select the backup file before asking + for its password. - Matrix keypad scanning. - GPIO expander abstraction. - Firmware build configuration. diff --git a/README.md b/README.md index 1c7c97d..16c19a2 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,25 @@ Completed or validated: - Persistent six-digit access codes - Keypad unlock flow with default development code `784512` - Persistent NFC tag UID table and scan-mode firmware API +- Flutter mobile application with branded Android icon/splash, bilingual dark + interface, secure registration, encrypted backups, and role-aware modals +- Mobile BLE automatic reconnection and command UI for clock sync, lock + opening, history, access codes, NFC tags, and guest invitations +- Firmware BLE service for those mobile commands plus stored-state readback +- One-time access codes with automatic removal after first successful keypad use +- Phone-synchronized runtime timestamps for lock-opening logs +- Persistent seven-day opening history with power-loss time continuity +- Eight permanent and twenty temporary access-code slots +- BLE LE Secure Connections, encrypted GATT access, and 128-bit phone identities +- First-phone administrator provisioning and named guest identities +- App-triggered NFC enrollment with stored tag names - Lock command pulse on XIAO `D9` - KR-S79 `COM/NC` lock state feedback on XIAO `D7` / `D8` - Green-open, off-closed, red-invalid LED feedback - Short key beeps, long valid-code beep, invalid-code beep sequence, and 2s open reminder beep - Door opened / closed serial debug messages - Firmware debug output over serial console +- One-second white RGB confirmation when the local keypad path is ready In progress: @@ -45,16 +58,16 @@ In progress: - Production keypad mapping - NFC credential reader hardware selection - Hardware validation +- BLE protocol documentation and hardening Planned: -- Administrator access management +- Firmware update from the mobile application - NFC badge unlock with a dedicated reader circuit - Battery management - Zigbee - OTA updates - Home Assistant integration -- Mobile configuration application - Custom PCB ## Repository Structure @@ -69,7 +82,8 @@ OpenParcelBox/ | +-- app/ Main firmware application +-- hardware/ Hardware documentation and reverse engineering +-- homeassistant/ Home Assistant integration notes -+-- mobile-app/ Mobile application notes ++-- mobile-app/ Flutter mobile application and notes ++-- images/ Project logo and application background assets +-- CHANGELOG.md +-- LICENSE +-- README.md @@ -93,6 +107,21 @@ Current firmware modules: - Lock state feedback - Persistent access codes - Persistent NFC tag UID storage +- BLE administration service + +## Mobile App + +The mobile application is developed with Flutter in: + +```text +mobile-app/app/ +``` + +The application provides local-first administrator and guest surfaces for +secure Bluetooth registration, automatic reconnection, XIAO date/time +synchronization, access-code management, NFC enrollment, guest invitation QR +codes, opening history, encrypted backup/restore, direct opening, and +authoritative firmware state readback. Firmware documentation is available in: diff --git a/ROADMAP.md b/ROADMAP.md index 4aac730..a4b63a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,10 +46,11 @@ The firmware is based on Zephyr RTOS and follows a modular architecture to simpl | NFC credential storage and scan API | Done | | Dedicated NFC reader driver | Pending | | Power management | Pending | -| Bluetooth configuration | Pending | +| Bluetooth configuration | In progress | | Zigbee integration | Pending | | Home Assistant integration | Pending | | PCB V1 | Pending | +| Flutter mobile application | In progress | --- @@ -192,7 +193,20 @@ Features: - Device information. - Firmware information. -Status: planned. +Implemented so far: + +- Flutter-side BLE scan and connection flow. +- Draft JSON command transport for clock sync, lock opening, access-code management, and NFC tag management. +- Firmware-side BLE command characteristic and state readback characteristic. +- Phone-synchronized volatile firmware clock for timestamped lock-opening logs. +- BLE LE Secure Connections, encrypted GATT permissions, and bonded peers. +- First-phone administrator plus named guest application identities. +- App-triggered named NFC enrollment. +- Persistent seven-day opening history and reboot time continuity. +- Persistent box identifier, guest invitation/revocation commands, and + administrator factory reset with BLE bond removal. + +Status: in progress. --- @@ -240,7 +254,27 @@ Features: - Device status. - Firmware update. -Status: planned. +Implemented so far: + +- Application shell using the project logo and background image. +- Bluetooth scan/connect UI targeting the XIAO BLE service. +- Phone-to-XIAO date/time sync command. +- Direct lock-open command. +- Random six-digit numeric code generation. +- Permanent code management screen. +- One-time temporary code management screen. +- NFC tag add/remove screen. +- Stored code and NFC tag readback from the firmware BLE state characteristic. +- Branded Android launcher icon and native splash screen. +- Automatic French/English selection with a manual override. +- Dark modal dashboard with separate administrator and guest capabilities. +- Secure box registration, invitation QR scanning/generation, and automatic + BLE reconnection. +- AES-256-GCM password-protected application backup and restore. +- Opening history, code limits, phone-side NFC scans, and destructive-action + warnings. + +Status: in progress. --- diff --git a/TODO.md b/TODO.md index 7701118..a28f04f 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,7 @@ Tasks are grouped by development phase and updated throughout the project. - [x] Create firmware documentation. - [x] Create hardware documentation baseline. - [ ] Create development documentation. -- [ ] Create protocol documentation. +- [x] Create BLE protocol documentation. ### Firmware Baseline @@ -146,7 +146,7 @@ Tasks are grouped by development phase and updated throughout the project. ### Configuration - [x] Persistent storage. -- [ ] Factory reset. +- [x] Factory reset. - [ ] Version information. --- @@ -181,10 +181,26 @@ Tasks are grouped by development phase and updated throughout the project. ## Later Phases -- [ ] Bluetooth communication. -- [ ] Flutter mobile application. -- [ ] User management. -- [ ] Local event history. +- [x] Firmware BLE command service. +- [x] Initial Flutter mobile application shell. +- [x] Mobile Bluetooth scan and connect UI. +- [x] Mobile clock-sync command. +- [x] Mobile random permanent and one-time code management UI. +- [x] Mobile NFC tag add/remove UI. +- [x] Mobile direct lock-open command UI. +- [x] Mobile stored-state readback from firmware. +- [x] Administrator and named guest identity storage. +- [x] Local seven-day event history. +- [x] Secure BLE pairing and encrypted application authorization. +- [x] App-triggered named NFC enrollment flow. +- [x] Branded Android launcher icon and native splash screen. +- [x] French/English application localization and language override. +- [x] Secure saved-box registration and automatic BLE reconnection. +- [x] Administrator/guest modal dashboard and role restrictions. +- [x] Guest invitation QR generation, scanning, and revocation. +- [x] Password-protected AES-256-GCM application backup and restore. +- [x] Mobile opening-history, NFC, and factory-reset administration. +- [ ] Validate pairing recovery and guest invitation on Android and iOS hardware. - [ ] Zigbee connectivity. - [ ] Home Assistant integration. - [ ] Temporary access. diff --git a/images/background.jpg b/images/background.jpg new file mode 100644 index 0000000..8812576 Binary files /dev/null and b/images/background.jpg differ diff --git a/images/openparcelbox-logo.ico b/images/openparcelbox-logo.ico new file mode 100644 index 0000000..6ba5a75 Binary files /dev/null and b/images/openparcelbox-logo.ico differ diff --git a/mobile-app/app/.gitignore b/mobile-app/app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/mobile-app/app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/mobile-app/app/.metadata b/mobile-app/app/.metadata new file mode 100644 index 0000000..235bfb1 --- /dev/null +++ b/mobile-app/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ee80f08bbf97172ec030b8751ceab557177a34a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + - platform: android + create_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + base_revision: ee80f08bbf97172ec030b8751ceab557177a34a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/mobile-app/app/README.md b/mobile-app/app/README.md new file mode 100644 index 0000000..716fcb3 --- /dev/null +++ b/mobile-app/app/README.md @@ -0,0 +1,104 @@ +# OpenParcelBox Mobile App + +Flutter application for local OpenParcelBox setup and administration. + +## User Interface + +- Dark solid background `#333333`, button surfaces `#292929`, modal surfaces + `#303030`, accent `#c19d60`, and text `#c6c6c6`. +- Project logo used for the Android launcher icon and centered native splash. +- Native splash uses the project `background.jpg`. +- Automatic French or English selection from the phone, with a manual override. +- Empty state restricted to box registration and encrypted-backup restoration. +- Shortcut bar for opening and history, plus modal cards for codes, NFC tags, + guests, and settings. +- Guest mode hides NFC, guest administration, factory reset, and future firmware + update controls. + +## Registration and Secure Storage + +The first phone connecting to an unprovisioned box: + +1. chooses the box identifier; +2. generates a random 128-bit administrator identity; +3. sends both values over the encrypted BLE link; +4. stores the remote BLE identifier, box name, role, and identity in the phone + secure keystore. + +An administrator creates named guest identities and displays an invitation QR +code. A guest scans that QR code to register the same box and its unique guest +identity. Saved boxes reconnect automatically when the application starts. + +Application backups contain the saved connection records. They are protected +with AES-256-GCM and a PBKDF2-SHA256 key derived from the user password before a +file is written. During restoration, the application first asks for the backup +file and only then requests the password needed to decrypt it. + +## Features + +- Direct lock opening and seven-day opening history. +- Eight permanent and twenty one-time access codes. +- NFC tag listing, renaming, deletion, manual UID entry, phone NFC scanning, + and enrollment through the box reader. +- Named guest creation, invitation QR display, and revocation. +- Firmware clock synchronization after connection. +- Language override, encrypted backup/restore, factory reset, and local removal + of a saved box. +- Reserved disabled entry for a later firmware-update flow. + +## BLE Security and Protocol + +The firmware requires BLE LE Secure Connections and encrypted GATT access. +Bluetooth uses AES-CCM link encryption. Every application command is also +authorized with the random administrator or guest identity stored in the phone +keystore. + +Service UUID: + +```text +f2a00000-8e7a-4f8d-9b1d-7d8e4b7a0001 +``` + +Command characteristic: + +```text +f2a00001-8e7a-4f8d-9b1d-7d8e4b7a0001 +``` + +State characteristic: + +```text +f2a00002-8e7a-4f8d-9b1d-7d8e4b7a0001 +``` + +See `docs/firmware/bluetooth.md` from the repository root for the command and +state formats. + +## Dependencies + +The app uses: + +- `flutter_blue_plus` for BLE; +- `flutter_secure_storage` for connection identities; +- `mobile_scanner` and `qr_flutter` for guest invitations; +- `nfc_manager` for optional phone-side NFC UID scans; +- `file_picker` and `cryptography` for encrypted backups. + +`flutter_blue_plus` 2.3.10 requires a license mode when connecting. The app uses +`License.nonprofit` for this open-source project. + +## Development + +```powershell +flutter pub get +flutter analyze +flutter test +flutter build apk --debug +``` + +Native assets can be regenerated with: + +```powershell +dart run flutter_launcher_icons +dart run flutter_native_splash:create +``` diff --git a/mobile-app/app/analysis_options.yaml b/mobile-app/app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/mobile-app/app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/mobile-app/app/android/.gitignore b/mobile-app/app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/mobile-app/app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/mobile-app/app/android/app/build.gradle.kts b/mobile-app/app/android/app/build.gradle.kts new file mode 100644 index 0000000..cd13db6 --- /dev/null +++ b/mobile-app/app/android/app/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "fr.zaynet.openparcelbox.app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "fr.zaynet.openparcelbox.app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/mobile-app/app/android/app/src/debug/AndroidManifest.xml b/mobile-app/app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile-app/app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile-app/app/android/app/src/main/AndroidManifest.xml b/mobile-app/app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..580b1c8 --- /dev/null +++ b/mobile-app/app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile-app/app/android/app/src/main/kotlin/fr/zaynet/openparcelbox/app/MainActivity.kt b/mobile-app/app/android/app/src/main/kotlin/fr/zaynet/openparcelbox/app/MainActivity.kt new file mode 100644 index 0000000..5c70a8e --- /dev/null +++ b/mobile-app/app/android/app/src/main/kotlin/fr/zaynet/openparcelbox/app/MainActivity.kt @@ -0,0 +1,5 @@ +package fr.zaynet.openparcelbox.app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/mobile-app/app/android/app/src/main/res/drawable-hdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..1623c79 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/mobile-app/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5be96cf Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-hdpi/splash.png b/mobile-app/app/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..1623c79 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-mdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..7967f6a Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/mobile-app/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..eff5201 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-mdpi/splash.png b/mobile-app/app/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..7967f6a Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..1623c79 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..7967f6a Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..90f06a5 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..f184c20 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..2b2e885 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-v21/background.png b/mobile-app/app/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..49a1724 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-v21/background.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-v21/launch_background.xml b/mobile-app/app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile-app/app/android/app/src/main/res/drawable-xhdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..90f06a5 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/mobile-app/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..46eb1ca Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xhdpi/splash.png b/mobile-app/app/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..90f06a5 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..f184c20 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a161790 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/splash.png b/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..f184c20 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..2b2e885 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ed67fb9 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/splash.png b/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..2b2e885 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable/background.png b/mobile-app/app/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..49a1724 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/drawable/background.png differ diff --git a/mobile-app/app/android/app/src/main/res/drawable/launch_background.xml b/mobile-app/app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile-app/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile-app/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/mobile-app/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile-app/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..f95c5fc Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile-app/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile-app/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..e37a115 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile-app/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile-app/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..18b3349 Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile-app/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile-app/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..5b4a62f Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile-app/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile-app/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..a099d6d Binary files /dev/null and b/mobile-app/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile-app/app/android/app/src/main/res/values-night-v31/styles.xml b/mobile-app/app/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..cbfc537 --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/mobile-app/app/android/app/src/main/res/values-night/styles.xml b/mobile-app/app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..dbc9ea9 --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/mobile-app/app/android/app/src/main/res/values-v31/styles.xml b/mobile-app/app/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..7977c19 --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/mobile-app/app/android/app/src/main/res/values/colors.xml b/mobile-app/app/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f2269c5 --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #333333 + \ No newline at end of file diff --git a/mobile-app/app/android/app/src/main/res/values/styles.xml b/mobile-app/app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0d1fa8f --- /dev/null +++ b/mobile-app/app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/mobile-app/app/android/app/src/profile/AndroidManifest.xml b/mobile-app/app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/mobile-app/app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile-app/app/android/build.gradle.kts b/mobile-app/app/android/build.gradle.kts new file mode 100644 index 0000000..10922c9 --- /dev/null +++ b/mobile-app/app/android/build.gradle.kts @@ -0,0 +1,36 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) + + // mobile_scanner 7.2.1 detects AGP 9, while Flutter currently opts this + // project out of AGP's built-in Kotlin support. Apply the legacy Kotlin + // plugin explicitly so the package's Kotlin DSL remains available. + if (name in setOf("file_picker", "mobile_scanner", "nfc_manager")) { + pluginManager.apply("org.jetbrains.kotlin.android") + tasks.withType().configureEach { + compilerOptions.jvmTarget.set( + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + ) + } + } +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/mobile-app/app/android/build/reports/problems/problems-report.html b/mobile-app/app/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..0c57573 --- /dev/null +++ b/mobile-app/app/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/mobile-app/app/android/gradle.properties b/mobile-app/app/android/gradle.properties new file mode 100644 index 0000000..b7ecb1f --- /dev/null +++ b/mobile-app/app/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false +kotlin.incremental=false diff --git a/mobile-app/app/android/gradle/wrapper/gradle-wrapper.properties b/mobile-app/app/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/mobile-app/app/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/mobile-app/app/android/settings.gradle.kts b/mobile-app/app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/mobile-app/app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/mobile-app/app/images/background.jpg b/mobile-app/app/images/background.jpg new file mode 100644 index 0000000..8812576 Binary files /dev/null and b/mobile-app/app/images/background.jpg differ diff --git a/mobile-app/app/images/openparcelbox-logo-256.png b/mobile-app/app/images/openparcelbox-logo-256.png new file mode 100644 index 0000000..e06db11 Binary files /dev/null and b/mobile-app/app/images/openparcelbox-logo-256.png differ diff --git a/mobile-app/app/lib/app_strings.dart b/mobile-app/app/lib/app_strings.dart new file mode 100644 index 0000000..75e9204 --- /dev/null +++ b/mobile-app/app/lib/app_strings.dart @@ -0,0 +1,157 @@ +import 'package:flutter/widgets.dart'; + +class AppStrings { + AppStrings(this.locale); + + final Locale locale; + + bool get isFrench => locale.languageCode == 'fr'; + + String text(String key) => (_values[key] ?? const {})[ + isFrench ? 'fr' : 'en'] ?? + key; + + static const Map> _values = + >{ + 'add_box': { + 'fr': 'Ajouter une nouvelle OpenParcelBox', + 'en': 'Add a new OpenParcelBox', + }, + 'restore_backup': { + 'fr': 'Restaurer une sauvegarde', + 'en': 'Restore a backup', + }, + 'open': {'fr': 'Ouvrir', 'en': 'Open'}, + 'history': {'fr': 'Historique', 'en': 'History'}, + 'permanent_codes': { + 'fr': 'Codes permanents', + 'en': 'Permanent codes', + }, + 'temporary_codes': { + 'fr': 'Codes temporaires', + 'en': 'Temporary codes', + }, + 'nfc_tags': {'fr': 'Tags NFC', 'en': 'NFC tags'}, + 'guests': {'fr': 'Invités', 'en': 'Guests'}, + 'settings': {'fr': 'Paramètres', 'en': 'Settings'}, + 'close': {'fr': 'Fermer', 'en': 'Close'}, + 'add': {'fr': 'Ajouter', 'en': 'Add'}, + 'delete': {'fr': 'Supprimer', 'en': 'Delete'}, + 'edit': {'fr': 'Modifier', 'en': 'Edit'}, + 'cancel': {'fr': 'Annuler', 'en': 'Cancel'}, + 'save': {'fr': 'Enregistrer', 'en': 'Save'}, + 'scan': {'fr': 'Scanner', 'en': 'Scan'}, + 'scan_qr': { + 'fr': 'Scanner un QR code invité', + 'en': 'Scan a guest QR code', + }, + 'box_name': { + 'fr': 'Identifiant de la box', + 'en': 'Box identifier', + }, + 'nearby_boxes': { + 'fr': 'OpenParcelBox détectées', + 'en': 'Detected OpenParcelBox devices', + }, + 'search': {'fr': 'Rechercher', 'en': 'Search'}, + 'no_results': { + 'fr': 'Aucune OpenParcelBox détectée.', + 'en': 'No OpenParcelBox detected.', + }, + 'connected': {'fr': 'Connectée', 'en': 'Connected'}, + 'disconnected': {'fr': 'Déconnectée', 'en': 'Disconnected'}, + 'connecting': {'fr': 'Connexion…', 'en': 'Connecting…'}, + 'no_code': {'fr': 'Aucun code.', 'en': 'No code.'}, + 'new_code': {'fr': 'Générer un code', 'en': 'Generate a code'}, + 'code_limit': { + 'fr': 'La limite de codes est atteinte.', + 'en': 'The code limit has been reached.', + }, + 'no_history': { + 'fr': 'Aucune ouverture enregistrée.', + 'en': 'No opening has been recorded.', + }, + 'opened_by_permanent': { + 'fr': 'Code permanent', + 'en': 'Permanent code', + }, + 'opened_by_temporary': { + 'fr': 'Code temporaire', + 'en': 'Temporary code', + }, + 'opened_by_nfc': {'fr': 'Tag NFC', 'en': 'NFC tag'}, + 'opened_by_app': {'fr': 'Application', 'en': 'Application'}, + 'tag_name': {'fr': 'Nom du tag', 'en': 'Tag name'}, + 'tag_uid': {'fr': 'UID du tag', 'en': 'Tag UID'}, + 'scan_phone_nfc': { + 'fr': 'Scanner avec le téléphone', + 'en': 'Scan with phone', + }, + 'pair_on_box': { + 'fr': 'Appairer sur la box', + 'en': 'Pair on the box', + }, + 'manual_uid': {'fr': 'Ajouter un UID', 'en': 'Add a UID'}, + 'nfc_unavailable': { + 'fr': 'Le NFC est indisponible sur ce téléphone.', + 'en': 'NFC is unavailable on this phone.', + }, + 'hold_tag': { + 'fr': 'Approchez un tag NFC du téléphone…', + 'en': 'Hold an NFC tag near the phone…', + }, + 'guest_name': {'fr': "Nom de l'invité", 'en': 'Guest name'}, + 'show_qr': {'fr': 'Voir le QR code', 'en': 'Show QR code'}, + 'no_guest': {'fr': 'Aucun invité.', 'en': 'No guest.'}, + 'language': {'fr': 'Langue', 'en': 'Language'}, + 'automatic': {'fr': 'Automatique', 'en': 'Automatic'}, + 'french': {'fr': 'Français', 'en': 'French'}, + 'english': {'fr': 'Anglais', 'en': 'English'}, + 'backup': { + 'fr': "Sauvegarder les paramètres de l'app", + 'en': 'Back up app settings', + }, + 'backup_location': { + 'fr': "Choisir l'emplacement de sauvegarde", + 'en': 'Choose backup location', + }, + 'restore': { + 'fr': 'Restaurer une sauvegarde', + 'en': 'Restore a backup', + }, + 'password': {'fr': 'Mot de passe', 'en': 'Password'}, + 'firmware_update': { + 'fr': 'Mise à jour du firmware (à venir)', + 'en': 'Firmware update (coming later)', + }, + 'factory_reset': { + 'fr': "Réinitialiser la box", + 'en': 'Factory-reset the box', + }, + 'forget_box': {'fr': 'Supprimer la box', 'en': 'Forget the box'}, + 'danger': {'fr': 'Danger', 'en': 'Danger'}, + 'factory_warning': { + 'fr': + "Cette action supprime tous les paramètres de la box et autorise l'appairage d'un nouvel administrateur.", + 'en': + 'This deletes every setting on the box and allows a new administrator to pair.', + }, + 'forget_warning': { + 'fr': + "Seules les informations de l'application seront supprimées. Les paramètres de la box restent inchangés et un administrateur peut perdre définitivement l'accès.", + 'en': + 'Only app information will be deleted. Box settings remain unchanged and an administrator may permanently lose access.', + }, + 'confirm': {'fr': 'Confirmer', 'en': 'Confirm'}, + 'administrator': {'fr': 'Administrateur', 'en': 'Administrator'}, + 'guest': {'fr': 'Invité', 'en': 'Guest'}, + 'backup_done': { + 'fr': 'Sauvegarde chiffrée créée.', + 'en': 'Encrypted backup created.', + }, + 'restore_done': { + 'fr': 'Sauvegarde restaurée.', + 'en': 'Backup restored.', + }, + }; +} diff --git a/mobile-app/app/lib/backup_service.dart b/mobile-app/app/lib/backup_service.dart new file mode 100644 index 0000000..43657a4 --- /dev/null +++ b/mobile-app/app/lib/backup_service.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:file_picker/file_picker.dart'; + +import 'models.dart'; + +class BackupService { + static const _format = 'openparcelbox-backup-v1'; + final Random _random = Random.secure(); + + Future exportBoxes( + List boxes, + String password, { + String? initialDirectory, + }) async { + if (password.isEmpty) { + throw ArgumentError('A backup password is required.'); + } + final salt = _randomBytes(16); + final nonce = _randomBytes(12); + final key = await _deriveKey(password, salt); + final clearText = utf8.encode( + jsonEncode({ + 'format': _format, + 'boxes': boxes.map((box) => box.toJson()).toList(), + }), + ); + final secretBox = await AesGcm.with256bits().encrypt( + clearText, + secretKey: key, + nonce: nonce, + ); + final envelope = utf8.encode( + jsonEncode({ + 'format': _format, + 'salt': base64Encode(salt), + 'nonce': base64Encode(secretBox.nonce), + 'ciphertext': base64Encode(secretBox.cipherText), + 'mac': base64Encode(secretBox.mac.bytes), + }), + ); + final path = await FilePicker.saveFile( + dialogTitle: 'OpenParcelBox backup', + fileName: 'openparcelbox-backup.opb', + initialDirectory: initialDirectory, + type: FileType.custom, + allowedExtensions: const ['opb'], + bytes: Uint8List.fromList(envelope), + ); + return path != null; + } + + Future pickBackupFile() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: const ['opb'], + withData: true, + ); + return result?.files.single.bytes; + } + + Future> importBoxes( + Uint8List bytes, + String password, + ) async { + if (password.isEmpty) { + throw ArgumentError('A backup password is required.'); + } + final envelope = jsonDecode(utf8.decode(bytes)); + if (envelope is! Map || envelope['format'] != _format) { + throw const FormatException('Unsupported OpenParcelBox backup.'); + } + final salt = base64Decode(envelope['salt']! as String); + final nonce = base64Decode(envelope['nonce']! as String); + final cipherText = base64Decode(envelope['ciphertext']! as String); + final mac = Mac(base64Decode(envelope['mac']! as String)); + final clearText = await AesGcm.with256bits().decrypt( + SecretBox(cipherText, nonce: nonce, mac: mac), + secretKey: await _deriveKey(password, salt), + ); + final decoded = jsonDecode(utf8.decode(clearText)); + if (decoded is! Map || decoded['format'] != _format) { + throw const FormatException('Invalid OpenParcelBox backup.'); + } + final boxes = decoded['boxes']; + if (boxes is! List) { + throw const FormatException('The backup does not contain boxes.'); + } + return boxes + .whereType>() + .map((item) => SavedBox.fromJson(item.cast())) + .toList(); + } + + Future _deriveKey(String password, List salt) { + return Pbkdf2( + macAlgorithm: Hmac.sha256(), + iterations: 210000, + bits: 256, + ).deriveKey(secretKey: SecretKey(utf8.encode(password)), nonce: salt); + } + + Uint8List _randomBytes(int length) => + Uint8List.fromList(List.generate(length, (_) => _random.nextInt(256))); +} diff --git a/mobile-app/app/lib/ble_controller.dart b/mobile-app/app/lib/ble_controller.dart new file mode 100644 index 0000000..fc879fc --- /dev/null +++ b/mobile-app/app/lib/ble_controller.dart @@ -0,0 +1,491 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +import 'models.dart'; +import 'secure_box_store.dart'; + +const _serviceUuid = 'f2a00000-8e7a-4f8d-9b1d-7d8e4b7a0001'; +const _commandUuid = 'f2a00001-8e7a-4f8d-9b1d-7d8e4b7a0001'; +const _stateUuid = 'f2a00002-8e7a-4f8d-9b1d-7d8e4b7a0001'; + +class OpenParcelBoxCommand { + const OpenParcelBoxCommand(this.name, [this.payload = const {}]); + + final String name; + final Map payload; + + List encode([String? identityKey]) => utf8.encode( + jsonEncode({ + 'command': name, + ...payload, + 'identity_key': ?identityKey, + }), + ); +} + +class OpenParcelBoxBleController extends ChangeNotifier { + OpenParcelBoxBleController({SecureBoxStore? store}) + : store = store ?? SecureBoxStore(); + + final SecureBoxStore store; + final List savedBoxes = []; + final List scanResults = []; + final List permanentCodes = []; + final List oneTimeCodes = []; + final List nfcTags = []; + final List history = []; + final List guests = []; + + SavedBox? currentBox; + BluetoothDevice? connectedDevice; + BluetoothCharacteristic? _commandCharacteristic; + BluetoothCharacteristic? _stateCharacteristic; + StreamSubscription>? _scanSubscription; + StreamSubscription? _connectionSubscription; + bool initialized = false; + bool isScanning = false; + bool isBusy = false; + bool isAuthenticated = false; + String status = 'disconnected'; + String? errorMessage; + + bool get hasBox => savedBoxes.isNotEmpty; + bool get isConnected => + connectedDevice != null && _commandCharacteristic != null; + bool get isAdministrator => + currentBox?.role == BoxRole.administrator && isAuthenticated; + + Future initialize() async { + try { + savedBoxes + ..clear() + ..addAll(await store.loadBoxes()); + if (savedBoxes.isNotEmpty) { + currentBox = savedBoxes.first; + await connectSavedBox(); + } + } catch (error) { + errorMessage = '$error'; + } finally { + initialized = true; + notifyListeners(); + } + } + + Future startScan() async { + _setBusy(true); + try { + scanResults.clear(); + errorMessage = null; + status = 'scanning'; + if (!await FlutterBluePlus.isSupported) { + throw StateError('Bluetooth is not supported on this phone.'); + } + _scanSubscription ??= FlutterBluePlus.scanResults.listen((results) { + scanResults + ..clear() + ..addAll(results.where(_looksLikeOpenParcelBox)); + notifyListeners(); + }); + await FlutterBluePlus.adapterState + .where((state) => state == BluetoothAdapterState.on) + .first + .timeout(const Duration(seconds: 8)); + isScanning = true; + notifyListeners(); + await FlutterBluePlus.startScan( + withServices: [Guid(_serviceUuid)], + timeout: const Duration(seconds: 8), + ); + } catch (error) { + errorMessage = '$error'; + } finally { + isScanning = false; + status = isConnected ? 'connected' : 'disconnected'; + _setBusy(false); + } + } + + Future registerAdministrator(ScanResult result, String boxName) async { + final cleanedName = boxName.trim(); + if (cleanedName.isEmpty) { + throw ArgumentError('A box identifier is required.'); + } + _setBusy(true); + try { + await _connectDevice(result.device); + final publicState = await _readState(updateCollections: false); + if (publicState?['admin_exists'] == true) { + throw StateError('This box already has an administrator.'); + } + final key = _generateIdentityKey(); + await _write( + OpenParcelBoxCommand('provision_admin', { + 'identity_key': key, + 'name': 'Administrator', + 'box_name': cleanedName, + }), + authenticate: false, + ); + final saved = SavedBox( + remoteId: result.device.remoteId.str, + name: cleanedName, + identityKey: key, + role: BoxRole.administrator, + ); + await _upsertSavedBox(saved); + currentBox = saved; + isAuthenticated = true; + await syncClock(); + } finally { + _setBusy(false); + } + } + + Future importGuestInvitation(String rawPayload) async { + final decoded = jsonDecode(rawPayload); + if (decoded is! Map || + decoded['format'] != 'openparcelbox-invite-v1' || + decoded['remote_id'] is! String || + decoded['box_name'] is! String || + decoded['guest_key'] is! String) { + throw const FormatException('Invalid OpenParcelBox invitation.'); + } + final saved = SavedBox( + remoteId: decoded['remote_id'] as String, + name: decoded['box_name'] as String, + identityKey: decoded['guest_key'] as String, + role: BoxRole.guest, + ); + await _upsertSavedBox(saved); + currentBox = saved; + await connectSavedBox(); + } + + Future connectSavedBox() async { + final box = currentBox; + if (box == null) { + return; + } + _setBusy(true); + try { + await _connectDevice(BluetoothDevice.fromId(box.remoteId)); + await _write(const OpenParcelBoxCommand('authenticate')); + isAuthenticated = true; + await syncClock(); + } catch (error) { + isAuthenticated = false; + errorMessage = '$error'; + status = 'disconnected'; + } finally { + _setBusy(false); + } + } + + Future _connectDevice(BluetoothDevice device) async { + status = 'connecting'; + errorMessage = null; + notifyListeners(); + await device.connect( + license: License.nonprofit, + timeout: const Duration(seconds: 12), + ); + _connectionSubscription?.cancel(); + _connectionSubscription = device.connectionState.listen((state) { + if (state == BluetoothConnectionState.disconnected) { + connectedDevice = null; + _commandCharacteristic = null; + _stateCharacteristic = null; + isAuthenticated = false; + status = 'disconnected'; + notifyListeners(); + } + }); + final services = await device.discoverServices(); + _commandCharacteristic = _findCharacteristic(services, _commandUuid); + _stateCharacteristic = _findCharacteristic(services, _stateUuid); + if (_commandCharacteristic == null || _stateCharacteristic == null) { + throw StateError('OpenParcelBox BLE characteristics were not found.'); + } + connectedDevice = device; + status = 'connected'; + } + + BluetoothCharacteristic? _findCharacteristic( + List services, + String uuid, + ) { + for (final service in services) { + if (service.uuid.str.toLowerCase() != _serviceUuid) { + continue; + } + for (final characteristic in service.characteristics) { + if (characteristic.uuid.str.toLowerCase() == uuid) { + return characteristic; + } + } + } + return null; + } + + bool _looksLikeOpenParcelBox(ScanResult result) { + final name = result.advertisementData.advName.isNotEmpty + ? result.advertisementData.advName + : result.device.platformName; + return name.toUpperCase().startsWith('OPB-') || + name.toLowerCase().contains('openparcelbox') || + result.advertisementData.serviceUuids.any( + (uuid) => uuid.str.toLowerCase() == _serviceUuid, + ); + } + + Future syncClock() async { + final now = DateTime.now(); + await send( + OpenParcelBoxCommand('sync_clock', { + 'unix_ms': now.toUtc().millisecondsSinceEpoch, + 'timezone_offset_minutes': now.timeZoneOffset.inMinutes, + }), + ); + } + + Future openLock() => send(const OpenParcelBoxCommand('open_lock')); + + Future addGeneratedCode(CredentialKind kind) => send( + OpenParcelBoxCommand('add_code', { + 'code': _generateCode(), + 'kind': kind == CredentialKind.oneTime ? 'one_time' : 'permanent', + }), + ); + + Future removeCode(AccessCode code) => send( + OpenParcelBoxCommand('remove_code', {'code': code.code}), + ); + + Future addNfcTag(String uid, String name) => send( + OpenParcelBoxCommand('add_nfc_tag', { + 'uid': uid.trim(), + 'name': name.trim(), + }), + ); + + Future startNfcEnrollment(String name) => send( + OpenParcelBoxCommand('start_nfc_enrollment', { + 'name': name.trim(), + }), + ); + + Future removeNfcTag(NfcTag tag) => send( + OpenParcelBoxCommand('remove_nfc_tag', {'uid': tag.uid}), + ); + + Future addGuest(String name) async { + final key = _generateIdentityKey(); + await send( + OpenParcelBoxCommand('add_guest', { + 'name': name.trim(), + 'guest_key': key, + }), + ); + return key; + } + + Future removeGuest(GuestIdentity guest) => send( + OpenParcelBoxCommand('remove_guest', { + 'guest_key': guest.key, + }), + ); + + String invitationPayload(GuestIdentity guest) => jsonEncode({ + 'format': 'openparcelbox-invite-v1', + 'remote_id': currentBox!.remoteId, + 'box_name': currentBox!.name, + 'guest_name': guest.name, + 'guest_key': guest.key, + }); + + Future factoryReset() async { + await send(const OpenParcelBoxCommand('factory_reset'), refresh: false); + await forgetCurrentBox(); + } + + Future forgetCurrentBox() async { + final box = currentBox; + if (box == null) { + return; + } + savedBoxes.removeWhere((item) => item.remoteId == box.remoteId); + await store.saveBoxes(savedBoxes); + await connectedDevice?.disconnect(); + currentBox = savedBoxes.firstOrNull; + _clearState(); + notifyListeners(); + } + + Future replaceSavedBoxes(List boxes) async { + savedBoxes + ..clear() + ..addAll(boxes); + await store.saveBoxes(savedBoxes); + currentBox = savedBoxes.firstOrNull; + notifyListeners(); + if (currentBox != null) { + await connectSavedBox(); + } + } + + Future send(OpenParcelBoxCommand command, {bool refresh = true}) async { + _setBusy(true); + try { + await _write(command); + if (refresh) { + await _readState(); + } + } catch (error) { + errorMessage = '$error'; + rethrow; + } finally { + _setBusy(false); + } + } + + Future _write( + OpenParcelBoxCommand command, { + bool authenticate = true, + }) async { + final characteristic = _commandCharacteristic; + if (characteristic == null) { + throw StateError('The box is not connected.'); + } + final key = authenticate ? currentBox?.identityKey : null; + if (authenticate && key == null) { + throw StateError('No secure identity is available.'); + } + await characteristic.write(command.encode(key), withoutResponse: false); + } + + Future?> _readState({ + bool updateCollections = true, + }) async { + final raw = await _stateCharacteristic?.read(); + if (raw == null) { + return null; + } + final decoded = jsonDecode(utf8.decode(raw)); + if (decoded is! Map) { + return null; + } + if (updateCollections) { + _applyState(decoded); + } + return decoded; + } + + void _applyState(Map state) { + permanentCodes.clear(); + oneTimeCodes.clear(); + nfcTags.clear(); + history.clear(); + guests.clear(); + for (final item in (state['codes'] as List? ?? const [])) { + if (item is! Map || + item['code'] is! String || + item['kind'] is! String) { + continue; + } + final kind = item['kind'] == 'one_time' + ? CredentialKind.oneTime + : CredentialKind.permanent; + final code = AccessCode( + id: 'slot-${item['slot']}', + code: item['code'] as String, + kind: kind, + ); + (kind == CredentialKind.oneTime ? oneTimeCodes : permanentCodes).add( + code, + ); + } + for (final item in (state['nfc_tags'] as List? ?? const [])) { + if (item is Map && item['uid'] is String) { + nfcTags.add( + NfcTag( + id: 'slot-${item['slot']}', + uid: item['uid'] as String, + name: item['name'] as String? ?? '', + ), + ); + } + } + for (final item in (state['history'] as List? ?? const [])) { + if (item is Map && item['unix_ms'] is int) { + history.add( + OpeningEvent( + date: DateTime.fromMillisecondsSinceEpoch( + item['unix_ms'] as int, + isUtc: true, + ).toLocal(), + kind: item['kind'] as int? ?? 0, + actor: item['actor'] as String? ?? '', + ), + ); + } + } + for (final item in (state['guests'] as List? ?? const [])) { + if (item is Map && + item['name'] is String && + item['key'] is String) { + guests.add( + GuestIdentity( + name: item['name'] as String, + key: item['key'] as String, + ), + ); + } + } + notifyListeners(); + } + + Future _upsertSavedBox(SavedBox box) async { + savedBoxes.removeWhere((item) => item.remoteId == box.remoteId); + savedBoxes.insert(0, box); + await store.saveBoxes(savedBoxes); + } + + void _clearState() { + connectedDevice = null; + _commandCharacteristic = null; + _stateCharacteristic = null; + isAuthenticated = false; + permanentCodes.clear(); + oneTimeCodes.clear(); + nfcTags.clear(); + history.clear(); + guests.clear(); + status = 'disconnected'; + } + + String _generateCode() => + List.generate(6, (_) => Random.secure().nextInt(10)).join(); + + String _generateIdentityKey() => + List.generate(16, (_) => Random.secure().nextInt(256)) + .map((value) => value.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(); + + void _setBusy(bool value) { + isBusy = value; + notifyListeners(); + } + + @override + void dispose() { + _scanSubscription?.cancel(); + _connectionSubscription?.cancel(); + super.dispose(); + } +} diff --git a/mobile-app/app/lib/main.dart b/mobile-app/app/lib/main.dart new file mode 100644 index 0000000..a156687 --- /dev/null +++ b/mobile-app/app/lib/main.dart @@ -0,0 +1,1325 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:nfc_manager/nfc_manager.dart' hide NfcTag; +import 'package:nfc_manager/nfc_manager_android.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'app_strings.dart'; +import 'backup_service.dart'; +import 'ble_controller.dart'; +import 'models.dart'; +import 'secure_box_store.dart'; + +const _logoAsset = 'images/openparcelbox-logo-256.png'; +const _background = Color(0xFF333333); +const _surface = Color(0xFF292929); +const _modalSurface = Color(0xFF303030); +const _accent = Color(0xFFC19D60); +const _text = Color(0xFFC6C6C6); + +void main() { + FlutterBluePlus.setOperationQueueMode(OperationQueueMode.perDevice); + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({this.store, this.backupService, super.key}); + + final SecureBoxStore? store; + final BackupService? backupService; + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + late final SecureBoxStore _store = widget.store ?? SecureBoxStore(); + Locale? _locale; + bool _languageLoaded = false; + + @override + void initState() { + super.initState(); + _loadLanguage(); + } + + Future _loadLanguage() async { + try { + final language = await _store.loadLanguage(); + _locale = language == null ? null : Locale(language); + } catch (_) { + _locale = null; + } + if (mounted) { + setState(() => _languageLoaded = true); + } + } + + Future _changeLanguage(String? language) async { + await _store.saveLanguage(language); + setState(() => _locale = language == null ? null : Locale(language)); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'OpenParcelBox', + locale: _locale, + supportedLocales: const [Locale('en'), Locale('fr')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + localeResolutionCallback: (locale, supported) => + locale?.languageCode == 'fr' + ? const Locale('fr') + : const Locale('en'), + theme: ThemeData( + brightness: Brightness.dark, + scaffoldBackgroundColor: _background, + colorScheme: const ColorScheme.dark( + primary: _accent, + secondary: _accent, + surface: _surface, + onPrimary: Color(0xFF1F1F1F), + onSurface: _text, + error: Color(0xFFE06B6B), + ), + textTheme: ThemeData.dark().textTheme.apply( + bodyColor: _text, + displayColor: _accent, + ), + dialogTheme: const DialogThemeData(backgroundColor: _modalSurface), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF252525), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: _accent), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: _accent, + foregroundColor: const Color(0xFF1F1F1F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15), + ), + ), + useMaterial3: true, + ), + home: _languageLoaded + ? OpenParcelBoxHome( + store: _store, + backupService: widget.backupService, + selectedLanguage: _locale?.languageCode, + onLanguageChanged: _changeLanguage, + ) + : const ColoredBox(color: _background), + ); + } +} + +class OpenParcelBoxHome extends StatefulWidget { + const OpenParcelBoxHome({ + required this.store, + this.backupService, + required this.selectedLanguage, + required this.onLanguageChanged, + super.key, + }); + + final SecureBoxStore store; + final BackupService? backupService; + final String? selectedLanguage; + final ValueChanged onLanguageChanged; + + @override + State createState() => _OpenParcelBoxHomeState(); +} + +class _OpenParcelBoxHomeState extends State { + late final OpenParcelBoxBleController _controller = + OpenParcelBoxBleController(store: widget.store); + late final BackupService _backupService = + widget.backupService ?? BackupService(); + + AppStrings get strings => AppStrings(Localizations.localeOf(context)); + + @override + void initState() { + super.initState(); + _controller + ..addListener(_refresh) + ..initialize(); + } + + void _refresh() { + if (mounted) { + setState(() {}); + } + } + + @override + void dispose() { + _controller + ..removeListener(_refresh) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_controller.initialized) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + return Scaffold( + body: SafeArea( + child: Stack( + children: [ + if (!_controller.hasBox) + _EmptyHome( + addLabel: strings.text('add_box'), + restoreLabel: strings.text('restore_backup'), + onAdd: _showAddBox, + onRestore: _restoreBackup, + ) + else + _Dashboard( + controller: _controller, + strings: strings, + onHistory: _showHistory, + onCodes: _showCodes, + onNfc: _showNfc, + onGuests: _showGuests, + onSettings: _showSettings, + ), + if (_controller.isBusy) + const Positioned( + left: 0, + right: 0, + top: 0, + child: LinearProgressIndicator(), + ), + ], + ), + ), + ); + } + + Future _showAddBox() async { + var boxName = ''; + await showOpbModal( + context, + title: strings.text('add_box'), + child: StatefulBuilder( + builder: (context, setModalState) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + maxLength: 31, + onChanged: (value) => boxName = value, + decoration: InputDecoration(labelText: strings.text('box_name')), + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: () async { + final payload = await _scanQrCode(); + if (payload == null) { + return; + } + try { + await _controller.importGuestInvitation(payload); + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (error) { + if (context.mounted) { + _showMessage('$error'); + } + } + }, + icon: const Icon(Icons.qr_code_scanner), + label: Text(strings.text('scan_qr')), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text( + strings.text('nearby_boxes'), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: _accent, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton.filledTonal( + onPressed: _controller.isScanning + ? null + : () async { + await _controller.startScan(); + setModalState(() {}); + }, + icon: const Icon(Icons.radar), + tooltip: strings.text('search'), + ), + ], + ), + const SizedBox(height: 8), + if (_controller.scanResults.isEmpty) + _EmptyRow(text: strings.text('no_results')) + else + ..._controller.scanResults.map( + (result) => _DataRowCard( + icon: Icons.inventory_2_outlined, + title: _deviceName(result), + subtitle: result.device.remoteId.str, + actions: [ + IconButton( + onPressed: () async { + try { + await _controller.registerAdministrator( + result, + boxName, + ); + if (context.mounted) { + Navigator.of(context).pop(); + } + } catch (error) { + _showMessage('$error'); + } + }, + icon: const Icon(Icons.link), + color: _accent, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _scanQrCode() { + bool handled = false; + return showDialog( + context: context, + builder: (context) => Dialog( + backgroundColor: _modalSurface, + child: SizedBox( + height: 420, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + children: [ + MobileScanner( + onDetect: (capture) { + if (handled || capture.barcodes.isEmpty) { + return; + } + final value = capture.barcodes.first.rawValue; + if (value != null) { + handled = true; + Navigator.of(context).pop(value); + } + }, + ), + Positioned( + right: 12, + top: 12, + child: IconButton.filled( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Future _showHistory() => showOpbModal( + context, + title: strings.text('history'), + child: _controller.history.isEmpty + ? _EmptyRow(text: strings.text('no_history')) + : Column( + children: _controller.history.reversed + .map( + (event) => _DataRowCard( + icon: Icons.lock_open, + title: _formatDate(event.date), + subtitle: _historyLabel(event), + ), + ) + .toList(), + ), + ); + + Future _showCodes(CredentialKind kind) { + final codes = kind == CredentialKind.permanent + ? _controller.permanentCodes + : _controller.oneTimeCodes; + final limit = kind == CredentialKind.permanent ? 8 : 20; + return showOpbModal( + context, + title: strings.text( + kind == CredentialKind.permanent + ? 'permanent_codes' + : 'temporary_codes', + ), + child: StatefulBuilder( + builder: (context, setModalState) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: codes.length >= limit + ? null + : () async { + await _guarded(() => _controller.addGeneratedCode(kind)); + setModalState(() {}); + }, + icon: const Icon(Icons.add), + label: Text( + '${strings.text('new_code')} (${codes.length}/$limit)', + ), + ), + if (codes.length >= limit) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(strings.text('code_limit')), + ), + const SizedBox(height: 12), + if (codes.isEmpty) + _EmptyRow(text: strings.text('no_code')) + else + ...codes.map( + (code) => _DataRowCard( + icon: Icons.password, + title: code.code, + actions: [ + IconButton( + onPressed: () async { + await _guarded(() => _controller.removeCode(code)); + setModalState(() {}); + }, + icon: const Icon(Icons.delete_outline), + tooltip: strings.text('delete'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _showNfc() => showOpbModal( + context, + title: strings.text('nfc_tags'), + child: StatefulBuilder( + builder: (context, setModalState) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: () => _scanNfcWithPhone(setModalState), + icon: const Icon(Icons.nfc), + label: Text(strings.text('scan_phone_nfc')), + ), + FilledButton.tonalIcon( + onPressed: () => _addNfcFromBox(setModalState), + icon: const Icon(Icons.sensors), + label: Text(strings.text('pair_on_box')), + ), + FilledButton.tonalIcon( + onPressed: () => _addManualNfc(setModalState), + icon: const Icon(Icons.keyboard), + label: Text(strings.text('manual_uid')), + ), + ], + ), + const SizedBox(height: 12), + if (_controller.nfcTags.isEmpty) + _EmptyRow(text: strings.text('nfc_tags')) + else + ..._controller.nfcTags.map( + (tag) => _DataRowCard( + icon: Icons.contactless, + title: tag.name.isEmpty ? tag.uid : tag.name, + subtitle: tag.name.isEmpty ? null : tag.uid, + actions: [ + IconButton( + onPressed: () => _renameNfc(tag, setModalState), + icon: const Icon(Icons.edit_outlined), + tooltip: strings.text('edit'), + ), + IconButton( + onPressed: () async { + await _guarded(() => _controller.removeNfcTag(tag)); + setModalState(() {}); + }, + icon: const Icon(Icons.delete_outline), + tooltip: strings.text('delete'), + ), + ], + ), + ), + ], + ), + ), + ); + + Future _scanNfcWithPhone(StateSetter refreshModal) async { + final availability = await NfcManager.instance.checkAvailability(); + if (availability != NfcAvailability.enabled) { + _showMessage(strings.text('nfc_unavailable')); + return; + } + _showMessage(strings.text('hold_tag')); + await NfcManager.instance.startSession( + pollingOptions: const { + NfcPollingOption.iso14443, + NfcPollingOption.iso15693, + }, + onDiscovered: (tag) async { + final androidTag = Platform.isAndroid ? NfcTagAndroid.from(tag) : null; + final uid = androidTag?.id + .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(':'); + await NfcManager.instance.stopSession(); + if (uid != null && mounted) { + final name = await _askText(strings.text('tag_name')); + if (name != null) { + await _guarded(() => _controller.addNfcTag(uid, name)); + refreshModal(() {}); + } + } + }, + ); + } + + Future _addNfcFromBox(StateSetter refreshModal) async { + final name = await _askText(strings.text('tag_name')); + if (name != null) { + await _guarded(() => _controller.startNfcEnrollment(name)); + refreshModal(() {}); + } + } + + Future _addManualNfc(StateSetter refreshModal) async { + final uid = await _askText(strings.text('tag_uid')); + if (uid == null) { + return; + } + final name = await _askText(strings.text('tag_name')); + if (name != null) { + await _guarded(() => _controller.addNfcTag(uid, name)); + refreshModal(() {}); + } + } + + Future _renameNfc(NfcTag tag, StateSetter refreshModal) async { + final name = await _askText( + strings.text('tag_name'), + initialValue: tag.name, + ); + if (name != null) { + await _guarded(() => _controller.addNfcTag(tag.uid, name)); + refreshModal(() {}); + } + } + + Future _showGuests() => showOpbModal( + context, + title: strings.text('guests'), + child: StatefulBuilder( + builder: (context, setModalState) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + onPressed: () async { + final name = await _askText(strings.text('guest_name')); + if (name == null || name.trim().isEmpty) { + return; + } + final key = await _controller.addGuest(name); + final guest = GuestIdentity(name: name.trim(), key: key); + if (context.mounted) { + await _showGuestQr(guest); + setModalState(() {}); + } + }, + icon: const Icon(Icons.person_add_alt_1), + label: Text(strings.text('add')), + ), + const SizedBox(height: 12), + if (_controller.guests.isEmpty) + _EmptyRow(text: strings.text('no_guest')) + else + ..._controller.guests.map( + (guest) => _DataRowCard( + icon: Icons.person_outline, + title: guest.name, + actions: [ + IconButton( + onPressed: () => _showGuestQr(guest), + icon: const Icon(Icons.qr_code), + tooltip: strings.text('show_qr'), + ), + IconButton( + onPressed: () async { + await _guarded(() => _controller.removeGuest(guest)); + setModalState(() {}); + }, + icon: const Icon(Icons.delete_outline), + tooltip: strings.text('delete'), + ), + ], + ), + ), + ], + ), + ), + ); + + Future _showGuestQr(GuestIdentity guest) => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(guest.name, style: const TextStyle(color: _accent)), + content: SizedBox.square( + dimension: 260, + child: ColoredBox( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: QrImageView( + data: _controller.invitationPayload(guest), + backgroundColor: Colors.white, + dataModuleStyle: const QrDataModuleStyle( + color: Colors.black, + dataModuleShape: QrDataModuleShape.square, + ), + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(strings.text('close')), + ), + ], + ), + ); + + Future _showSettings() => showOpbModal( + context, + title: strings.text('settings'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: widget.selectedLanguage ?? 'auto', + decoration: InputDecoration(labelText: strings.text('language')), + items: >[ + DropdownMenuItem( + value: 'auto', + child: Text(strings.text('automatic')), + ), + DropdownMenuItem(value: 'fr', child: Text(strings.text('french'))), + DropdownMenuItem(value: 'en', child: Text(strings.text('english'))), + ], + onChanged: (value) => + widget.onLanguageChanged(value == 'auto' ? null : value), + ), + const SizedBox(height: 12), + _SettingsButton( + icon: Icons.system_update, + label: strings.text('firmware_update'), + onPressed: null, + ), + _SettingsButton( + icon: Icons.folder_outlined, + label: strings.text('backup_location'), + onPressed: _chooseBackupDirectory, + ), + _SettingsButton( + icon: Icons.backup_outlined, + label: strings.text('backup'), + onPressed: _createBackup, + ), + _SettingsButton( + icon: Icons.restore, + label: strings.text('restore'), + onPressed: _restoreBackup, + ), + if (_controller.isAdministrator) ...[ + const Divider(height: 28), + _SettingsButton( + icon: Icons.warning_amber_rounded, + label: strings.text('factory_reset'), + danger: true, + onPressed: _confirmFactoryReset, + ), + ], + const Divider(height: 28), + _SettingsButton( + icon: Icons.delete_forever_outlined, + label: strings.text('forget_box'), + danger: true, + onPressed: _confirmForgetBox, + ), + ], + ), + ); + + Future _chooseBackupDirectory() async { + final path = await FilePicker.getDirectoryPath(); + if (path != null) { + await widget.store.saveBackupDirectory(path); + _showMessage(path); + } + } + + Future _createBackup() async { + final password = await _askPassword(); + if (password == null) { + return; + } + try { + if (await _backupService.exportBoxes( + _controller.savedBoxes, + password, + initialDirectory: await widget.store.loadBackupDirectory(), + )) { + _showMessage(strings.text('backup_done')); + } + } catch (error) { + _showMessage('$error'); + } + } + + Future _restoreBackup() async { + try { + final backup = await _backupService.pickBackupFile(); + if (backup == null || !mounted) { + return; + } + final password = await _askPassword(); + if (password == null) { + return; + } + final boxes = await _backupService.importBoxes(backup, password); + await _controller.replaceSavedBoxes(boxes); + _showMessage(strings.text('restore_done')); + } catch (error) { + _showMessage('$error'); + } + } + + Future _confirmFactoryReset() async { + if (await _dangerConfirmation(strings.text('factory_warning'))) { + await _guarded(_controller.factoryReset); + if (mounted) { + Navigator.of(context).pop(); + } + } + } + + Future _confirmForgetBox() async { + if (await _dangerConfirmation(strings.text('forget_warning'))) { + await _controller.forgetCurrentBox(); + if (mounted) { + Navigator.of(context).pop(); + } + } + } + + Future _dangerConfirmation(String message) async => + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + strings.text('danger'), + style: const TextStyle(color: Color(0xFFE06B6B)), + ), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(strings.text('cancel')), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFE06B6B), + ), + onPressed: () => Navigator.of(context).pop(true), + child: Text(strings.text('confirm')), + ), + ], + ), + ) ?? + false; + + Future _askText(String label, {String initialValue = ''}) async { + return showDialog( + context: context, + builder: (context) => _TextPromptDialog( + title: label, + fieldLabel: label, + initialValue: initialValue, + cancelLabel: strings.text('cancel'), + confirmLabel: strings.text('save'), + ), + ); + } + + Future _askPassword() async { + return showDialog( + context: context, + builder: (context) => _TextPromptDialog( + title: strings.text('password'), + fieldLabel: strings.text('password'), + cancelLabel: strings.text('cancel'), + confirmLabel: strings.text('confirm'), + obscureText: true, + ), + ); + } + + Future _guarded(Future Function() action) async { + try { + await action(); + } catch (error) { + _showMessage('$error'); + } + } + + void _showMessage(String message) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + String _historyLabel(OpeningEvent event) { + final source = strings.text(switch (event.kind) { + 0 => 'opened_by_permanent', + 1 => 'opened_by_temporary', + 2 => 'opened_by_nfc', + _ => 'opened_by_app', + }); + return event.actor.isEmpty ? source : '$source · ${event.actor}'; + } + + String _formatDate(DateTime date) { + String two(int value) => value.toString().padLeft(2, '0'); + return '${two(date.day)}/${two(date.month)}/${date.year} · ' + '${two(date.hour)}:${two(date.minute)}:${two(date.second)}'; + } +} + +class _TextPromptDialog extends StatefulWidget { + const _TextPromptDialog({ + required this.title, + required this.fieldLabel, + required this.cancelLabel, + required this.confirmLabel, + this.initialValue = '', + this.obscureText = false, + }); + + final String title; + final String fieldLabel; + final String cancelLabel; + final String confirmLabel; + final String initialValue; + final bool obscureText; + + @override + State<_TextPromptDialog> createState() => _TextPromptDialogState(); +} + +class _TextPromptDialogState extends State<_TextPromptDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initialValue, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title, style: const TextStyle(color: _accent)), + content: TextField( + controller: _controller, + obscureText: widget.obscureText, + autofocus: true, + decoration: InputDecoration(labelText: widget.fieldLabel), + onSubmitted: (_) => Navigator.of(context).pop(_controller.text), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_controller.text), + child: Text(widget.confirmLabel), + ), + ], + ); + } +} + +class _EmptyHome extends StatelessWidget { + const _EmptyHome({ + required this.addLabel, + required this.restoreLabel, + required this.onAdd, + required this.onRestore, + }); + + final String addLabel; + final String restoreLabel; + final VoidCallback onAdd; + final VoidCallback onRestore; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton( + onPressed: onAdd, + child: Text(addLabel, textAlign: TextAlign.center), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: onRestore, + icon: const Icon(Icons.restore), + label: Text(restoreLabel), + ), + ], + ), + ), + ); + } +} + +class _Dashboard extends StatelessWidget { + const _Dashboard({ + required this.controller, + required this.strings, + required this.onHistory, + required this.onCodes, + required this.onNfc, + required this.onGuests, + required this.onSettings, + }); + + final OpenParcelBoxBleController controller; + final AppStrings strings; + final VoidCallback onHistory; + final ValueChanged onCodes; + final VoidCallback onNfc; + final VoidCallback onGuests; + final VoidCallback onSettings; + + @override + Widget build(BuildContext context) { + final admin = controller.currentBox?.role == BoxRole.administrator; + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 12), + sliver: SliverToBoxAdapter( + child: Row( + children: [ + Image.asset(_logoAsset, width: 54, height: 54), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.currentBox?.name ?? 'OpenParcelBox', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: _accent, + fontWeight: FontWeight.w800, + ), + ), + Text( + '${strings.text(admin ? 'administrator' : 'guest')} · ' + '${strings.text(controller.isConnected ? 'connected' : controller.status)}', + ), + ], + ), + ), + Icon( + controller.isConnected + ? Icons.bluetooth_connected + : Icons.bluetooth_disabled, + color: controller.isConnected ? _accent : _text, + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + sliver: SliverToBoxAdapter( + child: DecoratedBox( + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: _Shortcut( + icon: Icons.lock_open, + label: strings.text('open'), + onTap: controller.openLock, + ), + ), + Container(width: 1, height: 44, color: Colors.white12), + Expanded( + child: _Shortcut( + icon: Icons.history, + label: strings.text('history'), + onTap: onHistory, + ), + ), + ], + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + sliver: SliverGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 14, + crossAxisSpacing: 14, + childAspectRatio: 1, + children: [ + _SquareAction( + icon: Icons.pin_outlined, + label: strings.text('permanent_codes'), + onTap: () => onCodes(CredentialKind.permanent), + ), + _SquareAction( + icon: Icons.timer_outlined, + label: strings.text('temporary_codes'), + onTap: () => onCodes(CredentialKind.oneTime), + ), + if (admin) + _SquareAction( + icon: Icons.nfc, + label: strings.text('nfc_tags'), + onTap: onNfc, + ), + if (admin) + _SquareAction( + icon: Icons.group_outlined, + label: strings.text('guests'), + onTap: onGuests, + ), + _SquareAction( + icon: Icons.settings_outlined, + label: strings.text('settings'), + onTap: onSettings, + ), + ], + ), + ), + ], + ); + } +} + +class _Shortcut extends StatelessWidget { + const _Shortcut({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: _accent, size: 28), + const SizedBox(height: 5), + Text(label, textAlign: TextAlign.center), + ], + ), + ), + ); + } +} + +class _SquareAction extends StatelessWidget { + const _SquareAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: _surface, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 54, color: _accent), + const SizedBox(height: 12), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ), + ), + ); + } +} + +class _DataRowCard extends StatelessWidget { + const _DataRowCard({ + required this.icon, + required this.title, + this.subtitle, + this.actions = const [], + }); + + final IconData icon; + final String title; + final String? subtitle; + final List actions; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.fromLTRB(12, 8, 6, 8), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white10), + ), + child: Row( + children: [ + Icon(icon, color: _accent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + if (subtitle != null) + Text(subtitle!, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ...actions, + ], + ), + ); + } +} + +class _EmptyRow extends StatelessWidget { + const _EmptyRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Center( + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white54), + ), + ), + ); +} + +class _SettingsButton extends StatelessWidget { + const _SettingsButton({ + required this.icon, + required this.label, + required this.onPressed, + this.danger = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onPressed; + final bool danger; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 8), + child: OutlinedButton.icon( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: danger ? const Color(0xFFE06B6B) : _text, + side: BorderSide( + color: danger ? const Color(0xFFE06B6B) : Colors.white24, + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + alignment: Alignment.centerLeft, + ), + icon: Icon(icon), + label: Text(label), + ), + ); +} + +Future showOpbModal( + BuildContext context, { + required String title, + required Widget child, +}) { + final size = MediaQuery.sizeOf(context); + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: title, + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 220), + pageBuilder: (context, animation, secondaryAnimation) => Material( + color: Colors.transparent, + child: Padding( + padding: EdgeInsets.fromLTRB( + size.width * 0.05, + size.height * 0.20, + size.width * 0.05, + 0, + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + color: _modalSurface, + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 54, 14), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: _accent, + fontWeight: FontWeight.w800, + ), + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 28), + child: child, + ), + ), + ], + ), + ), + ), + Positioned( + right: -22, + top: -22, + child: IconButton.filled( + style: IconButton.styleFrom( + backgroundColor: _accent, + foregroundColor: const Color(0xFF1F1F1F), + fixedSize: const Size(44, 44), + ), + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ), + ], + ), + ), + ), + transitionBuilder: (context, animation, secondaryAnimation, child) => + SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)), + child: FadeTransition(opacity: animation, child: child), + ), + ); +} + +String _deviceName(ScanResult result) { + final advertisedName = result.advertisementData.advName; + if (advertisedName.isNotEmpty) { + return advertisedName; + } + return result.device.platformName.isNotEmpty + ? result.device.platformName + : result.device.remoteId.str; +} diff --git a/mobile-app/app/lib/models.dart b/mobile-app/app/lib/models.dart new file mode 100644 index 0000000..4cbf451 --- /dev/null +++ b/mobile-app/app/lib/models.dart @@ -0,0 +1,72 @@ +enum BoxRole { administrator, guest } + +enum CredentialKind { permanent, oneTime } + +class SavedBox { + const SavedBox({ + required this.remoteId, + required this.name, + required this.identityKey, + required this.role, + }); + + final String remoteId; + final String name; + final String identityKey; + final BoxRole role; + + Map toJson() => { + 'remote_id': remoteId, + 'name': name, + 'identity_key': identityKey, + 'role': role.name, + }; + + factory SavedBox.fromJson(Map json) => SavedBox( + remoteId: json['remote_id']! as String, + name: json['name']! as String, + identityKey: json['identity_key']! as String, + role: json['role'] == BoxRole.guest.name + ? BoxRole.guest + : BoxRole.administrator, + ); +} + +class AccessCode { + const AccessCode({ + required this.id, + required this.code, + required this.kind, + }); + + final String id; + final String code; + final CredentialKind kind; +} + +class NfcTag { + const NfcTag({required this.id, required this.uid, required this.name}); + + final String id; + final String uid; + final String name; +} + +class OpeningEvent { + const OpeningEvent({ + required this.date, + required this.kind, + required this.actor, + }); + + final DateTime date; + final int kind; + final String actor; +} + +class GuestIdentity { + const GuestIdentity({required this.name, required this.key}); + + final String name; + final String key; +} diff --git a/mobile-app/app/lib/secure_box_store.dart b/mobile-app/app/lib/secure_box_store.dart new file mode 100644 index 0000000..c285d37 --- /dev/null +++ b/mobile-app/app/lib/secure_box_store.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'models.dart'; + +class SecureBoxStore { + SecureBoxStore({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + static const _boxesKey = 'opb.saved-boxes.v2'; + static const _languageKey = 'opb.language'; + static const _backupDirectoryKey = 'opb.backup-directory'; + final FlutterSecureStorage _storage; + + Future> loadBoxes() async { + final raw = await _storage.read(key: _boxesKey); + if (raw == null || raw.isEmpty) { + return []; + } + final decoded = jsonDecode(raw); + if (decoded is! List) { + return []; + } + return decoded + .whereType>() + .map((item) => SavedBox.fromJson(item.cast())) + .toList(); + } + + Future saveBoxes(List boxes) => _storage.write( + key: _boxesKey, + value: jsonEncode(boxes.map((box) => box.toJson()).toList()), + ); + + Future loadLanguage() => _storage.read(key: _languageKey); + + Future saveLanguage(String? language) async { + if (language == null) { + await _storage.delete(key: _languageKey); + } else { + await _storage.write(key: _languageKey, value: language); + } + } + + Future loadBackupDirectory() => + _storage.read(key: _backupDirectoryKey); + + Future saveBackupDirectory(String path) => + _storage.write(key: _backupDirectoryKey, value: path); +} diff --git a/mobile-app/app/pubspec.lock b/mobile-app/app/pubspec.lock new file mode 100644 index 0000000..1a79d4b --- /dev/null +++ b/mobile-app/app/pubspec.lock @@ -0,0 +1,743 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bluez: + dependency: transitive + description: + name: bluez + sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" + url: "https://pub.dev" + source: hosted + version: "0.8.3" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://pub.dev" + source: hosted + version: "11.0.2" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_blue_plus: + dependency: "direct main" + description: + name: flutter_blue_plus + sha256: "8b2e2f5870539e2d2a8a6473557455b990b87bc0cd86e7224bb3e20ee2eb00df" + url: "https://pub.dev" + source: hosted + version: "2.3.10" + flutter_blue_plus_android: + dependency: transitive + description: + name: flutter_blue_plus_android + sha256: "5f1db477d442974c516196718e2ab66e3601e9a2de271bddde9368c1a85e6e64" + url: "https://pub.dev" + source: hosted + version: "9.0.3" + flutter_blue_plus_darwin: + dependency: transitive + description: + name: flutter_blue_plus_darwin + sha256: bf41a4a07978b4c86a344c0c6e7388ff69f2b442a8d943b8da6d7f01a5c435bc + url: "https://pub.dev" + source: hosted + version: "9.0.3" + flutter_blue_plus_linux: + dependency: transitive + description: + name: flutter_blue_plus_linux + sha256: "79387947c27d04fce505916d168a1f8b7a89846d22d11a659970aba316459622" + url: "https://pub.dev" + source: hosted + version: "9.0.3" + flutter_blue_plus_platform_interface: + dependency: transitive + description: + name: flutter_blue_plus_platform_interface + sha256: "9378ed463673ab51e7ab72cf4bad3633b134182ca184ddcc598d6f7474ada993" + url: "https://pub.dev" + source: hosted + version: "9.0.3" + flutter_blue_plus_web: + dependency: transitive + description: + name: flutter_blue_plus_web + sha256: "62670fd0072e9424661170c3439eb2784679e0cb8420907ae2fe979aab8eed71" + url: "https://pub.dev" + source: hosted + version: "9.0.3" + flutter_blue_plus_winrt: + dependency: transitive + description: + name: flutter_blue_plus_winrt + sha256: "0000b2d818e6f79ad07764206fdd8afb69426fd44453c6254c35828fd16aa09f" + url: "https://pub.dev" + source: hosted + version: "0.0.20" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" + url: "https://pub.dev" + source: hosted + version: "2.4.8" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "30f7dc342094eb257ead933572f7e442dfd9d8aa6f3fa5506c3206f7837d1615" + url: "https://pub.dev" + source: hosted + version: "7.2.1" + ndef_record: + dependency: transitive + description: + name: ndef_record + sha256: "210ffb12284961cab9e44b99462143316d9a20cd992581170706069ef77d74a6" + url: "https://pub.dev" + source: hosted + version: "1.4.2" + nfc_manager: + dependency: "direct main" + description: + name: nfc_manager + sha256: "1b8d3788ed71bb34f59878a445e433891eb2268353289515c1542b2780151fe6" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.dev" + source: hosted + version: "9.4.1" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.38.4" diff --git a/mobile-app/app/pubspec.yaml b/mobile-app/app/pubspec.yaml new file mode 100644 index 0000000..374937c --- /dev/null +++ b/mobile-app/app/pubspec.yaml @@ -0,0 +1,114 @@ +name: app +description: "OpenParcelBox mobile application." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + cryptography: ^2.9.0 + file_picker: ^11.0.2 + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + cupertino_icons: ^1.0.8 + flutter_blue_plus: 2.3.10 + flutter_secure_storage: ^9.2.4 + mobile_scanner: ^7.2.1 + nfc_manager: ^4.2.1 + qr_flutter: ^4.1.0 + +dev_dependencies: + flutter_launcher_icons: ^0.14.4 + flutter_native_splash: ^2.4.8 + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - images/background.jpg + - images/openparcelbox-logo-256.png + +flutter_launcher_icons: + android: true + ios: false + image_path: images/openparcelbox-logo-256.png + adaptive_icon_background: "#333333" + adaptive_icon_foreground: images/openparcelbox-logo-256.png + +flutter_native_splash: + color: "#333333" + background_image: images/background.jpg + image: images/openparcelbox-logo-256.png + android: true + ios: false + android_12: + color: "#333333" + image: images/openparcelbox-logo-256.png + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/mobile-app/app/test/widget_test.dart b/mobile-app/app/test/widget_test.dart new file mode 100644 index 0000000..560b701 --- /dev/null +++ b/mobile-app/app/test/widget_test.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:app/backup_service.dart'; +import 'package:app/main.dart'; +import 'package:app/models.dart'; +import 'package:app/secure_box_store.dart'; + +class _MemoryStore extends SecureBoxStore { + _MemoryStore({this.language = 'en'}); + + final String? language; + + @override + Future> loadBoxes() async => []; + + @override + Future loadLanguage() async => language; + + @override + Future saveBoxes(List boxes) async {} +} + +class _FakeBackupService extends BackupService { + bool fileWasPicked = false; + + @override + Future pickBackupFile() async { + fileWasPicked = true; + return Uint8List(0); + } + + @override + Future> importBoxes(Uint8List bytes, String password) async => + []; +} + +void main() { + testWidgets('empty home only exposes registration and restore actions', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(MyApp(store: _MemoryStore())); + await tester.pumpAndSettle(); + + expect(find.text('Add a new OpenParcelBox'), findsOneWidget); + expect(find.text('Restore a backup'), findsOneWidget); + expect(find.byIcon(Icons.lock_open), findsNothing); + expect(find.text('Permanent codes'), findsNothing); + }); + + testWidgets('registration opens the styled setup modal', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(MyApp(store: _MemoryStore())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add a new OpenParcelBox')); + await tester.pumpAndSettle(); + + expect(find.text('Box identifier'), findsOneWidget); + expect(find.text('Scan a guest QR code'), findsOneWidget); + expect(find.text('Detected OpenParcelBox devices'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('registration text field works with the French locale', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(MyApp(store: _MemoryStore(language: 'fr'))); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Ajouter une nouvelle OpenParcelBox')); + await tester.pumpAndSettle(); + + expect(find.byType(TextFormField), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'closing registration after editing does not dispose input early', + (WidgetTester tester) async { + await tester.pumpWidget(MyApp(store: _MemoryStore())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add a new OpenParcelBox')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField), 'Front gate'); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.text('Add a new OpenParcelBox'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('restore picks a file before requesting its password', ( + WidgetTester tester, + ) async { + final backupService = _FakeBackupService(); + await tester.pumpWidget( + MyApp(store: _MemoryStore(), backupService: backupService), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Restore a backup')); + await tester.pumpAndSettle(); + + expect(backupService.fileWasPicked, isTrue); + expect(find.text('Password'), findsNWidgets(2)); + + await tester.enterText(find.byType(TextField), 'secret'); + await tester.tap(find.text('Confirm')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Backup restored.'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +}