Compare commits

...

17 Commits

Author SHA1 Message Date
2ac68e09ab bd: backup 2026-03-04 17:07 2026-03-04 18:07:18 +01:00
1dbbf191e6 feat(dfu): add firmware update controls to device page 2026-03-04 18:07:12 +01:00
32f258a492 bd: backup 2026-03-03 16:11 2026-03-03 17:11:51 +01:00
c581b4d92c feat(dfu): verify reconnect before reporting update success 2026-03-03 17:11:47 +01:00
aafa9928ac feat(dfu): add firmware file selection and validation 2026-03-03 17:06:54 +01:00
8b24084f97 feat(dfu): add firmware transfer engine with ack retries 2026-03-03 17:00:37 +01:00
dd2afa34ef bd: backup 2026-03-03 15:55 2026-03-03 16:55:11 +01:00
fb85565854 feat(dfu): add connection and MTU preflight checks 2026-03-03 16:54:56 +01:00
7a33e71410 feat(dfu): add packet codec and crc32 utilities 2026-03-03 16:48:49 +01:00
e704f27a96 feat(dfu): add protocol constants and progress models 2026-03-03 16:45:16 +01:00
08405c879b fix(devices details page): style fixes 2026-03-03 16:41:43 +01:00
76c0fbe237 bd: backup 2026-03-03 15:37 2026-03-03 16:37:50 +01:00
d3a2fe6613 bd: backup 2026-03-03 15:20 2026-03-03 16:20:28 +01:00
a673aa14b7 bd init: initialize beads issue tracking 2026-03-03 16:15:18 +01:00
575ccaae42 feat: new shifter types and better gear ratio editor 2026-02-23 11:45:25 +01:00
dcb1e6596e feat: working connection, conn setting, and gear ratio setting for universal shifters 2026-02-22 23:05:12 +01:00
f92d6d04f5 chore: update Java and Kotlin versions in build configuration 2025-03-27 14:51:28 +01:00
124 changed files with 13952 additions and 671 deletions

49
.beads/.gitignore vendored Normal file
View File

@ -0,0 +1,49 @@
# Dolt database (managed by Dolt, not git)
dolt/
dolt-access.lock
# Runtime files
bd.sock
bd.sock.startlock
sync-state.json
last-touched
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Sync state (local-only, per-machine)
# These files are machine-specific and should not be shared across clones
.sync.lock
export-state/
# Ephemeral store (SQLite - wisps/molecules, intentionally not versioned)
ephemeral.sqlite3
ephemeral.sqlite3-journal
ephemeral.sqlite3-wal
ephemeral.sqlite3-shm
# Dolt server management (auto-started by bd)
dolt-server.pid
dolt-server.log
dolt-server.lock
# Legacy files (from pre-Dolt versions)
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
db.sqlite
bd.db
daemon.lock
daemon.log
daemon-*.log.gz
daemon.pid
# NOTE: Do NOT add negation patterns here.
# They would override fork protection in .git/info/exclude.
# Config files (metadata.json, config.yaml) are tracked by git by default
# since no pattern above ignores them.

81
.beads/README.md Normal file
View File

@ -0,0 +1,81 @@
# Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
## What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads)
## Quick Start
### Essential Commands
```bash
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --claim
bd update <issue-id> --status done
# Sync with Dolt remote
bd dolt push
```
### Working with Issues
Issues in Beads are:
- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code
- **AI-friendly**: CLI-first design works perfectly with AI coding agents
- **Branch-aware**: Issues can follow your branch workflow
- **Always in sync**: Auto-syncs with your commits
## Why Beads?
**AI-Native Design**
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 **Developer Focused**
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 **Git Integration**
- Automatic sync with git commits
- Branch-aware issue tracking
- Intelligent JSONL merge resolution
## Get Started with Beads
Try Beads in your own projects:
```bash
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"
```
## Learn More
- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs)
- **Quick Start Guide**: Run `bd quickstart`
- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples)
---
*Beads: Issue tracking that moves at the speed of thought*

View File

@ -0,0 +1,13 @@
{
"last_dolt_commit": "mf3is3p9ve79on2q1achdjp8v50rq6s3",
"last_event_id": 0,
"timestamp": "2026-03-04T17:07:18.317614374Z",
"counts": {
"issues": 10,
"events": 24,
"comments": 0,
"dependencies": 21,
"labels": 0,
"config": 11
}
}

View File

View File

@ -0,0 +1,11 @@
{"key":"auto_compact_enabled","value":"false"}
{"key":"compact_batch_size","value":"50"}
{"key":"compact_parallel_workers","value":"5"}
{"key":"compact_tier1_days","value":"30"}
{"key":"compact_tier1_dep_levels","value":"2"}
{"key":"compact_tier2_commits","value":"100"}
{"key":"compact_tier2_days","value":"90"}
{"key":"compact_tier2_dep_levels","value":"5"}
{"key":"compaction_enabled","value":"false"}
{"key":"issue_prefix","value":"abawo_bt_app"}
{"key":"schema_version","value":"6"}

View File

@ -0,0 +1,21 @@
{"created_at":"2026-03-03T16:38:33Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.1","type":"parent-child"}
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.2","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.2","type":"blocks"}
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.3","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.4","issue_id":"abawo_bt_app-20q.3","type":"blocks"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.3","type":"blocks"}
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.4","type":"parent-child"}
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.5","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.5","type":"blocks"}
{"created_at":"2026-03-03T16:39:10Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.6","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.3","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.5","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.9","issue_id":"abawo_bt_app-20q.6","type":"blocks"}
{"created_at":"2026-03-03T16:39:17Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.7","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.2","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.8","issue_id":"abawo_bt_app-20q.7","type":"blocks"}
{"created_at":"2026-03-03T16:39:23Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.8","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.1","issue_id":"abawo_bt_app-20q.8","type":"blocks"}
{"created_at":"2026-03-03T16:39:28Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q","issue_id":"abawo_bt_app-20q.9","type":"parent-child"}
{"created_at":"2026-03-03T16:39:47Z","created_by":"Yandrik","depends_on_id":"abawo_bt_app-20q.7","issue_id":"abawo_bt_app-20q.9","type":"blocks"}

View File

@ -0,0 +1,24 @@
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:37:50Z","event_type":"created","id":1,"issue_id":"abawo_bt_app-20q","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:38:33Z","event_type":"created","id":2,"issue_id":"abawo_bt_app-20q.1","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":3,"issue_id":"abawo_bt_app-20q.2","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":5,"issue_id":"abawo_bt_app-20q.3","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":7,"issue_id":"abawo_bt_app-20q.4","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":9,"issue_id":"abawo_bt_app-20q.5","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:10Z","event_type":"created","id":10,"issue_id":"abawo_bt_app-20q.6","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:17Z","event_type":"created","id":11,"issue_id":"abawo_bt_app-20q.7","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:23Z","event_type":"created","id":12,"issue_id":"abawo_bt_app-20q.8","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:39:28Z","event_type":"created","id":13,"issue_id":"abawo_bt_app-20q.9","new_value":"","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:43:26Z","event_type":"claimed","id":14,"issue_id":"abawo_bt_app-20q.1","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.1\",\"title\":\"Add DFU protocol constants and domain models\",\"description\":\"Add protocol surface in app code for DFU support.\\n\\nWork:\\n- Add UUID constants for dfu_control (...40008), dfu_data (...40009), dfu_ack (...4000a)\\n- Add opcode/frame constants (START=0x01, FINISH=0x02, ABORT=0x03, frame size 64, payload size 63)\\n- Add flags constants and typed update-state/progress models used by service/UI\\n- Remove future magic numbers by centralizing constants\",\"acceptance_criteria\":\"- All protocol constants match spec exactly\\n- No duplicated literal DFU UUID/opcode values across services/UI\\n- Domain models compile and are ready for transfer engine integration\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:38:34Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:38:34Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:45:19Z","event_type":"closed","id":15,"issue_id":"abawo_bt_app-20q.1","new_value":"Implemented DFU constants, flags, opcodes, and typed progress models in shifter_types.dart","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:45:29Z","event_type":"claimed","id":16,"issue_id":"abawo_bt_app-20q.2","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.2\",\"title\":\"Implement DFU packet codec and CRC32 utilities with tests\",\"description\":\"Implement pure protocol helpers.\\n\\nWork:\\n- Build START payload (11 bytes, LE fields)\\n- Build FINISH/ABORT payloads\\n- Build/segment DATA frames (seq + 63-byte payload)\\n- Implement ACK/sequence helpers including wrapping behavior\\n- Implement CRC32 (ISO-HDLC reflected polynomial 0xEDB88320, init/final xor FFFFFFFF)\",\"acceptance_criteria\":\"- CRC test vector passes: \\\"123456789\\\" =\\u003e 0xCBF43926\\n- START/FINISH/ABORT encoders produce exact lengths/byte layout\\n- Frame segmentation handles final partial payload correctly\\n- Seq wrap and ack+1 rewind helpers covered by tests\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:48:57Z","event_type":"closed","id":17,"issue_id":"abawo_bt_app-20q.2","new_value":"Added DFU protocol codec/CRC utilities with unit tests for payloads, frames, and sequence helpers","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:49:06Z","event_type":"claimed","id":18,"issue_id":"abawo_bt_app-20q.8","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.8\",\"title\":\"Add BLE DFU preflight checks (MTU and connection readiness)\",\"description\":\"Add runtime guards required for protocol correctness.\\n\\nWork:\\n- Ensure active connection to target button before DFU start\\n- Request elevated MTU (e.g. 128/247) before upload\\n- Validate negotiated MTU supports 64-byte data writes (ATT payload requirement)\\n- Fail early with actionable message when transport preconditions are not met\",\"acceptance_criteria\":\"- Upload start is blocked when MTU/connection preconditions fail\\n- Error messages explain what failed and next step\\n- Preflight result is exposed for transfer start path\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:24Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:24Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:55:10Z","event_type":"closed","id":19,"issue_id":"abawo_bt_app-20q.8","new_value":"Implemented DFU preflight checks for connection state and negotiated MTU with typed results and tests","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T16:55:19Z","event_type":"claimed","id":20,"issue_id":"abawo_bt_app-20q.7","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.7\",\"title\":\"Implement BLE DFU transfer engine with cumulative ACK retransmit\",\"description\":\"Build the runtime transfer engine used by UI.\\n\\nWork:\\n- Subscribe to dfu_ack indications before START\\n- Send START and require initial ACK 0xFF\\n- Stream dfu_data using write without response in windows (configurable, default 8)\\n- Track cumulative ACK; on stall/timeout rewind to ack+1 (wrapping aware)\\n- Handle invalid/no-progress scenarios with bounded retries\\n- Send FINISH after full acked upload\\n- Support ABORT for cancellation and terminal error cleanup\\n- Emit state/progress stream for UI\",\"acceptance_criteria\":\"- Happy path reaches done with full ACKed transfer\\n- Loss/stall path retransmits and recovers correctly\\n- Cancel triggers ABORT and returns to idle cleanly\\n- Engine surfaces explicit error reasons for UI\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"feature\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:18Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:18Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:00:44Z","event_type":"closed","id":21,"issue_id":"abawo_bt_app-20q.7","new_value":"Implemented firmware transfer engine with preflight, cumulative ACK handling, retries, cancel ABORT, and unit tests","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:01:01Z","event_type":"claimed","id":22,"issue_id":"abawo_bt_app-20q.4","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.4\",\"title\":\"Add firmware file selection and binary validation flow\",\"description\":\"Implement local firmware artifact input for v1.\\n\\nWork:\\n- Integrate file picker for local firmware .bin\\n- Read bytes safely and validate non-empty payload\\n- Guard against malformed selections and unsupported files\\n- Compute total_len and crc32 from selected bytes\\n- Generate per-session session_id and set flags=0x00 for v1\",\"acceptance_criteria\":\"- User can select .bin and app obtains byte payload\\n- Validation errors are explicit and user-facing\\n- Metadata (size/crc/session) is available to transfer engine\",\"status\":\"open\",\"priority\":2,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:04:51Z","event_type":"closed","id":23,"issue_id":"abawo_bt_app-20q.4","new_value":"Completed","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:07:21Z","event_type":"claimed","id":24,"issue_id":"abawo_bt_app-20q.9","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.9\",\"title\":\"Handle post-FINISH disconnect/reboot and reconnect verification\",\"description\":\"Implement robust completion handling around expected device reset.\\n\\nWork:\\n- Treat disconnect after successful FINISH as expected behavior\\n- Reconnect with update-specific timeout strategy\\n- Verify device is reachable/readable after reconnect\\n- Surface success only after reconnect verification path\\n- Document limitation: no strict firmware version compare until firmware exposes version characteristic\",\"acceptance_criteria\":\"- Expected reset does not appear as generic failure\\n- Reconnect path is attempted and result is surfaced\\n- Completion criteria are consistent with v1 definition\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:28Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:28Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:12:02Z","event_type":"closed","id":25,"issue_id":"abawo_bt_app-20q.9","new_value":"Added post-FINISH reset disconnect, reconnect, and reachability verification before marking update complete","old_value":""}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-03T17:12:17Z","event_type":"claimed","id":26,"issue_id":"abawo_bt_app-20q.3","new_value":"{\"assignee\":\"Yandrik\",\"status\":\"in_progress\"}","old_value":"{\"id\":\"abawo_bt_app-20q.3\",\"title\":\"Integrate firmware update UI into device details page\",\"description\":\"Add user-facing update controls and status presentation.\\n\\nWork:\\n- Add update card with Select Firmware, Start Update, Cancel\\n- Show phase text, progress %, bytes sent/acked, and retry status\\n- Disable conflicting actions (gear writes / connect button-to-bike) during DFU\\n- Show explicit reboot expectation after FINISH\\n- Persist/clear transient state correctly on page lifecycle changes\",\"acceptance_criteria\":\"- UI can run full update flow start-to-finish\\n- Progress/state transitions are visible and consistent\\n- Conflicting controls are disabled during active transfer\\n- Failures and cancellations are clearly shown\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"feature\",\"owner\":\"me@yandrik.dev\",\"created_at\":\"2026-03-03T15:39:10Z\",\"created_by\":\"Yandrik\",\"updated_at\":\"2026-03-03T15:39:10Z\"}"}
{"actor":"Yandrik","comment":null,"created_at":"2026-03-04T18:07:17Z","event_type":"closed","id":27,"issue_id":"abawo_bt_app-20q.3","new_value":"Integrated firmware file selection, update controls, progress display, and DFU state handling into device details page","old_value":""}

View File

@ -0,0 +1,10 @@
{"acceptance_criteria":"- User can select a firmware .bin and complete upload end-to-end\n- Upload follows protocol and handles packet loss via retransmit\n- App shows clear progress and failure states\n- Device reconnects after reboot and is reachable","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"c3bd8fa9b5b9d51b04cde5e11c2d8cdedaca29e049a64e8521172a69093b4ba3","created_at":"2026-03-03T15:37:50Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement the firmware update flow defined in universal-shifters/update-process.md for the Flutter app.\n\nScope:\n- Manual local .bin selection and upload over BLE GATT\n- START/DATA/FINISH/ABORT protocol support\n- Cumulative ACK handling with retransmit\n- Expected reboot/disconnect handling and reconnect check\n\nOut of scope for v1:\n- Hosted firmware distribution/backend\n- Cryptographic signature verification in app\n- Encrypted payload transport mode","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q","is_template":0,"issue_type":"epic","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Implement Universal Shifters BLE DFU v1 in app (manual .bin upload)","updated_at":"2026-03-03T15:37:50Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- All protocol constants match spec exactly\n- No duplicated literal DFU UUID/opcode values across services/UI\n- Domain models compile and are ready for transfer engine integration","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented DFU constants, flags, opcodes, and typed progress models in shifter_types.dart","closed_at":"2026-03-03T15:45:20Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"d471157a1af6199f0ac0a1565b05b28d1a477e66d9166a3865edb42ea1e8c2ae","created_at":"2026-03-03T15:38:34Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add protocol surface in app code for DFU support.\n\nWork:\n- Add UUID constants for dfu_control (...40008), dfu_data (...40009), dfu_ack (...4000a)\n- Add opcode/frame constants (START=0x01, FINISH=0x02, ABORT=0x03, frame size 64, payload size 63)\n- Add flags constants and typed update-state/progress models used by service/UI\n- Remove future magic numbers by centralizing constants","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.1","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add DFU protocol constants and domain models","updated_at":"2026-03-03T15:45:20Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- CRC test vector passes: \"123456789\" =\u003e 0xCBF43926\n- START/FINISH/ABORT encoders produce exact lengths/byte layout\n- Frame segmentation handles final partial payload correctly\n- Seq wrap and ack+1 rewind helpers covered by tests","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Added DFU protocol codec/CRC utilities with unit tests for payloads, frames, and sequence helpers","closed_at":"2026-03-03T15:48:57Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"9ba609e63c9e2f8a4e95de6795dace11c610aa9c13908b5dab7aa6cb83ab58d4","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement pure protocol helpers.\n\nWork:\n- Build START payload (11 bytes, LE fields)\n- Build FINISH/ABORT payloads\n- Build/segment DATA frames (seq + 63-byte payload)\n- Implement ACK/sequence helpers including wrapping behavior\n- Implement CRC32 (ISO-HDLC reflected polynomial 0xEDB88320, init/final xor FFFFFFFF)","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.2","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Implement DFU packet codec and CRC32 utilities with tests","updated_at":"2026-03-03T15:48:57Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- UI can run full update flow start-to-finish\n- Progress/state transitions are visible and consistent\n- Conflicting controls are disabled during active transfer\n- Failures and cancellations are clearly shown","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Integrated firmware file selection, update controls, progress display, and DFU state handling into device details page","closed_at":"2026-03-04T17:07:18Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"e8d2f905536263a05de5e7e86bf8f02fa0491d7129e30f36f2c4dd09acab1882","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add user-facing update controls and status presentation.\n\nWork:\n- Add update card with Select Firmware, Start Update, Cancel\n- Show phase text, progress %, bytes sent/acked, and retry status\n- Disable conflicting actions (gear writes / connect button-to-bike) during DFU\n- Show explicit reboot expectation after FINISH\n- Persist/clear transient state correctly on page lifecycle changes","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.3","is_template":0,"issue_type":"feature","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Integrate firmware update UI into device details page","updated_at":"2026-03-04T17:07:18Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- User can select .bin and app obtains byte payload\n- Validation errors are explicit and user-facing\n- Metadata (size/crc/session) is available to transfer engine","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Completed","closed_at":"2026-03-03T16:04:51Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"be8d8646593e4fc6c269383efb41808c40ffdfb3103e58a90acc0142e32f4711","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement local firmware artifact input for v1.\n\nWork:\n- Integrate file picker for local firmware .bin\n- Read bytes safely and validate non-empty payload\n- Guard against malformed selections and unsupported files\n- Compute total_len and crc32 from selected bytes\n- Generate per-session session_id and set flags=0x00 for v1","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.4","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add firmware file selection and binary validation flow","updated_at":"2026-03-03T16:04:51Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- Tests cover success and critical failure/retry paths\n- Wrap-around and ack rewind behavior is validated\n- Regressions in sequencing/CRC are caught automatically","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"f784095261bfc41eed5544a567f667bb2ba4816433f1d5d6ae21ea4616c7109a","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add targeted tests for protocol and engine behavior.\n\nWork:\n- Unit tests for codec + CRC + sequence helpers\n- Engine tests with mocked BLE ack stream for:\n - happy path\n - dropped frame / stalled ACK and rewind\n - timeout and bounded retry fail\n - cancel/abort cleanup\n- Ensure deterministic tests for wrap-around sequence scenarios","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.5","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Add DFU test suite for happy path, loss, stalls, and cancel","updated_at":"2026-03-03T15:39:10Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- Team can execute update and triage failures from docs\n- v1 limitations are explicit and not ambiguous\n- QA checklist is actionable and complete","actor":"","agent_state":"","assignee":null,"await_id":"","await_type":"","close_reason":"","closed_at":null,"closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"3a5dd3fd4d901ae53a80b3c7b488b41db5e09f7cc50da0e6c185c075b8ad7e51","created_at":"2026-03-03T15:39:10Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Document how to use and support the new updater.\n\nWork:\n- Add app-side DFU flow docs (select/start/progress/reboot/reconnect)\n- Add troubleshooting matrix for common failures (MTU, stalled ACK, reconnect timeout, CRC mismatch)\n- Record explicit v1 limitations and future security/version-verification roadmap\n- Add manual QA checklist for release validation","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.6","is_template":0,"issue_type":"chore","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":2,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"open","target":"","timeout_ns":0,"title":"Document DFU v1 operator flow, troubleshooting, and constraints","updated_at":"2026-03-03T15:39:10Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- Happy path reaches done with full ACKed transfer\n- Loss/stall path retransmits and recovers correctly\n- Cancel triggers ABORT and returns to idle cleanly\n- Engine surfaces explicit error reasons for UI","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented firmware transfer engine with preflight, cumulative ACK handling, retries, cancel ABORT, and unit tests","closed_at":"2026-03-03T16:00:45Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"042d39f373b05c758d2d2724c757a7e790de522c2def68f8f12e9f2fbbb70dc6","created_at":"2026-03-03T15:39:18Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Build the runtime transfer engine used by UI.\n\nWork:\n- Subscribe to dfu_ack indications before START\n- Send START and require initial ACK 0xFF\n- Stream dfu_data using write without response in windows (configurable, default 8)\n- Track cumulative ACK; on stall/timeout rewind to ack+1 (wrapping aware)\n- Handle invalid/no-progress scenarios with bounded retries\n- Send FINISH after full acked upload\n- Support ABORT for cancellation and terminal error cleanup\n- Emit state/progress stream for UI","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.7","is_template":0,"issue_type":"feature","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Implement BLE DFU transfer engine with cumulative ACK retransmit","updated_at":"2026-03-03T16:00:45Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- Upload start is blocked when MTU/connection preconditions fail\n- Error messages explain what failed and next step\n- Preflight result is exposed for transfer start path","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Implemented DFU preflight checks for connection state and negotiated MTU with typed results and tests","closed_at":"2026-03-03T15:55:11Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"558fa59b42c42b122eefc43d3483d1a61bdd995464dbc36d166f763f42366e87","created_at":"2026-03-03T15:39:24Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Add runtime guards required for protocol correctness.\n\nWork:\n- Ensure active connection to target button before DFU start\n- Request elevated MTU (e.g. 128/247) before upload\n- Validate negotiated MTU supports 64-byte data writes (ATT payload requirement)\n- Fail early with actionable message when transport preconditions are not met","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.8","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Add BLE DFU preflight checks (MTU and connection readiness)","updated_at":"2026-03-03T15:55:11Z","waiters":"","wisp_type":"","work_type":""}
{"acceptance_criteria":"- Expected reset does not appear as generic failure\n- Reconnect path is attempted and result is surfaced\n- Completion criteria are consistent with v1 definition","actor":"","agent_state":"","assignee":"Yandrik","await_id":"","await_type":"","close_reason":"Added post-FINISH reset disconnect, reconnect, and reachability verification before marking update complete","closed_at":"2026-03-03T16:12:02Z","closed_by_session":"","compacted_at":null,"compacted_at_commit":null,"compaction_level":0,"content_hash":"024e8aa245b93098013fbd66102a621c9ecd58a1dda9fc276c74cd278bdd3512","created_at":"2026-03-03T15:39:28Z","created_by":"Yandrik","crystallizes":0,"defer_until":null,"description":"Implement robust completion handling around expected device reset.\n\nWork:\n- Treat disconnect after successful FINISH as expected behavior\n- Reconnect with update-specific timeout strategy\n- Verify device is reachable/readable after reconnect\n- Surface success only after reconnect verification path\n- Document limitation: no strict firmware version compare until firmware exposes version characteristic","design":"","due_at":null,"ephemeral":0,"estimated_minutes":null,"event_kind":"","external_ref":null,"hook_bead":"","id":"abawo_bt_app-20q.9","is_template":0,"issue_type":"task","last_activity":null,"metadata":"{}","mol_type":"","notes":"","original_size":null,"owner":"me@yandrik.dev","payload":"","pinned":0,"priority":1,"quality_score":null,"rig":"","role_bead":"","role_type":"","sender":"","source_repo":"","source_system":"","spec_id":"","status":"closed","target":"","timeout_ns":0,"title":"Handle post-FINISH disconnect/reboot and reconnect verification","updated_at":"2026-03-03T16:12:02Z","waiters":"","wisp_type":"","work_type":""}

View File

55
.beads/config.yaml Normal file
View File

@ -0,0 +1,55 @@
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: load from JSONL, write back after each command
# When true, bd will use .beads/issues.jsonl as the source of truth
# instead of the Dolt database
# no-db: false
# Enable JSON output by default
# json: false
# Feedback title formatting for mutating commands (create/update/close/dep/edit)
# 0 = hide titles, N > 0 = truncate to N characters
# output:
# title-length: 255
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
# actor: ""
# Export events (audit trail) to .beads/events.jsonl on each flush/sync
# When enabled, new events are appended incrementally using a high-water mark.
# Use 'bd export --events' to trigger manually regardless of this setting.
# events-export: false
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# JSONL backup (periodic export for off-machine recovery)
# Auto-enabled when a git remote exists. Override explicitly:
# backup:
# enabled: false # Disable auto-backup entirely
# interval: 15m # Minimum time between auto-exports
# git-push: false # Disable git push (export locally only)
# git-repo: "" # Separate git repo for backups (default: project repo)
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo

1
.beads/dolt-monitor.pid Normal file
View File

@ -0,0 +1 @@
48179

View File

@ -0,0 +1 @@
1772550918

1
.beads/dolt-server.port Normal file
View File

@ -0,0 +1 @@
13365

9
.beads/hooks/post-checkout Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
bd hooks run post-checkout "$@"
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION ---

9
.beads/hooks/post-merge Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
bd hooks run post-merge "$@"
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION ---

9
.beads/hooks/pre-commit Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
bd hooks run pre-commit "$@"
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION ---

9
.beads/hooks/pre-push Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
bd hooks run pre-push "$@"
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION ---

View File

@ -0,0 +1,9 @@
#!/usr/bin/env sh
# --- BEGIN BEADS INTEGRATION v0.57.0 ---
# This section is managed by beads. Do not remove these markers.
if command -v bd >/dev/null 2>&1; then
export BD_GIT_HOOK=1
bd hooks run prepare-commit-msg "$@"
_bd_exit=$?; if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi
fi
# --- END BEADS INTEGRATION ---

View File

6
.beads/metadata.json Normal file
View File

@ -0,0 +1,6 @@
{
"database": "dolt",
"backend": "dolt",
"dolt_mode": "server",
"dolt_database": "abawo_bt_app"
}

4
.gitignore vendored
View File

@ -44,3 +44,7 @@ app.*.map.json
/android/app/profile
/android/app/release
.aider*
# Dolt database files (added by bd init)
.dolt/
*.db

150
AGENTS.md Normal file
View File

@ -0,0 +1,150 @@
# Agent Instructions
This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started.
## Quick Reference
```bash
bd ready # Find available work
bd show <id> # View issue details
bd update <id> --claim # Claim work atomically
bd close <id> # Complete work
bd sync # Sync with git
```
## Non-Interactive Shell Commands
**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts.
Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input.
**Use these forms instead:**
```bash
# Force overwrite without prompting
cp -f source dest # NOT: cp source dest
mv -f source dest # NOT: mv source dest
rm -f file # NOT: rm file
# For recursive operations
rm -rf directory # NOT: rm -r directory
cp -rf source dest # NOT: cp -r source dest
```
**Other commands that may prompt:**
- `scp` - use `-o BatchMode=yes` for non-interactive
- `ssh` - use `-o BatchMode=yes` to fail instead of prompting
- `apt-get` - use `-y` flag
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
<!-- BEGIN BEADS INTEGRATION -->
## Issue Tracking with bd (beads)
**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods.
### Why bd?
- Dependency-aware: Track blockers and relationships between issues
- Git-friendly: Auto-syncs to JSONL for version control
- Agent-optimized: JSON output, ready work detection, discovered-from links
- Prevents duplicate tracking systems and confusion
### Quick Start
**Check for ready work:**
```bash
bd ready --json
```
**Create new issues:**
```bash
bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json
bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json
```
**Claim and update:**
```bash
bd update <id> --claim --json
bd update bd-42 --priority 1 --json
```
**Complete work:**
```bash
bd close bd-42 --reason "Completed" --json
```
### Issue Types
- `bug` - Something broken
- `feature` - New functionality
- `task` - Work item (tests, docs, refactoring)
- `epic` - Large feature with subtasks
- `chore` - Maintenance (dependencies, tooling)
### Priorities
- `0` - Critical (security, data loss, broken builds)
- `1` - High (major features, important bugs)
- `2` - Medium (default, nice-to-have)
- `3` - Low (polish, optimization)
- `4` - Backlog (future ideas)
### Workflow for AI Agents
1. **Check ready work**: `bd ready` shows unblocked issues
2. **Claim your task atomically**: `bd update <id> --claim`
3. **Work on it**: Implement, test, document
4. **Discover new work?** Create linked issue:
- `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:<parent-id>`
5. **Complete**: `bd close <id> --reason "Done"`
### Auto-Sync
bd automatically syncs with git:
- Exports to `.beads/issues.jsonl` after changes (5s debounce)
- Imports from JSONL when newer (e.g., after `git pull`)
- No manual export/import needed!
### Important Rules
- ✅ Use bd for ALL task tracking
- ✅ Always use `--json` flag for programmatic use
- ✅ Link discovered work with `discovered-from` dependencies
- ✅ Check `bd ready` before asking "what should I work on?"
- ❌ Do NOT create markdown TODO lists
- ❌ Do NOT use external issue trackers
- ❌ Do NOT duplicate tracking systems
For more details, see README.md and docs/QUICKSTART.md.
<!-- END BEADS INTEGRATION -->
## Landing the Plane (Session Completion)
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
**MANDATORY WORKFLOW:**
1. **File issues for remaining work** - Create issues for anything that needs follow-up
2. **Run quality gates** (if code changed) - Tests, linters, builds
3. **Update issue status** - Close finished work, update in-progress items
4. **PUSH TO REMOTE** - This is MANDATORY:
```bash
git pull --rebase
bd sync
git push
git status # MUST show "up to date with origin"
```
5. **Clean up** - Clear stashes, prune remote branches
6. **Verify** - All changes committed AND pushed
7. **Hand off** - Provide context for next session
**CRITICAL RULES:**
- Work is NOT complete until `git push` succeeds
- NEVER stop before pushing - that leaves work stranded locally
- NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds

View File

@ -11,12 +11,12 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
jvmTarget = JavaVersion.VERSION_17
}
defaultConfig {

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip

View File

@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

3
flutter_rust_bridge.yaml Normal file
View File

@ -0,0 +1,3 @@
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust

View File

@ -0,0 +1,13 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:abawo_bt_app/main.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async => await RustLib.init());
testWidgets('Can call rust function', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.textContaining('Result: `Hello, Tom!`'), findsOneWidget);
});
}

View File

@ -1,131 +1,367 @@
import 'dart:async';
import 'dart:io';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
hide ConnectionStatus, Result, Logger;
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'bluetooth.g.dart';
final log = Logger('BluetoothController');
@riverpod
@Riverpod(keepAlive: true)
FlutterReactiveBle reactiveBle(Ref ref) {
ref.keepAlive();
return FlutterReactiveBle();
}
@Riverpod(keepAlive: true)
Future<BluetoothController> bluetooth(Ref ref) async {
final controller = BluetoothController();
log.info(await controller.init());
ref.keepAlive();
final controller = BluetoothController(ref.read(reactiveBleProvider));
await controller.init();
return controller;
}
@Riverpod(keepAlive: true)
Stream<(ConnectionStatus, String?)> connectionStatus(Ref ref) {
final asyncController = ref.watch(bluetoothProvider);
return asyncController.when(
data: (controller) => controller.connectionStateStream,
loading: () => Stream.value((ConnectionStatus.disconnected, null)),
error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)),
);
}
/// Represents the connection status of the Bluetooth device.
enum ConnectionStatus { disconnected, connecting, connected, disconnecting }
class BluetoothController {
StreamSubscription<BluetoothAdapterState>? _btStateSubscription;
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
List<ScanResult> _latestScanResults = [];
BluetoothController(this._ble);
static const int defaultMtu = 64;
final FlutterReactiveBle _ble;
StreamSubscription<BleStatus>? _bleStatusSubscription;
StreamSubscription<DiscoveredDevice>? _scanResultsSubscription;
Timer? _scanTimeout;
final Map<String, DiscoveredDevice> _scanResultsById = {};
final _scanResultsSubject =
BehaviorSubject<List<DiscoveredDevice>>.seeded(const []);
final _isScanningSubject = BehaviorSubject<bool>.seeded(false);
String? _connectedDeviceId;
StreamSubscription<ConnectionStateUpdate>? _connectionStateSubscription;
final _connectionStateSubject =
BehaviorSubject<(ConnectionStatus, String?)>.seeded(
(ConnectionStatus.disconnected, null));
Stream<(ConnectionStatus, String?)> get connectionStateStream =>
_connectionStateSubject.stream;
(ConnectionStatus, String?) get currentConnectionState =>
_connectionStateSubject.value;
Stream<List<DiscoveredDevice>> get scanResultsStream =>
_scanResultsSubject.stream;
Stream<bool> get isScanningStream => _isScanningSubject.stream;
List<DiscoveredDevice> get scanResults => _scanResultsSubject.value;
Future<Result<void>> init() async {
if (await FlutterBluePlus.isSupported == false) {
log.severe("Bluetooth is not supported on this device!");
return bail("Bluetooth is not supported on this device!");
}
_btStateSubscription =
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
if (state == BluetoothAdapterState.on) {
log.info("Bluetooth is on!");
// usually start scanning, connecting, etc
} else {
log.info("Bluetooth is off!");
// show an error to the user, etc
}
_bleStatusSubscription ??= _ble.statusStream.listen((status) {
log.info('BLE status: $status');
});
if (!kIsWeb && Platform.isAndroid) {
await FlutterBluePlus.turnOn();
}
return Ok(null);
}
/// Start scanning for Bluetooth devices
///
/// [withServices] - Optional list of service UUIDs to filter devices by
/// [withNames] - Optional list of device names to filter by
/// [timeout] - Optional duration after which scanning will automatically stop
Future<Result<void>> startScan({
List<Guid>? withServices,
List<String>? withNames,
List<Uuid>? withServices,
Duration? timeout,
ScanMode scanMode = ScanMode.lowLatency,
bool requireLocationServicesEnabled = true,
}) async {
try {
// Wait for Bluetooth to be enabled
await FlutterBluePlus.adapterState
.where((val) => val == BluetoothAdapterState.on)
.first;
// Set up scan results listener
_scanResultsSubscription = FlutterBluePlus.onScanResults.listen(
(results) {
if (results.isNotEmpty) {
_latestScanResults = results;
ScanResult latestResult = results.last;
log.info(
'${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!');
if (_isScanningSubject.value) {
return Ok(null);
}
},
onError: (e) {
log.severe('Scan error: $e');
},
);
// Clean up subscription when scanning completes
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
try {
final status = _ble.status;
if (status != BleStatus.ready) {
await _ble.statusStream
.where((value) => value == BleStatus.ready)
.first;
}
// Start scanning with optional parameters
await FlutterBluePlus.startScan(
withServices: withServices ?? [],
withNames: withNames ?? [],
timeout: timeout,
);
_scanTimeout?.cancel();
_scanResultsById.clear();
_scanResultsSubject.add(const []);
_isScanningSubject.add(true);
_scanResultsSubscription = _ble
.scanForDevices(
withServices: withServices ?? const [],
scanMode: scanMode,
requireLocationServicesEnabled: requireLocationServicesEnabled,
)
.listen((device) {
_scanResultsById[device.id] = device;
_scanResultsSubject
.add(_scanResultsById.values.toList(growable: false));
}, onError: (Object error, StackTrace st) {
log.severe('Scan error: $error', error, st);
_isScanningSubject.add(false);
});
if (timeout != null) {
_scanTimeout = Timer(timeout, () {
unawaited(stopScan());
});
}
return Ok(null);
} catch (e) {
_isScanningSubject.add(false);
return bail('Failed to start Bluetooth scan: $e');
}
}
/// Stop an ongoing Bluetooth scan
Future<Result<void>> stopScan() async {
try {
await FlutterBluePlus.stopScan();
_scanTimeout?.cancel();
_scanTimeout = null;
await _scanResultsSubscription?.cancel();
_scanResultsSubscription = null;
_isScanningSubject.add(false);
return Ok(null);
} catch (e) {
_isScanningSubject.add(false);
return bail('Failed to stop Bluetooth scan: $e');
}
}
/// Get the latest scan results
List<ScanResult> get scanResults => _latestScanResults;
/// Wait for the current scan to complete
Future<Result<void>> waitForScanToComplete() async {
try {
await FlutterBluePlus.isScanning.where((val) => val == false).first;
await isScanningStream.where((val) => val == false).first;
return Ok(null);
} catch (e) {
return bail('Error waiting for scan to complete: $e');
}
}
/// Check if currently scanning
Future<bool> get isScanning async {
return await FlutterBluePlus.isScanning.first;
Future<bool> get isScanning async => isScanningStream.first;
Future<Result<void>> connect(DiscoveredDevice device,
{Duration? timeout}) async {
return connectById(device.id, timeout: timeout ?? Duration(seconds: 10));
}
Future<Result<void>> connectById(
String deviceId, {
Duration timeout = const Duration(seconds: 10),
Map<Uuid, List<Uuid>>? servicesWithCharacteristicsToDiscover,
}) async {
final currentState = currentConnectionState;
final currentDeviceId = currentState.$2;
if (deviceId == currentDeviceId &&
(currentState.$1 == ConnectionStatus.connected ||
currentState.$1 == ConnectionStatus.connecting)) {
log.info('Already connected or connecting to $deviceId.');
if (currentState.$1 == ConnectionStatus.connected) {
unawaited(_requestMtuOnConnect(deviceId));
}
return Ok(null);
}
if (currentDeviceId != null && deviceId != currentDeviceId) {
final disconnectResult = await disconnect();
if (disconnectResult.isErr()) {
return disconnectResult
.context('Failed to disconnect from previous device');
}
await Future.delayed(const Duration(milliseconds: 300));
}
try {
await _connectionStateSubscription?.cancel();
_updateConnectionState(ConnectionStatus.connecting, deviceId);
_connectionStateSubscription = _ble
.connectToDevice(
id: deviceId,
connectionTimeout: timeout,
servicesWithCharacteristicsToDiscover:
servicesWithCharacteristicsToDiscover,
)
.listen((update) {
switch (update.connectionState) {
case DeviceConnectionState.connected:
_connectedDeviceId = deviceId;
_updateConnectionState(ConnectionStatus.connected, deviceId);
unawaited(_requestMtuOnConnect(deviceId));
break;
case DeviceConnectionState.connecting:
_updateConnectionState(ConnectionStatus.connecting, deviceId);
break;
case DeviceConnectionState.disconnecting:
_updateConnectionState(ConnectionStatus.disconnecting, deviceId);
break;
case DeviceConnectionState.disconnected:
_cleanUpConnection();
break;
}
}, onError: (Object error, StackTrace st) {
log.severe('Failed to connect to $deviceId: $error', error, st);
_cleanUpConnection();
});
return Ok(null);
} catch (e) {
_cleanUpConnection();
return bail('Failed to connect to $deviceId: $e');
}
}
Future<Result<void>> disconnect() async {
final deviceIdToDisconnect =
_connectedDeviceId ?? _connectionStateSubject.value.$2;
if (deviceIdToDisconnect == null) {
_cleanUpConnection();
return Ok(null);
}
_updateConnectionState(
ConnectionStatus.disconnecting, deviceIdToDisconnect);
try {
await _connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
_cleanUpConnection();
return Ok(null);
} catch (e) {
_cleanUpConnection();
return bail('Failed to disconnect from $deviceIdToDisconnect: $e');
}
}
Future<Result<List<int>>> readCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
) async {
try {
final characteristic = QualifiedCharacteristic(
serviceId: Uuid.parse(serviceUuid),
characteristicId: Uuid.parse(characteristicUuid),
deviceId: deviceId,
);
final value = await _ble.readCharacteristic(characteristic);
return Ok(value);
} catch (e) {
return bail('Error reading characteristic: $e');
}
}
Future<Result<void>> writeCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
List<int> value, {
bool withResponse = true,
}) async {
try {
final characteristic = QualifiedCharacteristic(
serviceId: Uuid.parse(serviceUuid),
characteristicId: Uuid.parse(characteristicUuid),
deviceId: deviceId,
);
if (withResponse) {
await _ble.writeCharacteristicWithResponse(
characteristic,
value: value,
);
} else {
await _ble.writeCharacteristicWithoutResponse(
characteristic,
value: value,
);
}
return Ok(null);
} catch (e) {
return bail('Error writing characteristic: $e');
}
}
Future<Result<void>> requestMtu(String deviceId,
{int mtu = defaultMtu}) async {
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
if (result.isErr()) {
return bail(result.unwrapErr());
}
return Ok(null);
}
Future<Result<int>> requestMtuAndGetValue(String deviceId,
{int mtu = defaultMtu}) async {
try {
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
log.info(
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
return Ok(negotiatedMtu);
} catch (e) {
return bail('Error requesting MTU $mtu for $deviceId: $e');
}
}
Future<void> _requestMtuOnConnect(String deviceId) async {
final mtuResult = await requestMtu(deviceId, mtu: defaultMtu);
if (mtuResult.isErr()) {
log.warning(
'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}');
}
}
Stream<List<int>> subscribeToCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
) {
final characteristic = QualifiedCharacteristic(
serviceId: Uuid.parse(serviceUuid),
characteristicId: Uuid.parse(characteristicUuid),
deviceId: deviceId,
);
return _ble.subscribeToCharacteristic(characteristic);
}
void _updateConnectionState(ConnectionStatus status, String? deviceId) {
if (_connectionStateSubject.value.$1 == status &&
_connectionStateSubject.value.$2 == deviceId) {
return;
}
_connectionStateSubject.add((status, deviceId));
log.fine(
'Connection state updated: $status, device: ${deviceId ?? 'none'}');
}
void _cleanUpConnection() {
_connectedDeviceId = null;
_updateConnectionState(ConnectionStatus.disconnected, null);
}
Future<Result<void>> dispose() async {
_scanTimeout?.cancel();
await _scanResultsSubscription?.cancel();
await _btStateSubscription?.cancel();
await _bleStatusSubscription?.cancel();
await disconnect();
await _scanResultsSubject.close();
await _isScanningSubject.close();
await _connectionStateSubject.close();
return Ok(null);
}
}

View File

@ -6,7 +6,24 @@ part of 'bluetooth.dart';
// RiverpodGenerator
// **************************************************************************
String _$bluetoothHash() => r'5e9c37c57e723b84dd08fd8763e7c445b3a4dbf3';
String _$bluetoothHash() => r'f1fb75c72a7a473fc545baea6bedfdf4a21ab26b';
String _$reactiveBleHash() => r'9c4d4f37f7a0da1741b42d6a4c3f0f00c2c07f3c';
/// See also [reactiveBle].
@ProviderFor(reactiveBle)
final reactiveBleProvider = AutoDisposeProvider<FlutterReactiveBle>.internal(
reactiveBle,
name: r'reactiveBleProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$reactiveBleHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ReactiveBleRef = AutoDisposeProviderRef<FlutterReactiveBle>;
/// See also [bluetooth].
@ProviderFor(bluetooth)
@ -23,5 +40,24 @@ final bluetoothProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef BluetoothRef = AutoDisposeFutureProviderRef<BluetoothController>;
String _$connectionStatusHash() => r'4dba587ef7703dabca4b2b9800e0798decfe2977';
/// See also [connectionStatus].
@ProviderFor(connectionStatus)
final connectionStatusProvider =
AutoDisposeStreamProvider<(ConnectionStatus, String?)>.internal(
connectionStatus,
name: r'connectionStatusProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$connectionStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ConnectionStatusRef
= AutoDisposeStreamProviderRef<(ConnectionStatus, String?)>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -0,0 +1,627 @@
import 'dart:async';
import 'dart:io';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'bluetooth.g.dart';
final log = Logger('BluetoothController');
@Riverpod(keepAlive: true)
Future<BluetoothController> bluetooth(Ref ref) async {
ref.keepAlive();
final controller = BluetoothController();
log.info(await controller.init());
return controller;
}
@Riverpod(keepAlive: true)
Stream<(ConnectionStatus, BluetoothDevice?)> connectionStatus(Ref ref) {
// Get the (potentially still loading) BluetoothController
final asyncController = ref.watch(bluetoothProvider);
// If the controller is ready, return its stream. Otherwise, return an empty stream.
// The provider will automatically update when the controller becomes ready.
return asyncController.when(
data: (controller) => controller.connectionStateStream,
loading: () => Stream.value((ConnectionStatus.disconnected, null)),
error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)),
);
}
/// Represents the connection status of the Bluetooth device.
enum ConnectionStatus { disconnected, connecting, connected, disconnecting }
class BluetoothController {
StreamSubscription<BluetoothAdapterState>? _btStateSubscription;
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
List<ScanResult> _latestScanResults = [];
StreamSubscription<void>? _servicesResetSubscription;
final Map<String, Map<Guid, BluetoothService>> _servicesByDevice = {};
final Map<String, Map<String, BluetoothCharacteristic>>
_characteristicsByDevice = {};
// Connection State
BluetoothDevice? _connectedDevice;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
final _connectionStateSubject =
BehaviorSubject<(ConnectionStatus, BluetoothDevice?)>.seeded(
(ConnectionStatus.disconnected, null));
/// Stream providing the current connection status and the connected device (if any).
Stream<(ConnectionStatus, BluetoothDevice?)> get connectionStateStream =>
_connectionStateSubject.stream;
/// Gets the latest connection status and device.
(ConnectionStatus, BluetoothDevice?) get currentConnectionState =>
_connectionStateSubject.value;
Future<Result<void>> init() async {
log.severe("CALLED FBPON!");
if (await FlutterBluePlus.isSupported == false) {
log.severe("Bluetooth is not supported on this device!");
return bail("Bluetooth is not supported on this device!");
}
_btStateSubscription =
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
if (state == BluetoothAdapterState.on) {
log.info("Bluetooth is on!");
// usually start scanning, connecting, etc
} else {
log.info("Bluetooth is off!");
// show an error to the user, etc
}
});
if (!kIsWeb && Platform.isAndroid) {
await FlutterBluePlus.turnOn();
}
connectionStateStream.listen((state) {
log.info('Connection state changed: $state');
});
return Ok(null);
}
/// Start scanning for Bluetooth devices
///
/// [withServices] - Optional list of service UUIDs to filter devices by
/// [withNames] - Optional list of device names to filter by
/// [timeout] - Optional duration after which scanning will automatically stop
Future<Result<void>> startScan({
List<Guid>? withServices,
List<String>? withNames,
Duration? timeout,
}) async {
try {
// Wait for Bluetooth to be enabled
await FlutterBluePlus.adapterState
.where((val) => val == BluetoothAdapterState.on)
.first;
// Set up scan results listener
_scanResultsSubscription = FlutterBluePlus.onScanResults.listen(
(results) {
if (results.isNotEmpty) {
_latestScanResults = results;
ScanResult latestResult = results.last;
log.info(
'${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!');
}
},
onError: (e) {
log.severe('Scan error: $e');
},
);
// Clean up subscription when scanning completes
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
// Start scanning with optional parameters
await FlutterBluePlus.startScan(
withServices: withServices ?? [],
withNames: withNames ?? [],
timeout: timeout,
);
return Ok(null);
} catch (e) {
return bail('Failed to start Bluetooth scan: $e');
}
}
/// Stop an ongoing Bluetooth scan
Future<Result<void>> stopScan() async {
try {
await FlutterBluePlus.stopScan();
return Ok(null);
} catch (e) {
return bail('Failed to stop Bluetooth scan: $e');
}
}
/// Get the latest scan results
List<ScanResult> get scanResults => _latestScanResults;
/// Wait for the current scan to complete
Future<Result<void>> waitForScanToComplete() async {
try {
await FlutterBluePlus.isScanning.where((val) => val == false).first;
return Ok(null);
} catch (e) {
return bail('Error waiting for scan to complete: $e');
}
}
/// Check if currently scanning
Future<bool> get isScanning async {
return await FlutterBluePlus.isScanning.first;
}
/// Connects to a specific Bluetooth device.
///
/// Ensures that only one device is connected at a time. If another device
/// is already connected or connecting, it will be disconnected first.
Future<Result<void>> connect(BluetoothDevice device,
{Duration? timeout}) async {
final currentState = currentConnectionState;
final currentDevice = currentState.$2;
// Prevent connecting if already connected/connecting to the *same* device
if (device.remoteId == currentDevice?.remoteId &&
(currentState.$1 == ConnectionStatus.connected ||
currentState.$1 == ConnectionStatus.connecting)) {
log.info('Currently connected device: ${currentState.$2}');
log.info('Already connected or connecting to ${device.remoteId}.');
return Ok(null); // Or potentially an error/different status?
}
log.info('Attempting to connect to ${device.remoteId}...');
// If connecting or connected to a *different* device, disconnect it first.
if (currentDevice != null && device.remoteId != currentDevice.remoteId) {
log.info(
'Disconnecting from previous device ${currentDevice.remoteId} first.');
final disconnectResult = await disconnect();
if (disconnectResult.isErr()) {
return disconnectResult
.context('Failed to disconnect from previous device');
}
// Wait a moment for the disconnection to fully process
await Future.delayed(const Duration(milliseconds: 500));
}
try {
// Cancel any previous connection state listener before starting a new one
await _connectionStateSubscription?.cancel();
_connectionStateSubscription =
device.connectionState.listen((BluetoothConnectionState state) async {
log.info('[${device.remoteId}] Connection state changed: $state');
switch (state) {
case BluetoothConnectionState.connected:
_connectedDevice = device;
_updateConnectionState(ConnectionStatus.connected, device);
// IMPORTANT: Discover services after connecting
try {
_attachServicesResetListener(device);
final servicesResult =
await _discoverAndCacheServices(device, force: true);
if (servicesResult.isErr()) {
throw servicesResult.unwrapErr();
}
log.info(
'[${device.remoteId}] Services discovered: \n${servicesResult.unwrap().map((e) => e.uuid.toString()).join('\n')}');
} catch (e) {
log.severe(
'[${device.remoteId}] Error discovering services: $e. Disconnecting.');
// Disconnect if service discovery fails, as the connection might be unusable
await disconnect();
}
break;
case BluetoothConnectionState.disconnected:
if (_connectionStateSubject.value.$1 !=
ConnectionStatus.connected) {
log.warning(
'[${device.remoteId}] Disconnected WITHOUT being connected! Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}\nDoing nothing');
break;
} else {
log.warning(
'[${device.remoteId}] Disconnected. Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}');
// Only clean up if this is the device we were connected/connecting to
if (_connectionStateSubject.value.$2?.remoteId ==
device.remoteId) {
// Clean up connection state, handling disconnection.
// In general, reconnection is better, but this is how it's handled here.
// App behavior would be to go back to the homepage on disconnection
_cleanUpConnection();
} else {
log.info(
'[${device.remoteId}] Received disconnect for a device we were not tracking.');
}
break;
}
case BluetoothConnectionState.connecting:
case BluetoothConnectionState.disconnecting:
// deprecated states
log.warning(
'Received unexpected connection state: ${device.connectionState}. This should not happen.');
break;
}
});
await device.connect(
license: License.free,
timeout: timeout ?? const Duration(seconds: 15),
mtu: 512,
);
// Note: Success is primarily handled by the connectionState listener
log.info(
'Connection initiated for ${device.remoteId}. Waiting for state change.');
_connectionStateSubject.add((ConnectionStatus.connected, device));
return Ok(null);
} catch (e) {
log.severe('Failed to connect to ${device.remoteId}: $e');
_cleanUpConnection(); // Clean up state on connection failure
return bail('Failed to connect to ${device.remoteId}: $e');
}
}
/// Connects to a device using its remote ID string with a specific timeout.
Future<Result<void>> connectById(String remoteId,
{Duration timeout = const Duration(seconds: 10)}) async {
log.info('Attempting to connect by ID: $remoteId with timeout: $timeout');
try {
// Get the BluetoothDevice object from the ID
final device = BluetoothDevice.fromId(remoteId);
// Call the existing connect method, passing the device and timeout
// Assumes the 'connect' method below is modified to accept the timeout.
return await connect(device, timeout: timeout); // Pass timeout here
} catch (e, st) {
// Catch potential errors from fromId or during connection setup before connect() is called
log.severe('Error connecting by ID $remoteId: $e');
_cleanUpConnection(); // Ensure state is cleaned up
return bail('Failed to initiate connection for ID $remoteId: $e', st);
}
}
/// Disconnects from the currently connected device.
Future<Result<void>> disconnect() async {
final deviceToDisconnect =
_connectedDevice ?? _connectionStateSubject.value.$2;
if (deviceToDisconnect == null) {
log.info('No device is currently connected or connecting.');
// Ensure state is definitely disconnected if called unnecessarily
_cleanUpConnection();
return Ok(null);
}
log.info('Disconnecting from ${deviceToDisconnect.remoteId}...');
_updateConnectionState(ConnectionStatus.disconnecting, deviceToDisconnect);
try {
await deviceToDisconnect.disconnect();
log.info('Disconnect command sent to ${deviceToDisconnect.remoteId}.');
// State update to disconnected is handled by the connectionState listener
// but we call cleanup here as a safety measure in case the listener fails
_cleanUpConnection();
return Ok(null);
} catch (e) {
log.severe(
'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e');
// Even on error, try to clean up the state
_cleanUpConnection();
return bail(
'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e');
}
}
void _updateConnectionState(
ConnectionStatus status, BluetoothDevice? device) {
// Avoid emitting redundant states
if (_connectionStateSubject.value.$1 == status &&
_connectionStateSubject.value.$2?.remoteId == device?.remoteId) {
return;
}
_connectionStateSubject.add((status, device));
log.fine(
'Connection state updated: $status, Device: ${device?.remoteId ?? 'none'}');
}
Future<Result<List<BluetoothService>>> discoverServices(
BluetoothDevice device, {
bool force = false,
}) async {
return _discoverAndCacheServices(device, force: force);
}
Future<Result<void>> writeCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid,
List<int> value, {
bool withoutResponse = false,
bool allowLongWrite = false,
int timeout = 15,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return chrResult.context('Failed to resolve characteristic for write');
}
try {
await chrResult.unwrap().write(
value,
withoutResponse: withoutResponse,
allowLongWrite: allowLongWrite,
timeout: timeout,
);
return Ok(null);
} catch (e) {
return bail('Error writing characteristic $characteristicUuid: $e');
}
}
Future<Result<StreamSubscription<List<int>>>> subscribeToNotifications(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
void Function(List<int>)? onValue,
bool useLastValueStream = false,
int timeout = 15,
}) async {
return _subscribeToCharacteristic(
device,
serviceUuid,
characteristicUuid,
useLastValueStream: useLastValueStream,
timeout: timeout,
forceIndications: false,
onValue: onValue,
);
}
Future<Result<StreamSubscription<List<int>>>> subscribeToIndications(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
void Function(List<int>)? onValue,
bool useLastValueStream = false,
int timeout = 15,
}) async {
return _subscribeToCharacteristic(
device,
serviceUuid,
characteristicUuid,
useLastValueStream: useLastValueStream,
timeout: timeout,
forceIndications: true,
onValue: onValue,
);
}
Future<Result<void>> unsubscribeFromCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
int timeout = 15,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return chrResult
.context('Failed to resolve characteristic to unsubscribe');
}
try {
await chrResult.unwrap().setNotifyValue(false, timeout: timeout);
return Ok(null);
} catch (e) {
return bail('Error disabling notifications for $characteristicUuid: $e');
}
}
/// Helper function to clean up connection resources and state.
Future<void> _cleanUpConnection() async {
log.fine('Cleaning up connection state and subscriptions.');
_connectedDevice = null;
await _servicesResetSubscription?.cancel();
_servicesResetSubscription = null;
_servicesByDevice.clear();
_characteristicsByDevice.clear();
await _connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
_updateConnectionState(ConnectionStatus.disconnected, null);
}
Future<Result<void>> dispose() async {
await _scanResultsSubscription?.cancel();
await _btStateSubscription?.cancel();
await disconnect(); // Ensure disconnection on dispose
await _connectionStateSubject.close();
return Ok(null);
}
Future<Result<List<int>>> readCharacteristic(
BluetoothDevice device, String svcUuid, String characteristic) async {
// Implement reading characteristic logic here
// This is a placeholder implementation
log.info(
'Reading characteristic from device: $device, characteristic: $characteristic');
final serviceUUID = Guid(svcUuid);
final characteristicUUID = Guid(characteristic);
if (!device.servicesList.map((e) => e.uuid).contains(serviceUUID)) {
return bail('Service $svcUuid not found on device ${device.remoteId}');
}
final BluetoothService service =
(device.servicesList).firstWhere((s) => s.uuid == serviceUUID);
if (service.characteristics.isEmpty ||
!service.characteristics
.map((c) => c.uuid)
.contains(characteristicUUID)) {
return bail(
'Characteristic $characteristic not found on device ${device.remoteId}');
}
try {
final val = await service.characteristics
.firstWhere((c) => c.uuid == characteristicUUID)
.read();
return Ok(val);
} catch (e) {
return bail('Error reading characteristic: $e');
}
}
String _deviceKey(BluetoothDevice device) => device.remoteId.str;
String _characteristicKey(Guid serviceUuid, Guid characteristicUuid) =>
'${serviceUuid.toString()}|${characteristicUuid.toString()}';
void _cacheServices(BluetoothDevice device, List<BluetoothService> services) {
final serviceMap = <Guid, BluetoothService>{};
final characteristicMap = <String, BluetoothCharacteristic>{};
for (final service in services) {
serviceMap[service.uuid] = service;
for (final chr in service.characteristics) {
characteristicMap[_characteristicKey(service.uuid, chr.uuid)] = chr;
}
}
_servicesByDevice[_deviceKey(device)] = serviceMap;
_characteristicsByDevice[_deviceKey(device)] = characteristicMap;
}
void _attachServicesResetListener(BluetoothDevice device) {
_servicesResetSubscription?.cancel();
_servicesResetSubscription = device.onServicesReset.listen((_) async {
log.info('[${device.remoteId}] Services reset. Re-discovering.');
final res = await _discoverAndCacheServices(device, force: true);
if (res.isErr()) {
log.severe(
'[${device.remoteId}] Failed to re-discover services: ${res.unwrapErr()}');
}
});
device.cancelWhenDisconnected(_servicesResetSubscription!);
}
Future<Result<List<BluetoothService>>> _discoverAndCacheServices(
BluetoothDevice device, {
bool force = false,
}) async {
try {
if (!force) {
final cached = _servicesByDevice[_deviceKey(device)];
if (cached != null && cached.isNotEmpty) {
return Ok(cached.values.toList());
}
}
if (!force && device.servicesList.isNotEmpty) {
_cacheServices(device, device.servicesList);
return Ok(device.servicesList);
}
final services = await device.discoverServices();
_cacheServices(device, services);
return Ok(services);
} catch (e) {
return bail('Failed to discover services for ${device.remoteId}: $e');
}
}
Future<Result<BluetoothCharacteristic>> _getCharacteristic(
BluetoothDevice device,
Guid serviceUuid,
Guid characteristicUuid,
) async {
final deviceKey = _deviceKey(device);
final cached = _characteristicsByDevice[deviceKey]
?[_characteristicKey(serviceUuid, characteristicUuid)];
if (cached != null) {
return Ok(cached);
}
final discoverResult = await _discoverAndCacheServices(device);
if (discoverResult.isErr()) {
return bail(discoverResult.unwrapErr().toString());
}
final refreshed = _characteristicsByDevice[deviceKey]
?[_characteristicKey(serviceUuid, characteristicUuid)];
if (refreshed == null) {
return bail(
'Characteristic $characteristicUuid not found on service $serviceUuid for device ${device.remoteId}');
}
return Ok(refreshed);
}
Future<Result<StreamSubscription<List<int>>>> _subscribeToCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
required bool forceIndications,
required bool useLastValueStream,
required int timeout,
void Function(List<int>)? onValue,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return bail('Failed to resolve characteristic subscription: '
'${chrResult.unwrapErr()}');
}
final characteristic = chrResult.unwrap();
final properties = characteristic.properties;
if (forceIndications && !properties.indicate) {
return bail(
'Characteristic $characteristicUuid does not support indications');
}
if (!forceIndications && !properties.notify && !properties.indicate) {
return bail(
'Characteristic $characteristicUuid does not support notifications');
}
if (forceIndications && !kIsWeb && !Platform.isAndroid) {
return bail('Indications can only be forced on Android.');
}
try {
final stream = useLastValueStream
? characteristic.lastValueStream
: characteristic.onValueReceived;
final subscription = stream.listen(onValue ?? (_) {});
device.cancelWhenDisconnected(subscription);
await characteristic.setNotifyValue(
true,
timeout: timeout,
forceIndications: forceIndications,
);
return Ok(subscription);
} catch (e) {
return bail(
'Error subscribing to characteristic $characteristicUuid: $e');
}
}
}

View File

@ -0,0 +1 @@

143
lib/database/database.dart Normal file
View File

@ -0,0 +1,143 @@
import 'package:anyhow/anyhow.dart';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'database.g.dart';
@riverpod
class NConnectedDevices extends _$NConnectedDevices {
@override
Future<List<ConnectedDevice>> build() async {
final db = await ref.watch(databaseProvider);
return await db.getAllConnectedDevices();
}
Future<Result<int>> addConnectedDevice(
ConnectedDevicesCompanion device) async {
final db = await ref.watch(databaseProvider);
final res = await db.addConnectedDevice(device);
if (res.isOk()) {
ref.invalidateSelf();
}
return res;
}
Future<Result<void>> deleteConnectedDevice(int id) async {
final db = await ref.watch(databaseProvider);
final res = await db.deleteConnectedDevice(id);
if (res.isOk()) {
ref.invalidateSelf();
}
return res;
}
}
/// Provider for the [AppDatabase] instance
final databaseProvider = Provider<AppDatabase>((ref) {
final database = AppDatabase();
ref.onDispose(() => database.close());
return database;
});
/// Provider for all connected devices as a stream
final connectedDevicesStreamProvider =
StreamProvider<List<ConnectedDevice>>((ref) {
final database = ref.watch(databaseProvider);
return database.getAllConnectedDevicesStream();
});
/// Provider for all connected devices as a future
final connectedDevicesProvider = FutureProvider<List<ConnectedDevice>>((ref) {
final database = ref.watch(databaseProvider);
return database.getAllConnectedDevices();
});
class ConnectedDevices extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get deviceName => text()();
TextColumn get deviceAddress => text()();
TextColumn get deviceType => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get lastConnectedAt => dateTime().nullable()();
}
@DriftDatabase(tables: [ConnectedDevices])
class AppDatabase extends _$AppDatabase {
// After generating code, this class needs to define a `schemaVersion` getter
// and a constructor telling drift where the database should be stored.
// These are described in the getting started guide: https://drift.simonbinder.eu/setup/
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'my_database',
native: const DriftNativeOptions(
// By default, `driftDatabase` from `package:drift_flutter` stores the
// database files in `getApplicationDocumentsDirectory()`.
databaseDirectory: getApplicationSupportDirectory,
),
// If you need web support, see https://drift.simonbinder.eu/platforms/web/
);
}
Future<List<ConnectedDevice>> getAllConnectedDevices() {
return select(connectedDevices).get();
}
/// Adds a new connected device to the database.
///
/// [device] is a [ConnectedDevicesCompanion] representing the device to be inserted.
///
/// Returns a [Result] indicating success or failure of the device insertion.
/// On successful insertion, returns [Ok]\(rowid\). On failure, returns an error with a descriptive message.
Future<Result<int>> addConnectedDevice(
ConnectedDevicesCompanion device) async {
try {
if (!device.deviceAddress.present) {
return bail('Device address is required to save a connected device.');
}
final exists = await (select(connectedDevices)
..where(
(tbl) => tbl.deviceAddress.equals(device.deviceAddress.value)))
.getSingleOrNull();
if (exists != null) {
return bail('Device ${device.deviceAddress.value} is already added.');
}
final rowid = await into(connectedDevices).insert(device);
return Ok(rowid);
} catch (e, st) {
return bail('Failed to add device: $e', st);
}
}
/// Deletes a connected device from the database by its ID.
///
/// [id] is the ID of the device to be deleted.
///
/// Returns a [Result] indicating success or failure of the deletion.
/// On successful deletion, returns [Ok](number of deleted rows). On failure, returns an error with a descriptive message.
Future<Result<void>> deleteConnectedDevice(int id) async {
try {
final count = await (delete(connectedDevices)
..where((tbl) => tbl.id.equals(id)))
.go();
if (count == 0) {
return bail('Device with id $id not found.');
}
return Ok(());
} catch (e, st) {
return bail('Failed to delete device with id $id: $e', st);
}
}
Stream<List<ConnectedDevice>> getAllConnectedDevicesStream() {
return select(connectedDevices).watch();
}
}

View File

@ -0,0 +1,586 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'database.dart';
// ignore_for_file: type=lint
class $ConnectedDevicesTable extends ConnectedDevices
with TableInfo<$ConnectedDevicesTable, ConnectedDevice> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ConnectedDevicesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _deviceNameMeta =
const VerificationMeta('deviceName');
@override
late final GeneratedColumn<String> deviceName = GeneratedColumn<String>(
'device_name', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _deviceAddressMeta =
const VerificationMeta('deviceAddress');
@override
late final GeneratedColumn<String> deviceAddress = GeneratedColumn<String>(
'device_address', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _deviceTypeMeta =
const VerificationMeta('deviceType');
@override
late final GeneratedColumn<String> deviceType = GeneratedColumn<String>(
'device_type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
static const VerificationMeta _lastConnectedAtMeta =
const VerificationMeta('lastConnectedAt');
@override
late final GeneratedColumn<DateTime> lastConnectedAt =
GeneratedColumn<DateTime>('last_connected_at', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns =>
[id, deviceName, deviceAddress, deviceType, createdAt, lastConnectedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'connected_devices';
@override
VerificationContext validateIntegrity(Insertable<ConnectedDevice> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('device_name')) {
context.handle(
_deviceNameMeta,
deviceName.isAcceptableOrUnknown(
data['device_name']!, _deviceNameMeta));
} else if (isInserting) {
context.missing(_deviceNameMeta);
}
if (data.containsKey('device_address')) {
context.handle(
_deviceAddressMeta,
deviceAddress.isAcceptableOrUnknown(
data['device_address']!, _deviceAddressMeta));
} else if (isInserting) {
context.missing(_deviceAddressMeta);
}
if (data.containsKey('device_type')) {
context.handle(
_deviceTypeMeta,
deviceType.isAcceptableOrUnknown(
data['device_type']!, _deviceTypeMeta));
} else if (isInserting) {
context.missing(_deviceTypeMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('last_connected_at')) {
context.handle(
_lastConnectedAtMeta,
lastConnectedAt.isAcceptableOrUnknown(
data['last_connected_at']!, _lastConnectedAtMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
ConnectedDevice map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ConnectedDevice(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
deviceName: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}device_name'])!,
deviceAddress: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}device_address'])!,
deviceType: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}device_type'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
lastConnectedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}last_connected_at']),
);
}
@override
$ConnectedDevicesTable createAlias(String alias) {
return $ConnectedDevicesTable(attachedDatabase, alias);
}
}
class ConnectedDevice extends DataClass implements Insertable<ConnectedDevice> {
final int id;
final String deviceName;
final String deviceAddress;
final String deviceType;
final DateTime createdAt;
final DateTime? lastConnectedAt;
const ConnectedDevice(
{required this.id,
required this.deviceName,
required this.deviceAddress,
required this.deviceType,
required this.createdAt,
this.lastConnectedAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['device_name'] = Variable<String>(deviceName);
map['device_address'] = Variable<String>(deviceAddress);
map['device_type'] = Variable<String>(deviceType);
map['created_at'] = Variable<DateTime>(createdAt);
if (!nullToAbsent || lastConnectedAt != null) {
map['last_connected_at'] = Variable<DateTime>(lastConnectedAt);
}
return map;
}
ConnectedDevicesCompanion toCompanion(bool nullToAbsent) {
return ConnectedDevicesCompanion(
id: Value(id),
deviceName: Value(deviceName),
deviceAddress: Value(deviceAddress),
deviceType: Value(deviceType),
createdAt: Value(createdAt),
lastConnectedAt: lastConnectedAt == null && nullToAbsent
? const Value.absent()
: Value(lastConnectedAt),
);
}
factory ConnectedDevice.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ConnectedDevice(
id: serializer.fromJson<int>(json['id']),
deviceName: serializer.fromJson<String>(json['deviceName']),
deviceAddress: serializer.fromJson<String>(json['deviceAddress']),
deviceType: serializer.fromJson<String>(json['deviceType']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
lastConnectedAt: serializer.fromJson<DateTime?>(json['lastConnectedAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'deviceName': serializer.toJson<String>(deviceName),
'deviceAddress': serializer.toJson<String>(deviceAddress),
'deviceType': serializer.toJson<String>(deviceType),
'createdAt': serializer.toJson<DateTime>(createdAt),
'lastConnectedAt': serializer.toJson<DateTime?>(lastConnectedAt),
};
}
ConnectedDevice copyWith(
{int? id,
String? deviceName,
String? deviceAddress,
String? deviceType,
DateTime? createdAt,
Value<DateTime?> lastConnectedAt = const Value.absent()}) =>
ConnectedDevice(
id: id ?? this.id,
deviceName: deviceName ?? this.deviceName,
deviceAddress: deviceAddress ?? this.deviceAddress,
deviceType: deviceType ?? this.deviceType,
createdAt: createdAt ?? this.createdAt,
lastConnectedAt: lastConnectedAt.present
? lastConnectedAt.value
: this.lastConnectedAt,
);
ConnectedDevice copyWithCompanion(ConnectedDevicesCompanion data) {
return ConnectedDevice(
id: data.id.present ? data.id.value : this.id,
deviceName:
data.deviceName.present ? data.deviceName.value : this.deviceName,
deviceAddress: data.deviceAddress.present
? data.deviceAddress.value
: this.deviceAddress,
deviceType:
data.deviceType.present ? data.deviceType.value : this.deviceType,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
lastConnectedAt: data.lastConnectedAt.present
? data.lastConnectedAt.value
: this.lastConnectedAt,
);
}
@override
String toString() {
return (StringBuffer('ConnectedDevice(')
..write('id: $id, ')
..write('deviceName: $deviceName, ')
..write('deviceAddress: $deviceAddress, ')
..write('deviceType: $deviceType, ')
..write('createdAt: $createdAt, ')
..write('lastConnectedAt: $lastConnectedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
id, deviceName, deviceAddress, deviceType, createdAt, lastConnectedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ConnectedDevice &&
other.id == this.id &&
other.deviceName == this.deviceName &&
other.deviceAddress == this.deviceAddress &&
other.deviceType == this.deviceType &&
other.createdAt == this.createdAt &&
other.lastConnectedAt == this.lastConnectedAt);
}
class ConnectedDevicesCompanion extends UpdateCompanion<ConnectedDevice> {
final Value<int> id;
final Value<String> deviceName;
final Value<String> deviceAddress;
final Value<String> deviceType;
final Value<DateTime> createdAt;
final Value<DateTime?> lastConnectedAt;
const ConnectedDevicesCompanion({
this.id = const Value.absent(),
this.deviceName = const Value.absent(),
this.deviceAddress = const Value.absent(),
this.deviceType = const Value.absent(),
this.createdAt = const Value.absent(),
this.lastConnectedAt = const Value.absent(),
});
ConnectedDevicesCompanion.insert({
this.id = const Value.absent(),
required String deviceName,
required String deviceAddress,
required String deviceType,
this.createdAt = const Value.absent(),
this.lastConnectedAt = const Value.absent(),
}) : deviceName = Value(deviceName),
deviceAddress = Value(deviceAddress),
deviceType = Value(deviceType);
static Insertable<ConnectedDevice> custom({
Expression<int>? id,
Expression<String>? deviceName,
Expression<String>? deviceAddress,
Expression<String>? deviceType,
Expression<DateTime>? createdAt,
Expression<DateTime>? lastConnectedAt,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (deviceName != null) 'device_name': deviceName,
if (deviceAddress != null) 'device_address': deviceAddress,
if (deviceType != null) 'device_type': deviceType,
if (createdAt != null) 'created_at': createdAt,
if (lastConnectedAt != null) 'last_connected_at': lastConnectedAt,
});
}
ConnectedDevicesCompanion copyWith(
{Value<int>? id,
Value<String>? deviceName,
Value<String>? deviceAddress,
Value<String>? deviceType,
Value<DateTime>? createdAt,
Value<DateTime?>? lastConnectedAt}) {
return ConnectedDevicesCompanion(
id: id ?? this.id,
deviceName: deviceName ?? this.deviceName,
deviceAddress: deviceAddress ?? this.deviceAddress,
deviceType: deviceType ?? this.deviceType,
createdAt: createdAt ?? this.createdAt,
lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (deviceName.present) {
map['device_name'] = Variable<String>(deviceName.value);
}
if (deviceAddress.present) {
map['device_address'] = Variable<String>(deviceAddress.value);
}
if (deviceType.present) {
map['device_type'] = Variable<String>(deviceType.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (lastConnectedAt.present) {
map['last_connected_at'] = Variable<DateTime>(lastConnectedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ConnectedDevicesCompanion(')
..write('id: $id, ')
..write('deviceName: $deviceName, ')
..write('deviceAddress: $deviceAddress, ')
..write('deviceType: $deviceType, ')
..write('createdAt: $createdAt, ')
..write('lastConnectedAt: $lastConnectedAt')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ConnectedDevicesTable connectedDevices =
$ConnectedDevicesTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [connectedDevices];
}
typedef $$ConnectedDevicesTableCreateCompanionBuilder
= ConnectedDevicesCompanion Function({
Value<int> id,
required String deviceName,
required String deviceAddress,
required String deviceType,
Value<DateTime> createdAt,
Value<DateTime?> lastConnectedAt,
});
typedef $$ConnectedDevicesTableUpdateCompanionBuilder
= ConnectedDevicesCompanion Function({
Value<int> id,
Value<String> deviceName,
Value<String> deviceAddress,
Value<String> deviceType,
Value<DateTime> createdAt,
Value<DateTime?> lastConnectedAt,
});
class $$ConnectedDevicesTableFilterComposer
extends Composer<_$AppDatabase, $ConnectedDevicesTable> {
$$ConnectedDevicesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get deviceName => $composableBuilder(
column: $table.deviceName, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get deviceAddress => $composableBuilder(
column: $table.deviceAddress, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get deviceType => $composableBuilder(
column: $table.deviceType, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastConnectedAt => $composableBuilder(
column: $table.lastConnectedAt,
builder: (column) => ColumnFilters(column));
}
class $$ConnectedDevicesTableOrderingComposer
extends Composer<_$AppDatabase, $ConnectedDevicesTable> {
$$ConnectedDevicesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get deviceName => $composableBuilder(
column: $table.deviceName, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get deviceAddress => $composableBuilder(
column: $table.deviceAddress,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get deviceType => $composableBuilder(
column: $table.deviceType, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastConnectedAt => $composableBuilder(
column: $table.lastConnectedAt,
builder: (column) => ColumnOrderings(column));
}
class $$ConnectedDevicesTableAnnotationComposer
extends Composer<_$AppDatabase, $ConnectedDevicesTable> {
$$ConnectedDevicesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get deviceName => $composableBuilder(
column: $table.deviceName, builder: (column) => column);
GeneratedColumn<String> get deviceAddress => $composableBuilder(
column: $table.deviceAddress, builder: (column) => column);
GeneratedColumn<String> get deviceType => $composableBuilder(
column: $table.deviceType, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get lastConnectedAt => $composableBuilder(
column: $table.lastConnectedAt, builder: (column) => column);
}
class $$ConnectedDevicesTableTableManager extends RootTableManager<
_$AppDatabase,
$ConnectedDevicesTable,
ConnectedDevice,
$$ConnectedDevicesTableFilterComposer,
$$ConnectedDevicesTableOrderingComposer,
$$ConnectedDevicesTableAnnotationComposer,
$$ConnectedDevicesTableCreateCompanionBuilder,
$$ConnectedDevicesTableUpdateCompanionBuilder,
(
ConnectedDevice,
BaseReferences<_$AppDatabase, $ConnectedDevicesTable, ConnectedDevice>
),
ConnectedDevice,
PrefetchHooks Function()> {
$$ConnectedDevicesTableTableManager(
_$AppDatabase db, $ConnectedDevicesTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ConnectedDevicesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ConnectedDevicesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$ConnectedDevicesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<String> deviceName = const Value.absent(),
Value<String> deviceAddress = const Value.absent(),
Value<String> deviceType = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime?> lastConnectedAt = const Value.absent(),
}) =>
ConnectedDevicesCompanion(
id: id,
deviceName: deviceName,
deviceAddress: deviceAddress,
deviceType: deviceType,
createdAt: createdAt,
lastConnectedAt: lastConnectedAt,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
required String deviceName,
required String deviceAddress,
required String deviceType,
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime?> lastConnectedAt = const Value.absent(),
}) =>
ConnectedDevicesCompanion.insert(
id: id,
deviceName: deviceName,
deviceAddress: deviceAddress,
deviceType: deviceType,
createdAt: createdAt,
lastConnectedAt: lastConnectedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$ConnectedDevicesTableProcessedTableManager = ProcessedTableManager<
_$AppDatabase,
$ConnectedDevicesTable,
ConnectedDevice,
$$ConnectedDevicesTableFilterComposer,
$$ConnectedDevicesTableOrderingComposer,
$$ConnectedDevicesTableAnnotationComposer,
$$ConnectedDevicesTableCreateCompanionBuilder,
$$ConnectedDevicesTableUpdateCompanionBuilder,
(
ConnectedDevice,
BaseReferences<_$AppDatabase, $ConnectedDevicesTable, ConnectedDevice>
),
ConnectedDevice,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$ConnectedDevicesTableTableManager get connectedDevices =>
$$ConnectedDevicesTableTableManager(_db, _db.connectedDevices);
}
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$nConnectedDevicesHash() => r'022e744d950bb37c1016266064639e51ce6031b5';
/// See also [NConnectedDevices].
@ProviderFor(NConnectedDevices)
final nConnectedDevicesProvider = AutoDisposeAsyncNotifierProvider<
NConnectedDevices, List<ConnectedDevice>>.internal(
NConnectedDevices.new,
name: r'nConnectedDevicesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$nConnectedDevicesHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$NConnectedDevices = AutoDisposeAsyncNotifier<List<ConnectedDevice>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,19 +1,23 @@
import 'package:abawo_bt_app/pages/devices_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
import 'package:abawo_bt_app/util/sharedPrefs.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nb_utils/nb_utils.dart';
import 'pages/home_page.dart';
import 'pages/settings_page.dart';
import 'package:abawo_bt_app/pages/device_details_page.dart';
Future<void> main() async {
Logger.root.level = Level.ALL; // defaults to Level.INFO
Logger.root.onRecord.listen((record) {
print('${record.level.name}: ${record.time}: ${record.message}');
});
await RustLib.init();
WidgetsFlutterBinding.ensureInitialized();
await initialize();
final prefs = await SharedPreferences.getInstance();
@ -49,6 +53,7 @@ class AbawoBtApp extends StatelessWidget {
// Configure GoRouter
final _router = GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/',
routes: [
GoRoute(
@ -65,5 +70,42 @@ final _router = GoRouter(
)
],
),
GoRoute(
path: '/device/:deviceAddress',
builder: (context, state) {
final deviceAddress = state.pathParameters['deviceAddress']!;
return DeviceDetailsPage(deviceAddress: deviceAddress);
},
),
],
);
/*
import 'package:flutter/material.dart';
import 'package:abawo_bt_app/src/rust/api/simple.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
Future<void> main() async {
await RustLib.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('flutter_rust_bridge quickstart')),
body: Center(
child: Text(
'Action: Call Rust `greet("Tom")`\nResult: `${greet(name: "Tom")}`'),
),
),
);
}
}
*/

View File

@ -1,3 +1,6 @@
import 'package:abawo_bt_app/util/constants.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' show DeviceIdentifier;
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'bluetooth_device_model.freezed.dart';
@ -12,6 +15,24 @@ enum DeviceType {
other,
}
DeviceType deviceTypeFromUuids(List<Uuid> uuids) {
if (uuids.any((uuid) => isAbawoUniversalShiftersDeviceGuid(uuid))) {
return DeviceType.universalShifters;
}
return DeviceType.other;
}
DeviceType deviceTypeFromString(String type) {
return DeviceType.values.firstWhere(
(e) => e.toString().split('.').last == type,
orElse: () => DeviceType.other,
);
}
String deviceTypeToString(DeviceType type) {
return type.toString().split('.').last;
}
/// Model representing a Bluetooth device
@freezed
abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
@ -25,23 +46,28 @@ abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
/// MAC address of the device
required String address,
/// Signal strength indicator (RSSI)
int? rssi,
/// Type of the device
@Default(DeviceType.other) DeviceType type,
/// Whether the device is currently connected
@Default(false) bool isConnected,
/// Additional device information
Map<String, dynamic>? manufacturerData,
/// Service UUIDs advertised by the device
List<String>? serviceUuids,
/// Identifier of the device
@DeviceIdentJsonConverter() required DeviceIdentifier deviceIdent,
}) = _BluetoothDeviceModel;
/// Create a BluetoothDeviceModel from JSON
factory BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
_$BluetoothDeviceModelFromJson(json);
}
class DeviceIdentJsonConverter
implements JsonConverter<DeviceIdentifier, String> {
const DeviceIdentJsonConverter();
@override
DeviceIdentifier fromJson(String json) => DeviceIdentifier(json);
@override
String toJson(DeviceIdentifier object) => object.str;
}

View File

@ -24,20 +24,15 @@ mixin _$BluetoothDeviceModel {
/// MAC address of the device
String get address;
/// Signal strength indicator (RSSI)
int? get rssi;
/// Type of the device
DeviceType get type;
/// Whether the device is currently connected
bool get isConnected;
/// Additional device information
Map<String, dynamic>? get manufacturerData;
/// Service UUIDs advertised by the device
List<String>? get serviceUuids;
/// Identifier of the device
@DeviceIdentJsonConverter()
DeviceIdentifier get deviceIdent;
/// Create a copy of BluetoothDeviceModel
/// with the given fields replaced by the non-null parameter values.
@ -58,32 +53,21 @@ mixin _$BluetoothDeviceModel {
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.rssi, rssi) || other.rssi == rssi) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.isConnected, isConnected) ||
other.isConnected == isConnected) &&
const DeepCollectionEquality()
.equals(other.manufacturerData, manufacturerData) &&
const DeepCollectionEquality()
.equals(other.serviceUuids, serviceUuids));
(identical(other.deviceIdent, deviceIdent) ||
other.deviceIdent == deviceIdent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
name,
address,
rssi,
type,
isConnected,
const DeepCollectionEquality().hash(manufacturerData),
const DeepCollectionEquality().hash(serviceUuids));
int get hashCode => Object.hash(runtimeType, id, name, address, type,
const DeepCollectionEquality().hash(manufacturerData), deviceIdent);
@override
String toString() {
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)';
}
}
@ -97,11 +81,9 @@ abstract mixin class $BluetoothDeviceModelCopyWith<$Res> {
{String id,
String? name,
String address,
int? rssi,
DeviceType type,
bool isConnected,
Map<String, dynamic>? manufacturerData,
List<String>? serviceUuids});
@DeviceIdentJsonConverter() DeviceIdentifier deviceIdent});
}
/// @nodoc
@ -120,11 +102,9 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res>
Object? id = null,
Object? name = freezed,
Object? address = null,
Object? rssi = freezed,
Object? type = null,
Object? isConnected = null,
Object? manufacturerData = freezed,
Object? serviceUuids = freezed,
Object? deviceIdent = null,
}) {
return _then(_self.copyWith(
id: null == id
@ -139,26 +119,18 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res>
? _self.address
: address // ignore: cast_nullable_to_non_nullable
as String,
rssi: freezed == rssi
? _self.rssi
: rssi // ignore: cast_nullable_to_non_nullable
as int?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as DeviceType,
isConnected: null == isConnected
? _self.isConnected
: isConnected // ignore: cast_nullable_to_non_nullable
as bool,
manufacturerData: freezed == manufacturerData
? _self.manufacturerData
: manufacturerData // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
serviceUuids: freezed == serviceUuids
? _self.serviceUuids
: serviceUuids // ignore: cast_nullable_to_non_nullable
as List<String>?,
deviceIdent: null == deviceIdent
? _self.deviceIdent
: deviceIdent // ignore: cast_nullable_to_non_nullable
as DeviceIdentifier,
));
}
}
@ -170,13 +142,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
{required this.id,
this.name,
required this.address,
this.rssi,
this.type = DeviceType.other,
this.isConnected = false,
final Map<String, dynamic>? manufacturerData,
final List<String>? serviceUuids})
: _manufacturerData = manufacturerData,
_serviceUuids = serviceUuids;
@DeviceIdentJsonConverter() required this.deviceIdent})
: _manufacturerData = manufacturerData;
factory _BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
_$BluetoothDeviceModelFromJson(json);
@ -192,20 +161,11 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
@override
final String address;
/// Signal strength indicator (RSSI)
@override
final int? rssi;
/// Type of the device
@override
@JsonKey()
final DeviceType type;
/// Whether the device is currently connected
@override
@JsonKey()
final bool isConnected;
/// Additional device information
final Map<String, dynamic>? _manufacturerData;
@ -219,18 +179,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
return EqualUnmodifiableMapView(value);
}
/// Service UUIDs advertised by the device
final List<String>? _serviceUuids;
/// Service UUIDs advertised by the device
/// Identifier of the device
@override
List<String>? get serviceUuids {
final value = _serviceUuids;
if (value == null) return null;
if (_serviceUuids is EqualUnmodifiableListView) return _serviceUuids;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@DeviceIdentJsonConverter()
final DeviceIdentifier deviceIdent;
/// Create a copy of BluetoothDeviceModel
/// with the given fields replaced by the non-null parameter values.
@ -256,32 +208,21 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.rssi, rssi) || other.rssi == rssi) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.isConnected, isConnected) ||
other.isConnected == isConnected) &&
const DeepCollectionEquality()
.equals(other._manufacturerData, _manufacturerData) &&
const DeepCollectionEquality()
.equals(other._serviceUuids, _serviceUuids));
(identical(other.deviceIdent, deviceIdent) ||
other.deviceIdent == deviceIdent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
name,
address,
rssi,
type,
isConnected,
const DeepCollectionEquality().hash(_manufacturerData),
const DeepCollectionEquality().hash(_serviceUuids));
int get hashCode => Object.hash(runtimeType, id, name, address, type,
const DeepCollectionEquality().hash(_manufacturerData), deviceIdent);
@override
String toString() {
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)';
}
}
@ -297,11 +238,9 @@ abstract mixin class _$BluetoothDeviceModelCopyWith<$Res>
{String id,
String? name,
String address,
int? rssi,
DeviceType type,
bool isConnected,
Map<String, dynamic>? manufacturerData,
List<String>? serviceUuids});
@DeviceIdentJsonConverter() DeviceIdentifier deviceIdent});
}
/// @nodoc
@ -320,11 +259,9 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res>
Object? id = null,
Object? name = freezed,
Object? address = null,
Object? rssi = freezed,
Object? type = null,
Object? isConnected = null,
Object? manufacturerData = freezed,
Object? serviceUuids = freezed,
Object? deviceIdent = null,
}) {
return _then(_BluetoothDeviceModel(
id: null == id
@ -339,26 +276,18 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res>
? _self.address
: address // ignore: cast_nullable_to_non_nullable
as String,
rssi: freezed == rssi
? _self.rssi
: rssi // ignore: cast_nullable_to_non_nullable
as int?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as DeviceType,
isConnected: null == isConnected
? _self.isConnected
: isConnected // ignore: cast_nullable_to_non_nullable
as bool,
manufacturerData: freezed == manufacturerData
? _self._manufacturerData
: manufacturerData // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
serviceUuids: freezed == serviceUuids
? _self._serviceUuids
: serviceUuids // ignore: cast_nullable_to_non_nullable
as List<String>?,
deviceIdent: null == deviceIdent
? _self.deviceIdent
: deviceIdent // ignore: cast_nullable_to_non_nullable
as DeviceIdentifier,
));
}
}

View File

@ -12,14 +12,11 @@ _BluetoothDeviceModel _$BluetoothDeviceModelFromJson(
id: json['id'] as String,
name: json['name'] as String?,
address: json['address'] as String,
rssi: (json['rssi'] as num?)?.toInt(),
type: $enumDecodeNullable(_$DeviceTypeEnumMap, json['type']) ??
DeviceType.other,
isConnected: json['isConnected'] as bool? ?? false,
manufacturerData: json['manufacturerData'] as Map<String, dynamic>?,
serviceUuids: (json['serviceUuids'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
deviceIdent: const DeviceIdentJsonConverter()
.fromJson(json['deviceIdent'] as String),
);
Map<String, dynamic> _$BluetoothDeviceModelToJson(
@ -28,11 +25,10 @@ Map<String, dynamic> _$BluetoothDeviceModelToJson(
'id': instance.id,
'name': instance.name,
'address': instance.address,
'rssi': instance.rssi,
'type': _$DeviceTypeEnumMap[instance.type]!,
'isConnected': instance.isConnected,
'manufacturerData': instance.manufacturerData,
'serviceUuids': instance.serviceUuids,
'deviceIdent':
const DeviceIdentJsonConverter().toJson(instance.deviceIdent),
};
const _$DeviceTypeEnumMap = {

View File

@ -0,0 +1,70 @@
import 'dart:typed_data';
class DfuV1FirmwareMetadata {
const DfuV1FirmwareMetadata({
required this.totalLength,
required this.crc32,
required this.sessionId,
required this.flags,
});
final int totalLength;
final int crc32;
final int sessionId;
final int flags;
}
class DfuV1PreparedFirmware {
const DfuV1PreparedFirmware({
required this.fileName,
required this.fileBytes,
required this.metadata,
this.filePath,
});
final String fileName;
final String? filePath;
final Uint8List fileBytes;
final DfuV1FirmwareMetadata metadata;
}
enum FirmwareSelectionFailureReason {
canceled,
malformedSelection,
unsupportedExtension,
emptyFile,
readFailed,
}
class FirmwareSelectionFailure {
const FirmwareSelectionFailure({
required this.reason,
required this.message,
});
final FirmwareSelectionFailureReason reason;
final String message;
}
class FirmwareFileSelectionResult {
const FirmwareFileSelectionResult._({
this.firmware,
this.failure,
});
final DfuV1PreparedFirmware? firmware;
final FirmwareSelectionFailure? failure;
bool get isSuccess => firmware != null;
bool get isCanceled =>
failure?.reason == FirmwareSelectionFailureReason.canceled;
static FirmwareFileSelectionResult success(DfuV1PreparedFirmware firmware) {
return FirmwareFileSelectionResult._(firmware: firmware);
}
static FirmwareFileSelectionResult failed(FirmwareSelectionFailure failure) {
return FirmwareFileSelectionResult._(failure: failure);
}
}

View File

@ -0,0 +1,456 @@
import 'package:cbor/simple.dart';
const String universalShifterControlServiceUuid =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
const String universalShifterStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40000';
const String universalShifterConnectToAddrCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40001';
const String universalShifterCommandCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40005';
const String universalShifterGearRatiosCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40006';
const String universalShifterDfuControlCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40008';
const String universalShifterDfuDataCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40009';
const String universalShifterDfuAckCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba4000a';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
const int universalShifterDfuOpcodeStart = 0x01;
const int universalShifterDfuOpcodeFinish = 0x02;
const int universalShifterDfuOpcodeAbort = 0x03;
const int universalShifterDfuFrameSizeBytes = 64;
const int universalShifterDfuFramePayloadSizeBytes = 63;
const int universalShifterAttWriteOverheadBytes = 3;
const int universalShifterDfuMinimumMtu =
universalShifterDfuFrameSizeBytes + universalShifterAttWriteOverheadBytes;
const int universalShifterDfuPreferredMtu = 128;
const int universalShifterDfuFlagEncrypted = 0x01;
const int universalShifterDfuFlagSigned = 0x02;
const int universalShifterDfuFlagNone = 0x00;
const int errorSequence = 1;
const int errorFtmsMissing = 2;
const int errorPairingAuth = 3;
const int errorPairingEncrypt = 4;
const int errorFtmsRequiredCharMissing = 5;
enum DfuUpdateState {
idle,
starting,
waitingForAck,
transferring,
finishing,
completed,
aborted,
failed,
}
class DfuUpdateFlags {
const DfuUpdateFlags({
this.encrypted = false,
this.signed = false,
});
final bool encrypted;
final bool signed;
int get rawValue {
var value = 0;
if (encrypted) {
value |= universalShifterDfuFlagEncrypted;
}
if (signed) {
value |= universalShifterDfuFlagSigned;
}
return value;
}
static DfuUpdateFlags fromRaw(int rawValue) {
return DfuUpdateFlags(
encrypted: (rawValue & universalShifterDfuFlagEncrypted) != 0,
signed: (rawValue & universalShifterDfuFlagSigned) != 0,
);
}
}
class DfuUpdateProgress {
const DfuUpdateProgress({
required this.state,
required this.totalBytes,
required this.sentBytes,
required this.lastAckedSequence,
required this.sessionId,
required this.flags,
this.errorMessage,
});
final DfuUpdateState state;
final int totalBytes;
final int sentBytes;
final int lastAckedSequence;
final int sessionId;
final DfuUpdateFlags flags;
final String? errorMessage;
double get fractionComplete {
if (totalBytes <= 0) {
return 0;
}
return sentBytes.clamp(0, totalBytes) / totalBytes;
}
int get percentComplete => (fractionComplete * 100).round();
bool get isTerminal =>
state == DfuUpdateState.completed ||
state == DfuUpdateState.aborted ||
state == DfuUpdateState.failed;
}
enum DfuPreflightFailureReason {
deviceNotConnected,
wrongConnectedDevice,
mtuRequestFailed,
mtuTooLow,
}
class DfuPreflightResult {
const DfuPreflightResult._({
required this.requestedMtu,
required this.requiredMtu,
required this.negotiatedMtu,
required this.failureReason,
required this.message,
});
final int requestedMtu;
final int requiredMtu;
final int? negotiatedMtu;
final DfuPreflightFailureReason? failureReason;
final String? message;
bool get canStart => failureReason == null;
static DfuPreflightResult ready({
required int requestedMtu,
required int negotiatedMtu,
int requiredMtu = universalShifterDfuMinimumMtu,
}) {
return DfuPreflightResult._(
requestedMtu: requestedMtu,
requiredMtu: requiredMtu,
negotiatedMtu: negotiatedMtu,
failureReason: null,
message: null,
);
}
static DfuPreflightResult failed({
required int requestedMtu,
required DfuPreflightFailureReason failureReason,
required String message,
int requiredMtu = universalShifterDfuMinimumMtu,
int? negotiatedMtu,
}) {
return DfuPreflightResult._(
requestedMtu: requestedMtu,
requiredMtu: requiredMtu,
negotiatedMtu: negotiatedMtu,
failureReason: failureReason,
message: message,
);
}
}
class ShifterErrorInfo {
const ShifterErrorInfo({
required this.code,
required this.title,
required this.details,
});
final int code;
final String title;
final String details;
}
ShifterErrorInfo shifterErrorInfo(int code) {
switch (code) {
case errorSequence:
return const ShifterErrorInfo(
code: errorSequence,
title: 'Invalid command sequence',
details:
'The button received Connect without a fresh target address. The app must write connect_to_addr first, then the connect command.',
);
case errorFtmsMissing:
return const ShifterErrorInfo(
code: errorFtmsMissing,
title: 'FTMS service missing',
details:
'The selected bike does not expose the FTMS service (UUID 0x1826), so pairing cannot continue.',
);
case errorPairingAuth:
return const ShifterErrorInfo(
code: errorPairingAuth,
title: 'Pairing authentication failed',
details:
'Bonding authentication with the bike failed. Remove old bonds on both devices and try pairing again nearby.',
);
case errorPairingEncrypt:
return const ShifterErrorInfo(
code: errorPairingEncrypt,
title: 'Pairing/encryption failed',
details:
'The secure link to the bike could not be established. Retry close to the bike and ensure it is pairable.',
);
case errorFtmsRequiredCharMissing:
return const ShifterErrorInfo(
code: errorFtmsRequiredCharMissing,
title: 'Required FTMS characteristic missing',
details:
'The bike has FTMS but is missing required characteristics (for example Indoor Bike Data), so control cannot start.',
);
default:
return ShifterErrorInfo(
code: code,
title: 'Unknown error',
details: 'The button reported an unknown error code ($code).',
);
}
}
enum UniversalShifterCommand {
reset(0x00),
startScan(0x01),
stopScan(0x02),
connectToDevice(0x03),
disconnect(0x04),
turnOff(0x05);
const UniversalShifterCommand(this.value);
final int value;
}
enum ControlConnectionState {
disconnected,
connected;
static ControlConnectionState fromRaw(dynamic raw) {
if (raw is int) {
return raw == 1
? ControlConnectionState.connected
: ControlConnectionState.disconnected;
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('connected')) {
return ControlConnectionState.connected;
}
}
return ControlConnectionState.disconnected;
}
}
enum TrainerConnectionState {
idle,
connecting,
pairing,
connected,
discoveringFtms,
ftmsReady,
error,
}
class TrainerStatus {
const TrainerStatus({required this.state, this.errorCode});
final TrainerConnectionState state;
final int? errorCode;
String get label {
switch (state) {
case TrainerConnectionState.idle:
return 'Idle';
case TrainerConnectionState.connecting:
return 'Connecting';
case TrainerConnectionState.pairing:
return 'Pairing';
case TrainerConnectionState.connected:
return 'Connected';
case TrainerConnectionState.discoveringFtms:
return 'Discovering FTMS';
case TrainerConnectionState.ftmsReady:
return 'FTMS Ready';
case TrainerConnectionState.error:
return 'Error${errorCode != null ? ' ($errorCode)' : ''}';
}
}
static TrainerStatus fromRaw(dynamic raw) {
if (raw is int) {
switch (raw) {
case 1:
return const TrainerStatus(state: TrainerConnectionState.connecting);
case 2:
return const TrainerStatus(state: TrainerConnectionState.pairing);
case 3:
return const TrainerStatus(state: TrainerConnectionState.connected);
case 4:
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
case 5:
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
default:
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
if (raw is List && raw.isNotEmpty) {
final variant = raw.first;
final value = raw.length > 1 ? raw[1] : null;
if (variant is int && (variant == 5 || variant == 6)) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
}
if (raw is Map) {
final entry = raw.entries.isNotEmpty ? raw.entries.first : null;
if (entry != null) {
final key = entry.key;
final value = entry.value;
if ((key is int && (key == 5 || key == 6)) ||
(key is String && key.toLowerCase().contains('error'))) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
}
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('connecting')) {
return const TrainerStatus(state: TrainerConnectionState.connecting);
}
if (normalized.contains('pairing')) {
return const TrainerStatus(state: TrainerConnectionState.pairing);
}
if (normalized.contains('connected')) {
return const TrainerStatus(state: TrainerConnectionState.connected);
}
if (normalized.contains('discover')) {
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
}
if (normalized.contains('ready')) {
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
}
if (normalized.contains('error')) {
return const TrainerStatus(state: TrainerConnectionState.error);
}
}
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
class CentralStatus {
const CentralStatus({
required this.control,
required this.trainer,
required this.hasSavedBond,
required this.connectedTrainerAddr,
required this.lastFailure,
required this.raw,
});
final ControlConnectionState control;
final TrainerStatus trainer;
final bool hasSavedBond;
final List<int>? connectedTrainerAddr;
final int? lastFailure;
final dynamic raw;
String get statusLine =>
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
static CentralStatus fromCborBytes(List<int> bytes) {
final decoded = cbor.decode(bytes);
if (decoded is! Map) {
return CentralStatus(
control: ControlConnectionState.disconnected,
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
hasSavedBond: false,
connectedTrainerAddr: null,
lastFailure: null,
raw: decoded,
);
}
final controlRaw = _readMapValue(decoded, [0, 'control']);
final trainerRaw = _readMapValue(decoded, [1, 'trainer']);
final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']);
final connectedTrainerAddrRaw =
_readMapValue(decoded, [3, 'connected_trainer_addr']);
final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']);
return CentralStatus(
control: ControlConnectionState.fromRaw(controlRaw),
trainer: TrainerStatus.fromRaw(trainerRaw),
hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false,
connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw),
lastFailure: lastFailureRaw is int ? lastFailureRaw : null,
raw: decoded,
);
}
}
dynamic _readMapValue(Map<dynamic, dynamic> map, List<dynamic> keys) {
for (final key in keys) {
if (map.containsKey(key)) {
return map[key];
}
}
return null;
}
List<int>? _toByteList(dynamic value) {
if (value == null) {
return null;
}
if (value is List) {
return value.whereType<int>().toList(growable: false);
}
return null;
}
List<int> parseMacToLittleEndianBytes(String macAddress) {
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
if (compact.length != 12) {
throw FormatException('Invalid MAC address format: $macAddress');
}
final bytes = <int>[];
for (int i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
}
return bytes.reversed.toList(growable: false);
}
String formatMacAddressFromLittleEndian(List<int> bytes) {
if (bytes.length != 6) {
return 'Unknown';
}
return bytes.reversed
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,17 @@
import 'dart:io';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/util/constants.dart';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:abawo_bt_app/widgets/device_listitem.dart';
import 'package:abawo_bt_app/widgets/scanning_animation.dart'; // Import the new animation widget
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; // Import the new horizontal animation
import 'package:abawo_bt_app/database/database.dart';
import 'package:drift/drift.dart' show Value;
const Duration _scanDuration = Duration(seconds: 10);
@ -18,25 +24,20 @@ class ConnectDevicePage extends ConsumerStatefulWidget {
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
with TickerProviderStateMixin {
// Use TickerProviderStateMixin for multiple controllers if needed later, good practice
bool _initialScanStarted = false;
// TickerProviderStateMixin is no longer needed as animations are self-contained or handled by StreamBuilder
int _retryScanCounter = 0; // Used to force animation reset
bool _initialScanTriggered = false; // Track if the first scan was requested
bool _showOnlyAbawoDevices = true; // State for filtering devices
late AnimationController
_progressController; // Controller for scan duration progress
late AnimationController
_waveAnimationController; // Controller for wave animation
// Function to start scan safely after controller is ready
void _startScanIfNeeded(BluetoothController controller) {
// Use WidgetsBinding to schedule the scan start after the build phase
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialScanStarted && mounted) {
// Start scan only if it hasn't been triggered yet and the widget is mounted
if (!_initialScanTriggered && mounted) {
controller.startScan(timeout: _scanDuration);
_startScanProgressAnimation(); // Start scan duration progress animation
_startWaveAnimation(); // Start the wave animation
if (mounted) {
setState(() {
_initialScanStarted = true;
_initialScanTriggered = true;
});
}
}
@ -46,64 +47,17 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
@override
void initState() {
super.initState();
// Initialize scan progress controller
_progressController = AnimationController(
vsync: this,
duration: _scanDuration,
)..addListener(() {
// Trigger rebuild when animation value changes for the progress indicator
if (mounted) {
setState(() {});
}
});
// Initialize wave animation controller
_waveAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500), // New duration: 1.5 seconds
)..repeat(); // Make the wave animation repeat
super.initState();
// No animation controllers needed here anymore
}
@override
void dispose() {
// Stop animations before disposing
_progressController.stop();
_waveAnimationController.stop();
_progressController.dispose();
_waveAnimationController.dispose();
// Dispose controllers if they existed (they don't anymore)
super.dispose();
}
// Helper method to start/reset scan progress animation
void _startScanProgressAnimation() {
if (mounted) {
_progressController.reset();
_progressController.forward().whenCompleteOrCancel(() {
// Optional: Add logic if needed when scan progress completes or cancels
});
}
}
// Helper method to start the wave animation
void _startWaveAnimation() {
if (mounted && !_waveAnimationController.isAnimating) {
_waveAnimationController.reset();
_waveAnimationController.repeat();
}
}
// Helper method to stop animations when scan finishes/cancels
void _stopAnimations() {
if (mounted) {
if (_progressController.isAnimating) {
_progressController.stop();
}
if (_waveAnimationController.isAnimating) {
_waveAnimationController.stop();
}
}
}
// Removed _startScanProgressAnimation, _startWaveAnimation, _stopAnimations
@override
Widget build(BuildContext context) {
return Scaffold(
@ -134,155 +88,264 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
)
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
body: Column(
// Use Column instead of Center(Column(...))
children: [
const Text(
const Padding(
padding: EdgeInsets.all(16.0), // Add padding around the title
child: Text(
'Available Devices',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// Use Consumer to react to bluetoothProvider changes
),
// Use Consumer to get the BluetoothController
Expanded(
// Allow the Consumer content to expand
child: Consumer(builder: (context, ref, child) {
// Allow the device list to take available space
child: Consumer(
builder: (context, ref, child) {
final btAsyncValue = ref.watch(bluetoothProvider);
final connectedDevices =
ref.watch(nConnectedDevicesProvider).valueOrNull ??
const <ConnectedDevice>[];
final connectedDeviceAddresses = connectedDevices
.map((device) => device.deviceAddress)
.toSet();
return btAsyncValue.when(
loading: () => const Center(
child:
CircularProgressIndicator()), // Center loading indicator
error: (err, stack) => Center(
child: Text(
'Error loading Bluetooth: $err')), // Center error
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (controller) {
// Start the initial scan once the controller is ready
// Start initial scan and animation
// Trigger the initial scan if needed
_startScanIfNeeded(controller);
// Use StreamBuilder to watch the scanning state
return StreamBuilder<bool>(
stream: FlutterBluePlus.isScanning,
initialData:
false, // Default to not scanning before check
// StreamBuilder for Scan Results (Device List)
return StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream,
initialData: const [],
builder: (context, snapshot) {
final isScanning = snapshot.data ?? false;
if (isScanning && _initialScanStarted) {
_startWaveAnimation(); // Ensure wave animation is running
// Show the new scanning wave animation with the progress indicator value
return Center(
// Pass the wave animation controller. The progress value will be added
// to the ScanningWaveAnimation widget itself later.
child: ScanningWaveAnimation(
animation: _waveAnimationController,
progressValue: _progressController
.value, // Pass the scan progress
),
);
} else if (!_initialScanStarted) {
// Show placeholder or button to start initial scan if needed, or just empty space
return const SizedBox(
height: 50); // Placeholder before scan starts
} else {
// Scan finished, stop animations and show results
_stopAnimations();
final results = controller.scanResults;
final results = snapshot.data ?? [];
// Filter results based on the toggle state
final filteredResults = _showOnlyAbawoDevices
? results
.where((device) => device
.advertisementData.serviceUuids
.contains(Guid(abawoServiceBtUUID)))
.where((device) =>
device.serviceUuids.any(isAbawoDeviceGuid))
.toList()
: results;
// Use Column + Expanded for ListView + Button layout
return Column(
children: [
Expanded(
// Allow ListView to take available space
child: filteredResults
.isEmpty // Use filtered list check
? const Center(
if (!_initialScanTriggered && filteredResults.isEmpty) {
// Show a message or placeholder before the first scan starts or if no devices found initially
return const Center(
child: Text(
'No devices found.')) // Center empty text
: ListView.builder(
itemCount: filteredResults
.length, // Use filtered list length
'Scanning for devices...')); // Or CircularProgressIndicator()
}
if (filteredResults.isEmpty && _initialScanTriggered) {
// Show 'No devices found' only after the initial scan was triggered
return const Center(child: Text('No devices found.'));
}
// Display the list
return ListView.builder(
itemCount: filteredResults.length,
itemBuilder: (context, index) {
final device = filteredResults[
index]; // Use filtered list
final isAbawoDevice = device
.advertisementData.serviceUuids
.contains(
Guid(abawoServiceBtUUID));
final deviceName =
device.device.advName.isEmpty
final device = filteredResults[index];
final isAlreadyConnected =
connectedDeviceAddresses.contains(device.id);
final abawoDevice =
device.serviceUuids.any(isAbawoDeviceGuid);
final connectable = device.serviceUuids
.any(isConnectableAbawoDeviceGuid);
final deviceName = device.name.isEmpty
? 'Unknown Device'
: device.device.advName;
// Use the custom DeviceListItem widget
: device.name;
return InkWell(
// Wrap with InkWell for tap feedback
onTap: () {
if (!isAbawoDevice) {
// Show a snackbar for non-Abawo devices
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
onTap: () async {
if (isAlreadyConnected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This app can only connect to abawo devices.')));
'This device is already connected in the app.'),
),
);
return;
}
// TODO: Implement connect logic
// controller.connectToDevice(device.device); // Pass the BluetoothDevice
// context.go('/control/${device.device.remoteId.str}');
print(
'Tapped on ${device.device.remoteId.str}');
if (!abawoDevice) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This app can only connect to abawo devices.')),
);
return;
} else if (!connectable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This device is not connectable with the app.')),
);
return;
} else {
final res = await controller.connect(device);
print('res: $res');
switch (res) {
case Ok():
// trigger pairing/permission prompt if needed
if (!Platform.isAndroid) {
controller.readCharacteristic(
device.id,
'0993826f-0ee4-4b37-9614-d13ecba4ffc2',
'0993826f-0ee4-4b37-9614-d13ecba40000');
}
// Save to DB and navigate
final notifier = ref.read(
nConnectedDevicesProvider.notifier);
final name = device.name.isNotEmpty
? device.name
: 'Unknown Device';
final deviceCompanion =
ConnectedDevicesCompanion(
deviceName: Value(name),
deviceAddress: Value(device.id),
deviceType: Value(deviceTypeToString(
deviceTypeFromUuids(
device.serviceUuids))),
lastConnectedAt: Value(DateTime.now()),
);
final addResult = await notifier
.addConnectedDevice(deviceCompanion);
// Check if mounted before using context
if (!context.mounted) break;
if (addResult.isErr()) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Failed to save device: ${addResult.unwrapErr()}')),
);
} else {
context.go('/device/${device.id}');
}
break;
case Err(:final v):
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
'Connection unsuccessful:\n${v.toString()}'),
));
break;
}
}
print('Tapped on ${device.id}');
},
child: DeviceListItem(
deviceName: deviceName,
deviceId:
device.device.remoteId.str,
isUnknownDevice:
device.device.advName.isEmpty,
deviceId: device.id,
type: deviceTypeFromUuids(device.serviceUuids),
),
);
} // End of itemBuilder
},
);
},
);
},
);
},
),
),
Padding(
// Add padding around the button
// Bottom section: Scanning Animation and Retry Button (visible only when scanning)
Consumer(
// Use Consumer to get the controller for the retry button action
builder: (context, ref, child) {
final btController = ref
.watch(bluetoothProvider)
.asData
?.value; // Get controller safely
return StreamBuilder<bool>(
stream: btController?.isScanningStream ?? Stream<bool>.empty(),
initialData: false,
builder: (context, snapshot) {
final isScanning = snapshot.data ?? false;
// Show bottom section only if scanning
if (!isScanning) {
// Show only the retry button when not scanning (optional, could be hidden)
// For now, let's keep the button always visible but disabled when not scannable.
// A better approach might be to hide the button when not scanning.
// Let's show the button but potentially disabled later if controller is null.
return Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: () {
// Retry scan by calling startScan on the controller
// Ensure _initialScanStarted is true so indicator shows
onPressed: btController != null
? () {
// Retry scan ONLY when NOT currently scanning
if (mounted) {
setState(() {
_initialScanStarted = true;
_initialScanTriggered =
true; // Ensure state reflects scan attempt
_retryScanCounter++; // Increment key counter
});
}
controller.startScan(
timeout: _scanDuration);
_startScanProgressAnimation(); // Restart scan progress animation
_startWaveAnimation(); // Ensure wave animation runs on retry
},
btController.startScan(timeout: _scanDuration);
}
: null, // Disable if controller not ready
child: const Text('Retry Scan'),
),
),
],
);
}
},
// If scanning, show animation and button
return Container(
padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(
color: Theme.of(context)
.scaffoldBackgroundColor
.withValues(alpha: 0.9), // Slight overlay effect
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, -4), // Shadow upwards
)
],
),
child: Column(
mainAxisSize: MainAxisSize.min, // Keep column compact
children: [
// Pass isScanning and the ValueKey
HorizontalScanningAnimation(
key: ValueKey(
_retryScanCounter), // Force state rebuild on counter change
isScanning: isScanning,
height: 40,
),
const SizedBox(height: 8),
ElevatedButton(
// Button does nothing if pressed *while* scanning.
// It just indicates the status.
onPressed: null, // Disable button while scanning
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Theme.of(context)
.primaryColor
.withValues(alpha: 0.5), // Custom disabled color
disabledForegroundColor: Colors.white70,
),
child:
const Text('Scanning...'), // Just indicate status
),
const SizedBox(height: 8), // Add some bottom padding
],
),
);
},
);
}),
),
],
),
),
], // End of outer Column children
), // End of Scaffold
);
}
}

View File

@ -1,4 +1,9 @@
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/database/database.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/widgets/device_listitem.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class HomePage extends StatelessWidget {
@ -53,10 +58,7 @@ class HomePage extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'No devices connected yet',
style: TextStyle(color: Colors.grey),
),
child: DevicesList(),
),
),
],
@ -73,3 +75,148 @@ class HomePage extends StatelessWidget {
);
}
}
class DevicesList extends ConsumerStatefulWidget {
const DevicesList({super.key});
@override
ConsumerState<DevicesList> createState() => _DevicesListState();
}
class _DevicesListState extends ConsumerState<DevicesList> {
String? _connectingDeviceId; // ID of device currently being connected
Future<void> _removeDevice(ConnectedDevice device) async {
final shouldRemove = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove device?'),
content:
Text('Do you want to remove ${device.deviceName} from the app?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Remove'),
),
],
),
);
if (shouldRemove != true || !mounted) {
return;
}
final result = await ref
.read(nConnectedDevicesProvider.notifier)
.deleteConnectedDevice(device.id);
if (!mounted) {
return;
}
if (result.isErr()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to remove device: ${result.unwrapErr()}')),
);
return;
}
if (_connectingDeviceId == device.deviceAddress) {
setState(() {
_connectingDeviceId = null;
});
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${device.deviceName} removed from the app.')),
);
}
@override
Widget build(BuildContext context) {
final asyncDevices = ref.watch(nConnectedDevicesProvider);
return asyncDevices.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Text(
'Error loading devices: ${error.toString()}',
style: TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
data: (devices) {
if (devices.isEmpty) {
return const Center(
child: Text(
'No devices connected yet',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: devices.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final device = devices[index];
return InkWell(
onTap: () async {
if (_connectingDeviceId != null) return;
setState(() {
_connectingDeviceId = device.deviceAddress;
});
try {
final controller = await ref.read(bluetoothProvider.future);
final result = await controller.connectById(
device.deviceAddress,
timeout: const Duration(seconds: 10),
);
if (result.isOk()) {
context.go('/device/${device.deviceAddress}');
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Connection failed. Is the device turned on and in range?'),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() {
_connectingDeviceId = null;
});
}
},
child: DeviceListItem(
deviceName: device.deviceName,
deviceId: device.deviceAddress,
type: deviceTypeFromString(device.deviceType),
isConnecting: device.deviceAddress == _connectingDeviceId,
trailing: IconButton(
tooltip: 'Remove device',
icon: const Icon(Icons.delete_outline),
onPressed: () => _removeDevice(device),
),
),
);
},
);
},
);
}
}

View File

@ -0,0 +1,137 @@
import 'dart:typed_data';
import 'package:abawo_bt_app/model/shifter_types.dart';
const int _startPayloadLength = 11;
class DfuStartPayload {
const DfuStartPayload({
required this.totalLength,
required this.imageCrc32,
required this.sessionId,
required this.flags,
});
final int totalLength;
final int imageCrc32;
final int sessionId;
final int flags;
}
class DfuDataFrame {
const DfuDataFrame({
required this.sequence,
required this.offset,
required this.payloadLength,
required this.bytes,
});
final int sequence;
final int offset;
final int payloadLength;
final Uint8List bytes;
}
class DfuProtocol {
const DfuProtocol._();
static Uint8List encodeStartPayload(DfuStartPayload payload) {
final data = ByteData(_startPayloadLength);
data.setUint8(0, universalShifterDfuOpcodeStart);
data.setUint32(1, payload.totalLength, Endian.little);
data.setUint32(5, payload.imageCrc32, Endian.little);
data.setUint8(9, payload.sessionId);
data.setUint8(10, payload.flags);
return data.buffer.asUint8List();
}
static Uint8List encodeFinishPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeFinish]);
}
static Uint8List encodeAbortPayload() {
return Uint8List.fromList([universalShifterDfuOpcodeAbort]);
}
static List<DfuDataFrame> buildDataFrames(
List<int> imageBytes, {
int startSequence = 0,
}) {
final frames = <DfuDataFrame>[];
var seq = _asU8(startSequence);
var offset = 0;
while (offset < imageBytes.length) {
final remaining = imageBytes.length - offset;
final chunkLength = remaining < universalShifterDfuFramePayloadSizeBytes
? remaining
: universalShifterDfuFramePayloadSizeBytes;
final frame = Uint8List(universalShifterDfuFrameSizeBytes);
frame[0] = seq;
frame.setRange(1, 1 + chunkLength, imageBytes, offset);
frames.add(
DfuDataFrame(
sequence: seq,
offset: offset,
payloadLength: chunkLength,
bytes: frame,
),
);
offset += chunkLength;
seq = nextSequence(seq);
}
return frames;
}
static int nextSequence(int sequence) {
return _asU8(sequence + 1);
}
static int rewindSequenceFromAck(int acknowledgedSequence) {
return nextSequence(acknowledgedSequence);
}
static int sequenceDistance(int from, int to) {
return _asU8(to - from);
}
static int parseAckPayload(List<int> payload) {
if (payload.length != 1) {
throw const FormatException('ACK payload must be exactly 1 byte.');
}
return _asU8(payload.first);
}
static const int crc32Initial = 0xFFFFFFFF;
static const int _crc32PolynomialReflected = 0xEDB88320;
static int crc32Update(int crc, List<int> bytes) {
var next = crc & 0xFFFFFFFF;
for (final byte in bytes) {
next ^= byte;
for (var bit = 0; bit < 8; bit++) {
if ((next & 0x1) != 0) {
next = (next >> 1) ^ _crc32PolynomialReflected;
} else {
next >>= 1;
}
}
}
return next & 0xFFFFFFFF;
}
static int crc32Finalize(int crc) {
return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF;
}
static int crc32(List<int> bytes) {
return crc32Finalize(crc32Update(crc32Initial, bytes));
}
static int _asU8(int value) {
return value & 0xFF;
}
}

View File

@ -0,0 +1,154 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:file_picker/file_picker.dart';
typedef SessionIdGenerator = int Function();
class FirmwarePickerSelection {
const FirmwarePickerSelection({
required this.fileName,
required this.fileBytes,
this.filePath,
});
final String fileName;
final Uint8List fileBytes;
final String? filePath;
}
abstract interface class FirmwareFilePicker {
Future<FirmwarePickerSelection?> pickFirmwareFile();
}
class LocalFirmwareFilePicker implements FirmwareFilePicker {
@override
Future<FirmwarePickerSelection?> pickFirmwareFile() async {
final pickResult = await FilePicker.platform.pickFiles(
allowMultiple: false,
withData: true,
type: FileType.custom,
allowedExtensions: const ['bin'],
);
if (pickResult == null) {
return null;
}
if (pickResult.files.isEmpty) {
return FirmwarePickerSelection(
fileName: '',
fileBytes: Uint8List(0),
);
}
final selected = pickResult.files.first;
final bytes = selected.bytes ?? await _readFromPath(selected.path);
return FirmwarePickerSelection(
fileName: selected.name,
filePath: selected.path,
fileBytes: bytes,
);
}
Future<Uint8List> _readFromPath(String? path) async {
if (path == null || path.trim().isEmpty) {
throw const FileSystemException(
'Selected file did not contain readable bytes or a valid path.',
);
}
final file = File(path);
return file.readAsBytes();
}
}
class FirmwareFileSelectionService {
FirmwareFileSelectionService({
required FirmwareFilePicker filePicker,
SessionIdGenerator? sessionIdGenerator,
}) : _filePicker = filePicker,
_sessionIdGenerator = sessionIdGenerator ?? _randomSessionId;
final FirmwareFilePicker _filePicker;
final SessionIdGenerator _sessionIdGenerator;
Future<FirmwareFileSelectionResult> selectAndPrepareDfuV1() async {
final FirmwarePickerSelection? selection;
try {
selection = await _filePicker.pickFirmwareFile();
} catch (error) {
return FirmwareFileSelectionResult.failed(
FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.readFailed,
message: 'Could not read selected firmware file: $error',
),
);
}
if (selection == null) {
return FirmwareFileSelectionResult.failed(
const FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.canceled,
message: 'Firmware selection canceled.',
),
);
}
final fileName = selection.fileName.trim();
if (fileName.isEmpty) {
return FirmwareFileSelectionResult.failed(
const FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.malformedSelection,
message: 'Selected firmware file is missing a valid name.',
),
);
}
if (!_hasBinExtension(fileName)) {
return FirmwareFileSelectionResult.failed(
FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.unsupportedExtension,
message:
'Unsupported firmware file "$fileName". Please select a .bin file.',
),
);
}
if (selection.fileBytes.isEmpty) {
return FirmwareFileSelectionResult.failed(
FirmwareSelectionFailure(
reason: FirmwareSelectionFailureReason.emptyFile,
message:
'Selected firmware file "$fileName" is empty. Choose a non-empty .bin file.',
),
);
}
final metadata = DfuV1FirmwareMetadata(
totalLength: selection.fileBytes.length,
crc32: DfuProtocol.crc32(selection.fileBytes),
sessionId: _sessionIdGenerator() & 0xFF,
flags: universalShifterDfuFlagNone,
);
return FirmwareFileSelectionResult.success(
DfuV1PreparedFirmware(
fileName: fileName,
filePath: selection.filePath,
fileBytes: selection.fileBytes,
metadata: metadata,
),
);
}
bool _hasBinExtension(String fileName) {
return fileName.toLowerCase().endsWith('.bin');
}
static int _randomSessionId() {
return Random.secure().nextInt(256);
}
}

View File

@ -0,0 +1,690 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/dfu_protocol.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:anyhow/anyhow.dart';
const int _initialAckSequence = 0xFF;
class FirmwareUpdateService {
FirmwareUpdateService({
required FirmwareUpdateTransport transport,
this.defaultWindowSize = 8,
this.maxNoProgressRetries = 5,
this.defaultAckTimeout = const Duration(milliseconds: 800),
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
this.defaultReconnectTimeout = const Duration(seconds: 12),
this.defaultVerificationTimeout = const Duration(seconds: 5),
}) : _transport = transport;
final FirmwareUpdateTransport _transport;
final int defaultWindowSize;
final int maxNoProgressRetries;
final Duration defaultAckTimeout;
final Duration defaultPostFinishResetTimeout;
final Duration defaultReconnectTimeout;
final Duration defaultVerificationTimeout;
final StreamController<DfuUpdateProgress> _progressController =
StreamController<DfuUpdateProgress>.broadcast();
DfuUpdateProgress _currentProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
lastAckedSequence: _initialAckSequence,
sessionId: 0,
flags: DfuUpdateFlags(),
);
StreamSubscription<List<int>>? _ackSubscription;
Completer<void>? _ackSignal;
Completer<void>? _cancelSignal;
int _ackEventCount = 0;
String? _ackStreamError;
bool _isRunning = false;
bool _cancelRequested = false;
int _latestAckSequence = _initialAckSequence;
int _ackedFrames = 0;
int _totalFrames = 0;
int _totalBytes = 0;
Stream<DfuUpdateProgress> get progressStream => _progressController.stream;
DfuUpdateProgress get currentProgress => _currentProgress;
bool get isUpdating => _isRunning;
Future<Result<void>> startUpdate({
required List<int> imageBytes,
required int sessionId,
DfuUpdateFlags flags = const DfuUpdateFlags(),
int requestedMtu = universalShifterDfuPreferredMtu,
int? windowSize,
Duration? ackTimeout,
int? noProgressRetries,
Duration? postFinishResetTimeout,
Duration? reconnectTimeout,
Duration? verificationTimeout,
}) async {
if (_isRunning) {
return bail(
'Firmware update is already running. Cancel or wait for completion before starting a new upload.');
}
if (imageBytes.isEmpty) {
return bail(
'Firmware image is empty. Select a valid .bin file and retry.');
}
final effectiveWindowSize = windowSize ?? defaultWindowSize;
final effectiveAckTimeout = ackTimeout ?? defaultAckTimeout;
final effectiveNoProgressRetries =
noProgressRetries ?? maxNoProgressRetries;
final effectivePostFinishResetTimeout =
postFinishResetTimeout ?? defaultPostFinishResetTimeout;
final effectiveReconnectTimeout =
reconnectTimeout ?? defaultReconnectTimeout;
final effectiveVerificationTimeout =
verificationTimeout ?? defaultVerificationTimeout;
if (effectiveWindowSize <= 0) {
return bail(
'DFU window size must be at least 1 frame. Got $effectiveWindowSize.');
}
if (effectiveNoProgressRetries < 0) {
return bail(
'No-progress retry limit cannot be negative. Got $effectiveNoProgressRetries.');
}
_isRunning = true;
_cancelRequested = false;
_cancelSignal = Completer<void>();
_ackSignal = null;
_ackEventCount = 0;
_ackStreamError = null;
_latestAckSequence = _initialAckSequence;
_ackedFrames = 0;
_totalFrames =
(imageBytes.length + universalShifterDfuFramePayloadSizeBytes - 1) ~/
universalShifterDfuFramePayloadSizeBytes;
_totalBytes = imageBytes.length;
final normalizedSessionId = sessionId & 0xFF;
final crc32 = DfuProtocol.crc32(imageBytes);
final frames = DfuProtocol.buildDataFrames(imageBytes);
var shouldAbortForCleanup = false;
_emitProgress(
state: DfuUpdateState.starting,
totalBytes: imageBytes.length,
sentBytes: 0,
lastAckedSequence: _initialAckSequence,
sessionId: normalizedSessionId,
flags: flags,
);
try {
final preflightResult = await _transport.runPreflight(
requestedMtu: requestedMtu,
);
if (preflightResult.isErr()) {
throw _DfuFailure(
'DFU preflight check failed due to transport error: ${preflightResult.unwrapErr()}',
);
}
final preflight = preflightResult.unwrap();
if (!preflight.canStart) {
throw _DfuFailure(
preflight.message ??
'DFU preflight failed. Ensure button connection and MTU are ready, then retry.',
);
}
await _ackSubscription?.cancel();
_ackSubscription = _transport.subscribeToAck().listen(
_handleAckPayload,
onError: (Object error) {
_ackStreamError =
'ACK indication stream failed: $error. Reconnect and retry the update.';
_signalAckWaiters();
},
);
_emitProgress(state: DfuUpdateState.waitingForAck);
final startEventCount = _ackEventCount;
final startWriteResult = await _transport.writeControl(
DfuProtocol.encodeStartPayload(
DfuStartPayload(
totalLength: imageBytes.length,
imageCrc32: crc32,
sessionId: normalizedSessionId,
flags: flags.rawValue,
),
),
);
if (startWriteResult.isErr()) {
throw _DfuFailure(
'Failed to send DFU START command: ${startWriteResult.unwrapErr()}',
);
}
shouldAbortForCleanup = true;
final initialAck = await _waitForInitialAck(
afterEventCount: startEventCount,
timeout: effectiveAckTimeout,
);
if (initialAck != _initialAckSequence) {
throw _DfuFailure(
'Device did not acknowledge START correctly (expected ACK 0xFF, got 0x${initialAck.toRadixString(16).padLeft(2, '0').toUpperCase()}). Send ABORT, reconnect if needed, and retry.',
);
}
_emitProgress(state: DfuUpdateState.transferring);
var nextFrameIndex = 0;
var retriesWithoutProgress = 0;
while (_ackedFrames < _totalFrames) {
_throwIfCancelled();
_throwIfAckStreamErrored();
final ackedBeforeWindow = _ackedFrames;
final endExclusive =
(nextFrameIndex + effectiveWindowSize).clamp(0, frames.length);
for (var frameIndex = nextFrameIndex;
frameIndex < endExclusive;
frameIndex++) {
_throwIfCancelled();
final writeResult =
await _transport.writeDataFrame(frames[frameIndex].bytes);
if (writeResult.isErr()) {
throw _DfuFailure(
'Failed sending DFU data frame #$frameIndex (seq 0x${frames[frameIndex].sequence.toRadixString(16).padLeft(2, '0').toUpperCase()}): ${writeResult.unwrapErr()}',
);
}
}
nextFrameIndex = endExclusive;
if (_ackedFrames > ackedBeforeWindow) {
retriesWithoutProgress = 0;
nextFrameIndex = _ackedFrames;
continue;
}
final gotProgress = await _waitForAckProgress(
ackedFramesBeforeWait: ackedBeforeWindow,
timeout: effectiveAckTimeout,
);
if (gotProgress) {
retriesWithoutProgress = 0;
nextFrameIndex = _ackedFrames;
continue;
}
retriesWithoutProgress += 1;
if (retriesWithoutProgress > effectiveNoProgressRetries) {
throw _DfuFailure(
'Upload stalled: no ACK progress after $retriesWithoutProgress retries (last ACK 0x${_latestAckSequence.toRadixString(16).padLeft(2, '0').toUpperCase()}). Check BLE signal quality and retry.',
);
}
nextFrameIndex = _ackedFrames;
}
_emitProgress(
state: DfuUpdateState.finishing, sentBytes: imageBytes.length);
final finishResult =
await _transport.writeControl(DfuProtocol.encodeFinishPayload());
if (finishResult.isErr()) {
throw _DfuFailure(
'Failed to send DFU FINISH command: ${finishResult.unwrapErr()}',
);
}
await _ackSubscription?.cancel();
_ackSubscription = null;
final resetDisconnectResult =
await _transport.waitForExpectedResetDisconnect(
timeout: effectivePostFinishResetTimeout,
);
if (resetDisconnectResult.isErr()) {
throw _DfuFailure(
'Device did not perform the expected post-FINISH reset disconnect: ${resetDisconnectResult.unwrapErr()}',
);
}
final reconnectResult = await _transport.reconnectForVerification(
timeout: effectiveReconnectTimeout,
);
if (reconnectResult.isErr()) {
throw _DfuFailure(
'Device did not reconnect after DFU reset: ${reconnectResult.unwrapErr()}',
);
}
final verificationResult = await _transport.verifyDeviceReachable(
timeout: effectiveVerificationTimeout,
);
if (verificationResult.isErr()) {
throw _DfuFailure(
'Device reconnected but post-update verification failed: ${verificationResult.unwrapErr()} '
'Firmware version cannot be compared yet because the device does not expose a version characteristic.',
);
}
shouldAbortForCleanup = false;
_emitProgress(
state: DfuUpdateState.completed, sentBytes: imageBytes.length);
return Ok(null);
} on _DfuCancelled {
if (shouldAbortForCleanup) {
await _sendAbortForCleanup();
}
_emitProgress(state: DfuUpdateState.aborted);
return bail('Firmware update canceled by user.');
} on _DfuFailure catch (failure) {
if (shouldAbortForCleanup) {
await _sendAbortForCleanup();
}
_emitProgress(
state: DfuUpdateState.failed, errorMessage: failure.message);
return bail(failure.message);
} catch (error) {
if (shouldAbortForCleanup) {
await _sendAbortForCleanup();
}
final message =
'Firmware update failed unexpectedly: $error. Reconnect to the button and retry.';
_emitProgress(state: DfuUpdateState.failed, errorMessage: message);
return bail(message);
} finally {
await _ackSubscription?.cancel();
_ackSubscription = null;
_isRunning = false;
_cancelRequested = false;
_cancelSignal = null;
_ackSignal = null;
_ackEventCount = 0;
_ackStreamError = null;
_latestAckSequence = _currentProgress.lastAckedSequence;
_ackedFrames = 0;
_totalFrames = 0;
_totalBytes = 0;
}
}
Future<void> cancelUpdate() async {
if (!_isRunning || _cancelRequested) {
return;
}
_cancelRequested = true;
_cancelSignal?.complete();
_signalAckWaiters();
}
Future<void> dispose() async {
await cancelUpdate();
await _ackSubscription?.cancel();
_ackSubscription = null;
await _progressController.close();
}
void _handleAckPayload(List<int> payload) {
try {
final sequence = DfuProtocol.parseAckPayload(payload);
final previousAck = _latestAckSequence;
_latestAckSequence = sequence;
if (_totalFrames > 0 &&
_currentProgress.state == DfuUpdateState.transferring) {
final delta = DfuProtocol.sequenceDistance(previousAck, sequence);
if (delta > 0) {
_ackedFrames = (_ackedFrames + delta).clamp(0, _totalFrames);
}
_emitProgress(
lastAckedSequence: sequence,
sentBytes:
_ackedBytesFromFrames(_ackedFrames, _totalFrames, _totalBytes),
);
} else {
_emitProgress(lastAckedSequence: sequence);
}
} on FormatException catch (error) {
_ackStreamError =
'Received malformed ACK indication: $error. Reconnect and retry.';
} finally {
_ackEventCount += 1;
_signalAckWaiters();
}
}
void _emitProgress({
DfuUpdateState? state,
int? totalBytes,
int? sentBytes,
int? lastAckedSequence,
int? sessionId,
DfuUpdateFlags? flags,
String? errorMessage,
}) {
final next = DfuUpdateProgress(
state: state ?? _currentProgress.state,
totalBytes: totalBytes ?? _currentProgress.totalBytes,
sentBytes: sentBytes ?? _currentProgress.sentBytes,
lastAckedSequence:
lastAckedSequence ?? _currentProgress.lastAckedSequence,
sessionId: sessionId ?? _currentProgress.sessionId,
flags: flags ?? _currentProgress.flags,
errorMessage: errorMessage,
);
_currentProgress = next;
_progressController.add(next);
}
Future<int> _waitForInitialAck({
required int afterEventCount,
required Duration timeout,
}) async {
final deadline = DateTime.now().add(timeout);
var observedEvents = afterEventCount;
while (true) {
_throwIfCancelled();
_throwIfAckStreamErrored();
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
throw _DfuFailure(
'Timed out waiting for initial DFU ACK after START. Ensure indications are enabled and retry.',
);
}
final gotEvent = await _waitForNextAckEvent(
afterEventCount: observedEvents,
timeout: remaining,
);
if (!gotEvent) {
continue;
}
observedEvents = _ackEventCount;
return _latestAckSequence;
}
}
Future<bool> _waitForAckProgress({
required int ackedFramesBeforeWait,
required Duration timeout,
}) async {
final deadline = DateTime.now().add(timeout);
var observedEvents = _ackEventCount;
while (true) {
_throwIfCancelled();
_throwIfAckStreamErrored();
if (_ackedFrames > ackedFramesBeforeWait) {
return true;
}
final remaining = deadline.difference(DateTime.now());
if (remaining <= Duration.zero) {
return false;
}
final gotEvent = await _waitForNextAckEvent(
afterEventCount: observedEvents,
timeout: remaining,
);
if (!gotEvent) {
continue;
}
observedEvents = _ackEventCount;
}
}
Future<bool> _waitForNextAckEvent({
required int afterEventCount,
required Duration timeout,
}) async {
if (_ackEventCount > afterEventCount) {
return true;
}
_ackSignal ??= Completer<void>();
final signal = _ackSignal!;
try {
await Future.any<void>([
signal.future,
_cancelSignal?.future ?? Future<void>.value(),
]).timeout(timeout);
} on TimeoutException {
return false;
}
if (identical(_ackSignal, signal)) {
_ackSignal = null;
}
_throwIfCancelled();
_throwIfAckStreamErrored();
return _ackEventCount > afterEventCount;
}
void _throwIfCancelled() {
if (_cancelRequested) {
throw const _DfuCancelled();
}
}
void _throwIfAckStreamErrored() {
final error = _ackStreamError;
if (error != null) {
throw _DfuFailure(error);
}
}
Future<void> _sendAbortForCleanup() async {
final result =
await _transport.writeControl(DfuProtocol.encodeAbortPayload());
if (result.isErr()) {
final cleanupMessage =
'Could not send DFU ABORT during cleanup: ${result.unwrapErr()}';
if (_currentProgress.state == DfuUpdateState.failed &&
_currentProgress.errorMessage != null) {
_emitProgress(
errorMessage: '${_currentProgress.errorMessage} $cleanupMessage',
);
}
}
}
void _signalAckWaiters() {
final signal = _ackSignal;
if (signal != null && !signal.isCompleted) {
signal.complete();
}
}
int _ackedBytesFromFrames(int ackedFrames, int totalFrames, int totalBytes) {
if (totalFrames == 0 || ackedFrames <= 0) {
return 0;
}
if (ackedFrames >= totalFrames) {
return totalBytes;
}
return ackedFrames * universalShifterDfuFramePayloadSizeBytes;
}
}
abstract interface class FirmwareUpdateTransport {
Future<Result<DfuPreflightResult>> runPreflight({required int requestedMtu});
Stream<List<int>> subscribeToAck();
Future<Result<void>> writeControl(List<int> payload);
Future<Result<void>> writeDataFrame(List<int> frame);
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
});
Future<Result<void>> reconnectForVerification({
required Duration timeout,
});
/// Verifies that the device is reachable after reconnect.
///
/// Current limitation: strict firmware version comparison is not possible
/// yet because no firmware version characteristic is exposed by the device.
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
});
}
class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
ShifterFirmwareUpdateTransport({
required this.shifterService,
required this.bluetoothController,
required this.buttonDeviceId,
});
final ShifterService shifterService;
final BluetoothController bluetoothController;
final String buttonDeviceId;
@override
Future<Result<DfuPreflightResult>> runPreflight({
required int requestedMtu,
}) {
return shifterService.runDfuPreflight(requestedMtu: requestedMtu);
}
@override
Stream<List<int>> subscribeToAck() {
return bluetoothController.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterDfuAckCharacteristicUuid,
);
}
@override
Future<Result<void>> writeControl(List<int> payload) {
return bluetoothController.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterDfuControlCharacteristicUuid,
payload,
);
}
@override
Future<Result<void>> writeDataFrame(List<int> frame) {
return bluetoothController.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterDfuDataCharacteristicUuid,
frame,
withResponse: false,
);
}
@override
Future<Result<void>> waitForExpectedResetDisconnect({
required Duration timeout,
}) async {
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.disconnected) {
return Ok(null);
}
try {
await bluetoothController.connectionStateStream
.firstWhere((state) => state.$1 == ConnectionStatus.disconnected)
.timeout(timeout);
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for the expected reset disconnect.',
);
} catch (error) {
return bail('Failed while waiting for expected reset disconnect: $error');
}
}
@override
Future<Result<void>> reconnectForVerification({
required Duration timeout,
}) async {
final connectResult =
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
if (connectResult.isErr()) {
return bail(connectResult.unwrapErr());
}
final currentState = bluetoothController.currentConnectionState;
if (currentState.$1 == ConnectionStatus.connected &&
currentState.$2 == buttonDeviceId) {
return Ok(null);
}
try {
await bluetoothController.connectionStateStream
.firstWhere(
(state) =>
state.$1 == ConnectionStatus.connected &&
state.$2 == buttonDeviceId,
)
.timeout(timeout);
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms waiting for reconnect.',
);
} catch (error) {
return bail('Reconnect wait failed: $error');
}
}
@override
Future<Result<void>> verifyDeviceReachable({
required Duration timeout,
}) async {
try {
final statusResult = await shifterService.readStatus().timeout(timeout);
if (statusResult.isErr()) {
return bail(statusResult.unwrapErr());
}
return Ok(null);
} on TimeoutException {
return bail(
'Timed out after ${timeout.inMilliseconds}ms while reading status for post-update verification.',
);
} catch (error) {
return bail('Post-update verification failed: $error');
}
}
}
class _DfuFailure implements Exception {
const _DfuFailure(this.message);
final String message;
@override
String toString() => message;
}
class _DfuCancelled implements Exception {
const _DfuCancelled();
}

View File

@ -0,0 +1,325 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:anyhow/anyhow.dart';
class ShifterService {
ShifterService({
BluetoothController? bluetooth,
required this.buttonDeviceId,
DfuPreflightBluetoothAdapter? dfuPreflightBluetooth,
}) : _bluetooth = bluetooth,
_dfuPreflightBluetooth =
dfuPreflightBluetooth ?? _BluetoothDfuPreflightAdapter(bluetooth!) {
if (bluetooth == null && dfuPreflightBluetooth == null) {
throw ArgumentError(
'Either bluetooth or dfuPreflightBluetooth must be provided.',
);
}
}
final BluetoothController? _bluetooth;
final String buttonDeviceId;
final DfuPreflightBluetoothAdapter _dfuPreflightBluetooth;
BluetoothController get _requireBluetooth {
final bluetooth = _bluetooth;
if (bluetooth == null) {
throw StateError('Bluetooth controller is not available.');
}
return bluetooth;
}
final StreamController<CentralStatus> _statusController =
StreamController<CentralStatus>.broadcast();
StreamSubscription<List<int>>? _statusSubscription;
Stream<CentralStatus> get statusStream => _statusController.stream;
static const int _gearRatioSlots = 32;
static const int _defaultGearIndexOffset = _gearRatioSlots;
static const int _gearRatioPayloadBytes = _gearRatioSlots + 1;
static const double _maxGearRatio = 255 / 64;
static const int _gearRatioWriteMtu = 64;
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
try {
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterConnectToAddrCharacteristicUuid,
payload,
);
} on FormatException catch (e) {
return bail('Could not parse bike address "$bikeDeviceId": $e');
} catch (e) {
return bail('Failed writing connect address: $e');
}
}
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterCommandCharacteristicUuid,
[command.value],
);
}
Future<Result<void>> connectButtonToBike(String bikeDeviceId) async {
final addrRes = await writeConnectToAddress(bikeDeviceId);
if (addrRes.isErr()) {
return addrRes;
}
return writeCommand(UniversalShifterCommand.connectToDevice);
}
Future<Result<GearRatiosData>> readGearRatios() async {
final readRes = await _requireBluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
}
final raw = readRes.unwrap();
if (raw.length > _gearRatioPayloadBytes) {
return bail(
'Invalid gear ratio payload length: expected at most $_gearRatioPayloadBytes, got ${raw.length}',
);
}
final normalizedRaw = List<int>.filled(
_gearRatioPayloadBytes,
0,
growable: false,
);
for (var i = 0; i < raw.length; i++) {
normalizedRaw[i] = raw[i];
}
final ratios = <double>[];
for (var i = 0; i < _gearRatioSlots; i++) {
final value = normalizedRaw[i];
if (value == 0) {
break;
}
ratios.add(_decodeGearRatio(value));
}
final defaultIndexRaw = normalizedRaw[_defaultGearIndexOffset];
final defaultGearIndex = ratios.isEmpty
? 0
: defaultIndexRaw.clamp(0, ratios.length - 1).toInt();
return Ok(
GearRatiosData(
ratios: ratios,
defaultGearIndex: defaultGearIndex,
),
);
}
Future<Result<void>> writeGearRatios(GearRatiosData data) async {
final mtuResult = await _requireBluetooth.requestMtu(
buttonDeviceId,
mtu: _gearRatioWriteMtu,
);
if (mtuResult.isErr()) {
return bail(
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
}
final payload =
List<int>.filled(_gearRatioPayloadBytes, 0, growable: false);
final ratios = data.ratios;
final limit =
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
for (var i = 0; i < limit; i++) {
payload[i] = _encodeGearRatio(ratios[i]);
}
payload[_defaultGearIndexOffset] =
limit == 0 ? 0 : data.defaultGearIndex.clamp(0, limit - 1).toInt();
return _requireBluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
payload,
);
}
Future<Result<CentralStatus>> readStatus() async {
final readRes = await _requireBluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
}
try {
return Ok(CentralStatus.fromCborBytes(readRes.unwrap()));
} catch (e) {
return bail('Failed to decode status payload: $e');
}
}
Future<Result<DfuPreflightResult>> runDfuPreflight({
int requestedMtu = universalShifterDfuPreferredMtu,
}) async {
final currentConnection = _dfuPreflightBluetooth.currentConnectionState;
final connectionStatus = currentConnection.$1;
final connectedDeviceId = currentConnection.$2;
if (connectionStatus != ConnectionStatus.connected ||
connectedDeviceId == null) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.deviceNotConnected,
message:
'No button connection is active. Connect the target button, then retry the firmware update.',
),
);
}
if (connectedDeviceId != buttonDeviceId) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.wrongConnectedDevice,
message:
'Connected to a different button ($connectedDeviceId). Reconnect to $buttonDeviceId before starting the firmware update.',
),
);
}
final mtuResult = await _dfuPreflightBluetooth.requestMtuAndGetValue(
buttonDeviceId,
mtu: requestedMtu,
);
if (mtuResult.isErr()) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
failureReason: DfuPreflightFailureReason.mtuRequestFailed,
message:
'Could not negotiate BLE MTU for DFU. Move closer to the button and retry. Details: ${mtuResult.unwrapErr()}',
),
);
}
final negotiatedMtu = mtuResult.unwrap();
if (negotiatedMtu < universalShifterDfuMinimumMtu) {
return Ok(
DfuPreflightResult.failed(
requestedMtu: requestedMtu,
negotiatedMtu: negotiatedMtu,
failureReason: DfuPreflightFailureReason.mtuTooLow,
message:
'Negotiated MTU $negotiatedMtu is too small for 64-byte DFU frames. Need at least $universalShifterDfuMinimumMtu bytes MTU (64-byte frame + $universalShifterAttWriteOverheadBytes-byte ATT overhead). Reconnect and retry.',
),
);
}
return Ok(
DfuPreflightResult.ready(
requestedMtu: requestedMtu,
negotiatedMtu: negotiatedMtu,
),
);
}
void startStatusNotifications() {
if (_statusSubscription != null) {
return;
}
_statusSubscription = _requireBluetooth
.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
)
.listen(
(data) {
try {
final status = CentralStatus.fromCborBytes(data);
_statusController.add(status);
} catch (_) {
// Ignore malformed payloads but keep stream alive.
}
},
onError: (_) {
// Keep UI running; reconnection logic is handled elsewhere.
},
);
}
Future<void> stopStatusNotifications() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
}
Future<void> dispose() async {
await stopStatusNotifications();
await _statusController.close();
}
int _encodeGearRatio(double value) {
if (value <= 0) {
return 0;
}
final clamped = value.clamp(0, _maxGearRatio);
final scaled = (clamped * 64).round();
if (scaled <= 0) {
return 1;
}
return scaled.clamp(1, 255);
}
double _decodeGearRatio(int raw) {
return raw / 64.0;
}
}
abstract interface class DfuPreflightBluetoothAdapter {
(ConnectionStatus, String?) get currentConnectionState;
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
});
}
class _BluetoothDfuPreflightAdapter implements DfuPreflightBluetoothAdapter {
const _BluetoothDfuPreflightAdapter(this._bluetooth);
final BluetoothController _bluetooth;
@override
(ConnectionStatus, String?) get currentConnectionState =>
_bluetooth.currentConnectionState;
@override
Future<Result<int>> requestMtuAndGetValue(
String deviceId, {
required int mtu,
}) {
return _bluetooth.requestMtuAndGetValue(deviceId, mtu: mtu);
}
}
class GearRatiosData {
const GearRatiosData({
required this.ratios,
required this.defaultGearIndex,
});
final List<double> ratios;
final int defaultGearIndex;
}

View File

@ -0,0 +1,10 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
String greet({required String name}) =>
RustLib.instance.api.crateApiSimpleGreet(name: name);

View File

@ -0,0 +1,240 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/simple.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'frb_generated.io.dart'
if (dart.library.js_interop) 'frb_generated.web.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
/// Main entrypoint of the Rust API
class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
@internal
static final instance = RustLib._();
RustLib._();
/// Initialize flutter_rust_bridge
static Future<void> init({
RustLibApi? api,
BaseHandler? handler,
ExternalLibrary? externalLibrary,
bool forceSameCodegenVersion = true,
}) async {
await instance.initImpl(
api: api,
handler: handler,
externalLibrary: externalLibrary,
forceSameCodegenVersion: forceSameCodegenVersion,
);
}
/// Initialize flutter_rust_bridge in mock mode.
/// No libraries for FFI are loaded.
static void initMock({
required RustLibApi api,
}) {
instance.initMockImpl(
api: api,
);
}
/// Dispose flutter_rust_bridge
///
/// The call to this function is optional, since flutter_rust_bridge (and everything else)
/// is automatically disposed when the app stops.
static void dispose() => instance.disposeImpl();
@override
ApiImplConstructor<RustLibApiImpl, RustLibWire> get apiImplConstructor =>
RustLibApiImpl.new;
@override
WireConstructor<RustLibWire> get wireConstructor =>
RustLibWire.fromExternalLibrary;
@override
Future<void> executeRustInitializers() async {
await api.crateApiSimpleInitApp();
}
@override
ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig =>
kDefaultExternalLibraryLoaderConfig;
@override
String get codegenVersion => '2.11.1';
@override
int get rustContentHash => -1918914929;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
stem: 'rust_lib_abawo_bt_app',
ioDirectory: 'rust/target/release/',
webPrefix: 'pkg/',
);
}
abstract class RustLibApi extends BaseApi {
String crateApiSimpleGreet({required String name});
Future<void> crateApiSimpleInitApp();
}
class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
RustLibApiImpl({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@override
String crateApiSimpleGreet({required String name}) {
return handler.executeSync(SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(name, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 1)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
decodeErrorData: null,
),
constMeta: kCrateApiSimpleGreetConstMeta,
argValues: [name],
apiImpl: this,
));
}
TaskConstMeta get kCrateApiSimpleGreetConstMeta => const TaskConstMeta(
debugName: "greet",
argNames: ["name"],
);
@override
Future<void> crateApiSimpleInitApp() {
return handler.executeNormal(NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(generalizedFrbRustBinding, serializer,
funcId: 2, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiSimpleInitAppConstMeta,
argValues: [],
apiImpl: this,
));
}
TaskConstMeta get kCrateApiSimpleInitAppConstMeta => const TaskConstMeta(
debugName: "init_app",
argNames: [],
);
@protected
String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as String;
}
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as Uint8List;
}
@protected
int dco_decode_u_8(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as int;
}
@protected
void dco_decode_unit(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return;
}
@protected
String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var inner = sse_decode_list_prim_u_8_strict(deserializer);
return utf8.decoder.convert(inner);
}
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
return deserializer.buffer.getUint8List(len_);
}
@protected
int sse_decode_u_8(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getUint8();
}
@protected
void sse_decode_unit(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
}
@protected
int sse_decode_i_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getInt32();
}
@protected
bool sse_decode_bool(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getUint8() != 0;
}
@protected
void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer);
}
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
serializer.buffer.putUint8List(self);
}
@protected
void sse_encode_u_8(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putUint8(self);
}
@protected
void sse_encode_unit(void self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
}
@protected
void sse_encode_i_32(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putInt32(self);
}
@protected
void sse_encode_bool(bool self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putUint8(self ? 1 : 0);
}
}

View File

@ -0,0 +1,84 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/simple.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
String dco_decode_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) =>
RustLibWire(lib.ffiDynamicLibrary);
/// Holds the symbol lookup function.
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustLibWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
}

View File

@ -0,0 +1,84 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
// Static analysis wrongly picks the IO variant, thus ignore this
// ignore_for_file: argument_type_not_assignable
import 'api/simple.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
String dco_decode_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
RustLibWire.fromExternalLibrary(ExternalLibrary lib);
}
@JS('wasm_bindgen')
external RustLibWasmModule get wasmModule;
@JS()
@anonymous
extension type RustLibWasmModule._(JSObject _) implements JSObject {}

View File

@ -1 +1,21 @@
const abawoServiceBtUUID = '0993826f-0ee4-4b37-9614-d13ecba4ffc2';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
const abawoServiceBtUUIDPrefix = '0993826f-0ee4-4b37-9614';
const abawoUniversalShiftersServiceBtUUID =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
bool isAbawoDeviceGuid(Uuid guid) {
return guid
.toString()
.toLowerCase()
.replaceAll('-', '')
.startsWith(abawoServiceBtUUIDPrefix.toLowerCase().replaceAll('-', ''));
}
bool isAbawoUniversalShiftersDeviceGuid(Uuid guid) {
return guid == Uuid.parse(abawoUniversalShiftersServiceBtUUID);
}
bool isConnectableAbawoDeviceGuid(Uuid guid) {
return isAbawoUniversalShiftersDeviceGuid(guid);
}

View File

@ -1,8 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'sharedPrefs.g.dart';
// part 'sharedPrefs.g.dart';
final sharedPreferencesProvider =
Provider<SharedPreferences>((ref) => throw UnimplementedError());

View File

@ -1,197 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sharedPrefs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sharedPrefValueHash() => r'6c78fac8d11d0df162d4d53f465c1c8535fcd150';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$SharedPrefValue extends BuildlessAutoDisposeNotifier<T> {
late final String key;
late final T defaultValue;
T build(
String key,
T defaultValue,
);
}
/// See also [SharedPrefValue].
@ProviderFor(SharedPrefValue)
const sharedPrefValueProvider = SharedPrefValueFamily();
/// See also [SharedPrefValue].
class SharedPrefValueFamily extends Family<T> {
/// See also [SharedPrefValue].
const SharedPrefValueFamily();
/// See also [SharedPrefValue].
SharedPrefValueProvider call(
String key,
T defaultValue,
) {
return SharedPrefValueProvider(
key,
defaultValue,
);
}
@override
SharedPrefValueProvider getProviderOverride(
covariant SharedPrefValueProvider provider,
) {
return call(
provider.key,
provider.defaultValue,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'sharedPrefValueProvider';
}
/// See also [SharedPrefValue].
class SharedPrefValueProvider
extends AutoDisposeNotifierProviderImpl<SharedPrefValue, T> {
/// See also [SharedPrefValue].
SharedPrefValueProvider(
String key,
T defaultValue,
) : this._internal(
() => SharedPrefValue()
..key = key
..defaultValue = defaultValue,
from: sharedPrefValueProvider,
name: r'sharedPrefValueProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$sharedPrefValueHash,
dependencies: SharedPrefValueFamily._dependencies,
allTransitiveDependencies:
SharedPrefValueFamily._allTransitiveDependencies,
key: key,
defaultValue: defaultValue,
);
SharedPrefValueProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.key,
required this.defaultValue,
}) : super.internal();
final String key;
final T defaultValue;
@override
T runNotifierBuild(
covariant SharedPrefValue notifier,
) {
return notifier.build(
key,
defaultValue,
);
}
@override
Override overrideWith(SharedPrefValue Function() create) {
return ProviderOverride(
origin: this,
override: SharedPrefValueProvider._internal(
() => create()
..key = key
..defaultValue = defaultValue,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
key: key,
defaultValue: defaultValue,
),
);
}
@override
AutoDisposeNotifierProviderElement<SharedPrefValue, T> createElement() {
return _SharedPrefValueProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SharedPrefValueProvider &&
other.key == key &&
other.defaultValue == defaultValue;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, key.hashCode);
hash = _SystemHash.combine(hash, defaultValue.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SharedPrefValueRef on AutoDisposeNotifierProviderRef<T> {
/// The parameter `key` of this provider.
String get key;
/// The parameter `defaultValue` of this provider.
T get defaultValue;
}
class _SharedPrefValueProviderElement
extends AutoDisposeNotifierProviderElement<SharedPrefValue, T>
with SharedPrefValueRef {
_SharedPrefValueProviderElement(super.provider);
@override
String get key => (origin as SharedPrefValueProvider).key;
@override
T get defaultValue => (origin as SharedPrefValueProvider).defaultValue;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -0,0 +1,214 @@
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BikeScanDialog extends ConsumerStatefulWidget {
const BikeScanDialog({
required this.excludedDeviceId,
super.key,
});
final String excludedDeviceId;
static Future<DiscoveredDevice?> show(
BuildContext context, {
required String excludedDeviceId,
}) {
return showDialog<DiscoveredDevice>(
context: context,
barrierDismissible: true,
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId),
);
}
@override
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState();
}
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
bool _showAll = false;
BluetoothController? _controller;
@override
void initState() {
super.initState();
_startScan();
}
Future<void> _startScan() async {
final controller = await ref.read(bluetoothProvider.future);
_controller = controller;
await controller.stopScan();
await controller.startScan();
}
@override
void dispose() {
_controller?.stopScan();
super.dispose();
}
@override
Widget build(BuildContext context) {
final btAsync = ref.watch(bluetoothProvider);
return Dialog(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: SizedBox(
width: 520,
height: 520,
child: btAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
data: (controller) {
_controller ??= controller;
return Column(
children: [
_buildHeader(context),
const Divider(height: 1),
Expanded(
child: StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream,
initialData: controller.scanResults,
builder: (context, snapshot) {
final devices =
_filteredDevices(snapshot.data ?? const []);
if (devices.isEmpty) {
return const Center(
child: Text('No matching devices nearby.'),
);
}
return ListView.separated(
itemCount: devices.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final device = devices[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: CircleAvatar(
backgroundColor: Theme.of(context)
.colorScheme
.primaryContainer,
child: const Icon(Icons.pedal_bike),
),
title: Text(
device.name.isEmpty
? 'Unknown Device'
: device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
device.id,
style: const TextStyle(fontFamily: 'monospace'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: _RssiBadge(rssi: device.rssi),
onTap: () {
Navigator.of(context).pop(device);
},
);
},
);
},
),
),
],
);
},
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: Row(
children: [
const Expanded(
child: Text(
'Select Bike',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
),
Row(
children: [
const Text('Show All'),
Switch(
value: _showAll,
onChanged: (value) {
setState(() {
_showAll = value;
});
},
),
],
),
IconButton(
tooltip: 'Rescan',
onPressed: _startScan,
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
);
}
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
final ftmsUuid = Uuid.parse(ftmsServiceUuid);
return devices.where((device) {
if (device.id == widget.excludedDeviceId) {
return false;
}
if (_showAll) {
return true;
}
return device.serviceUuids.contains(ftmsUuid);
}).toList(growable: false);
}
}
class _RssiBadge extends StatelessWidget {
const _RssiBadge({required this.rssi});
final int rssi;
@override
Widget build(BuildContext context) {
final color = rssi > -65
? Colors.green
: rssi > -80
? Colors.orange
: Colors.red;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$rssi dBm',
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
}

View File

@ -1,18 +1,23 @@
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:flutter/material.dart';
import 'dart:ui'; // Required for ImageFilter
class DeviceListItem extends StatelessWidget {
final String deviceName;
final String deviceId; // Added for potential future use or subtitle
final bool isUnknownDevice;
final DeviceType type;
// final String? imageUrl; // Optional image URL - commented out for now
final bool isConnecting; // Add this line
final Widget? trailing;
const DeviceListItem({
super.key,
required this.deviceName,
required this.deviceId,
this.isUnknownDevice = false,
required this.type,
// this.imageUrl,
this.isConnecting = false, // Add this line
this.trailing,
});
@override
@ -22,11 +27,11 @@ class DeviceListItem extends StatelessWidget {
// Glassy effect colors - adjust transparency and base color as needed
final glassColor = isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.black.withOpacity(0.05);
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.05);
final shadowColor = isDarkMode
? Colors.black.withOpacity(0.4)
: Colors.grey.withOpacity(0.5);
? Colors.black.withValues(alpha: 0.4)
: Colors.grey.withValues(alpha: 0.5);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
@ -57,18 +62,30 @@ class DeviceListItem extends StatelessWidget {
glassColor, // Semi-transparent color for glass effect
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.2), // Subtle border
color:
Colors.white.withValues(alpha: 0.2), // Subtle border
width: 0.5,
),
),
child: const Center(
// Placeholder '?' - replace with Image widget when imageUrl is available
child: type == DeviceType.universalShifters
// For Universal Shifters: Image fills the container, constrained by rounded borders
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/images/shifter-wireframe.png',
fit: BoxFit.cover, // Cover the entire container
width: 60,
height: 60,
),
)
// For other devices: Question mark with padding
: const Center(
child: Text(
'?',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white70, // Adjust color as needed
color: Colors.white70,
),
),
),
@ -84,14 +101,16 @@ class DeviceListItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isUnknownDevice ? 'Unknown Device' : deviceName,
deviceName.isEmpty ? 'Unknown Device' : deviceName,
style: TextStyle(
fontSize: 16,
fontWeight:
isUnknownDevice ? FontWeight.normal : FontWeight.w500,
fontStyle:
isUnknownDevice ? FontStyle.italic : FontStyle.normal,
color: isUnknownDevice
fontWeight: deviceName.isEmpty
? FontWeight.normal
: FontWeight.w500,
fontStyle: deviceName.isEmpty
? FontStyle.italic
: FontStyle.normal,
color: deviceName.isEmpty
? theme.hintColor
: theme.textTheme.bodyLarge?.color,
),
@ -108,6 +127,19 @@ class DeviceListItem extends StatelessWidget {
],
),
),
// Add spinner if connecting (Add this block)
if (isConnecting)
Padding(
padding: const EdgeInsets.only(left: 12.0), // Add some spacing
child: SizedBox(
width: 20, // Define spinner size
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.0), // Use a small spinner
),
)
else if (trailing != null)
trailing!,
// Optional: Add an icon or button on the far right if needed later
// Icon(Icons.chevron_right, color: theme.hintColor),
],

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
import 'dart:math';
import 'package:flutter/material.dart';
class HorizontalScanningAnimation extends StatefulWidget {
final bool isScanning; // Add this to control the animation
final Color waveColor;
final double height;
const HorizontalScanningAnimation({
super.key,
required this.isScanning, // Make it required
this.waveColor = Colors.lightBlueAccent,
this.height = 50.0,
});
@override
_HorizontalScanningAnimationState createState() =>
_HorizontalScanningAnimationState();
}
class _HorizontalScanningAnimationState
extends State<HorizontalScanningAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
// Start repeating only if initially scanning
if (widget.isScanning) {
_controller.repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant HorizontalScanningAnimation oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isScanning != oldWidget.isScanning) {
if (widget.isScanning) {
// Start or resume repeating
if (!_controller.isAnimating) {
// If stopped previously, reset before repeating for a clean start
// Though, repeat() should handle restarting if stopped. Testing needed.
// _controller.reset(); // Optional: uncomment if repeat doesn't restart smoothly
_controller.repeat();
}
} else {
// Stop repeating, but let the current animation cycle finish visually
if (_controller.isAnimating) {
_controller.stop(
canceled:
false); // Use canceled: false to let it finish the current tick
}
}
}
}
@override
Widget build(BuildContext context) {
// Only build the painter if the controller is active or was recently stopped
// This prevents drawing when completely idle. Check if value is changing or non-zero.
// Or simply rely on the AnimatedBuilder which won't rebuild if controller is idle at 0.0
return SizedBox(
height: widget.height,
width: double.infinity,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: _HorizontalWavePainter(
progress: _controller.value,
waveColor: widget.waveColor,
),
);
},
),
);
}
}
class _HorizontalWavePainter extends CustomPainter {
final double progress; // Animation value from 0.0 to 1.0
final Color waveColor;
final int waveCount = 2; // Number of waves visible at once
final double waveAmplitude = 10.0; // Max height deviation of the wave
_HorizontalWavePainter({required this.progress, required this.waveColor});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = waveColor.withValues(alpha: 0.6) // Semi-transparent waves
..style = PaintingStyle.fill;
final centerY = size.height / 2;
final width = size.width;
// Draw multiple waves propagating outwards
for (int i = 0; i < waveCount; i++) {
// Calculate the phase offset for each wave based on progress and index
// This creates the effect of waves moving outwards
double waveProgress = (progress + i / waveCount) % 1.0;
// Use an easing curve for smoother expansion
double easedProgress = Curves.easeInOutSine.transform(waveProgress);
// Calculate the current horizontal position (expanding from center)
// The wave starts narrow and expands outwards
double currentWidth =
width * easedProgress * 0.8; // Max 80% width expansion
double startX = (width / 2) - (currentWidth / 2);
double endX = (width / 2) + (currentWidth / 2);
// Calculate opacity based on progress (fade in and out)
double opacity;
if (waveProgress < 0.1) {
opacity = waveProgress / 0.1; // Fade in
} else if (waveProgress > 0.8) {
opacity = (1.0 - waveProgress) / 0.2; // Fade out
} else {
opacity = 1.0;
}
opacity = max(0.0, opacity); // Clamp opacity
if (opacity <= 0.0 || currentWidth < 5)
continue; // Skip drawing if invisible or too small
// Create the wave path
final path = Path();
path.moveTo(startX, centerY);
// Calculate points for the sine wave shape within the current width
const int segments = 50; // Number of segments for the curve
for (int j = 0; j <= segments; j++) {
double segmentProgress = j / segments;
double x = startX + currentWidth * segmentProgress;
// Apply sine wave based on segment progress and overall animation progress
// Multiply by (1 - easedProgress) to reduce amplitude as it expands
double yOffset = waveAmplitude *
sin(segmentProgress * 2 * pi + progress * 4 * pi) *
(1 - easedProgress * 0.8) * // Reduce amplitude as it expands
opacity; // Apply opacity effect to amplitude too
path.lineTo(x, centerY + yOffset);
}
// Draw a filled shape (like a lens flare or horizontal bar)
// Adjust thickness based on easedProgress (thicker in the middle, thinner at ends)
double thickness =
waveAmplitude * (1 - easedProgress * 0.9) * opacity * 0.5;
paint.color = waveColor.withValues(
alpha: opacity * 0.5); // Update paint color with opacity
// Simplified: Draw a rectangle that pulses
// More complex shapes could be drawn here using path.arcTo or path.quadraticBezierTo
// For simplicity, let's use a slightly blurred rectangle effect
final rectPath = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(width / 2, centerY),
width: currentWidth,
height: thickness * 2),
Radius.circular(thickness)));
// Apply a blur effect
final blurPaint = Paint()
..color = waveColor.withValues(alpha: opacity * 0.4)
..maskFilter = MaskFilter.blur(
BlurStyle.normal, thickness * 1.5); // Blur based on thickness
// Draw the blurred shape
canvas.drawPath(rectPath, blurPaint);
// Draw a slightly smaller, less opaque shape on top for highlight
final highlightPaint = Paint()
..color = waveColor.withValues(alpha: opacity * 0.7)
..style = PaintingStyle.fill;
final highlightRectPath = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(width / 2, centerY),
width: currentWidth * 0.95,
height: thickness * 1.5),
Radius.circular(thickness * 0.8)));
canvas.drawPath(highlightRectPath, highlightPaint);
// Old Path drawing - keep if rectangle isn't desired
// paint.color = waveColor.withValues(alpha: opacity * 0.5); // Apply opacity
// canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant _HorizontalWavePainter oldDelegate) {
// Repaint whenever the animation progress or color changes
return oldDelegate.progress != progress ||
oldDelegate.waveColor != waveColor;
}
}

View File

@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
#include <nb_utils/nb_utils_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) nb_utils_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "NbUtilsPlugin");
nb_utils_plugin_register_with_registrar(nb_utils_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
}

View File

@ -3,9 +3,12 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
nb_utils
sqlite3_flutter_libs
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
rust_lib_abawo_bt_app
)
set(PLUGIN_BUNDLED_LIBRARIES)

View File

@ -5,10 +5,22 @@
import FlutterMacOS
import Foundation
import connectivity_plus
import file_picker
import flutter_blue_plus_darwin
import nb_utils
import path_provider_foundation
import reactive_ble_mobile
import shared_preferences_foundation
import sqlite3_flutter_libs
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
NbUtilsPlugin.register(with: registry.registrar(forPlugin: "NbUtilsPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ReactiveBlePlugin.register(with: registry.registrar(forPlugin: "ReactiveBlePlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
}

View File

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
build_config:
dependency: transitive
description:
@ -129,14 +137,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.9.5"
cbor:
dependency: "direct main"
description:
name: cbor
sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2"
url: "https://pub.dev"
source: hosted
version: "6.5.1"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -165,10 +189,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
@ -181,10 +205,26 @@ packages:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
@ -193,6 +233,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -257,14 +305,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
drift:
dependency: "direct main"
description:
name: drift
sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev"
source: hosted
version: "2.26.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588"
url: "https://pub.dev"
source: hosted
version: "2.26.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: "0cadbf3b8733409a6cf61d18ba2e94e149df81df7de26f48ae0695b48fd71922"
url: "https://pub.dev"
source: hosted
version: "0.2.4"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.3"
ffi:
dependency: transitive
description:
@ -281,6 +353,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
fixnum:
dependency: transitive
description:
@ -298,50 +378,63 @@ packages:
dependency: "direct main"
description:
name: flutter_blue_plus
sha256: "2d926dbef0fd6c58d4be8fca9eaaf1ba747c0ccb8373ddd5386665317e26eb61"
sha256: "399b3dbc15562ef59749f04e43a99ccbb91540022380d5f269aff3c2787534e4"
url: "https://pub.dev"
source: hosted
version: "1.35.3"
version: "2.1.0"
flutter_blue_plus_android:
dependency: transitive
description:
name: flutter_blue_plus_android
sha256: c1d83f84b514e46345a8a58599c428f20b11e78379521e0d3b0611c7b7cbf2c1
sha256: "5010b0960cce533a8fa71401573f044362c3e2e111dc6eb4898c92e85f85f50c"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "8.1.0"
flutter_blue_plus_darwin:
dependency: transitive
description:
name: flutter_blue_plus_darwin
sha256: "8d0a0f11f83b13dda173396b7e4028b4e8656bc8dbbc82c26a7e49aafc62644b"
sha256: d160a8128e3a016fa58dd65ab6dac05cbc73e0fa799a1f24211d041641ed63ba
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "8.1.0"
flutter_blue_plus_linux:
dependency: transitive
description:
name: flutter_blue_plus_linux
sha256: "1d367ed378b2bd6c3b9685fda7044e1d2f169884802b7dec7badb31a99a72660"
sha256: f5b02244d89465ba82c8c512686c66362fbb01f52fa03d645ed353ebf3883242
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "8.1.0"
flutter_blue_plus_platform_interface:
dependency: transitive
description:
name: flutter_blue_plus_platform_interface
sha256: "114f8e85a03a28a48d707a4df6cc9218e1f2005cf260c5e815e5585a00da5778"
sha256: "6e0fc04b77491dbfdbcd46c1a021b12f2f5fc5d6e01777f93a38a8431989b7f0"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "8.1.0"
flutter_blue_plus_web:
dependency: transitive
description:
name: flutter_blue_plus_web
sha256: db70cdc41bc743763dc0d47e8c7c10f3923cbbe71b33d9dc21deea482affeb4d
sha256: "376aad9595ee389c7cd56e0c373e78abcaa790c821ece9cb81f0969ec94c5bca"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "8.1.0"
flutter_blue_plus_winrt:
dependency: transitive
description:
name: flutter_blue_plus_winrt
sha256: "34be2d8e23d5881b46accebb0e71025f7d52869d72ea98b5082c20764e06aa80"
url: "https://pub.dev"
source: hosted
version: "0.0.16"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
@ -350,6 +443,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_reactive_ble:
dependency: "direct main"
description:
name: flutter_reactive_ble
sha256: "3ca8430bc7a6cabe5529aab4afaa2e3cb285941f7f7ab7472604074e347c1302"
url: "https://pub.dev"
source: hosted
version: "5.4.0"
flutter_riverpod:
dependency: "direct main"
description:
@ -358,6 +467,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_rust_bridge:
dependency: "direct main"
description:
name: flutter_rust_bridge
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
flutter_test:
dependency: "direct dev"
description: flutter
@ -368,6 +485,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fluttertoast:
dependency: transitive
description:
name: fluttertoast
sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
freezed:
dependency: "direct dev"
description:
@ -392,6 +517,19 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
functional_data:
dependency: transitive
description:
name: functional_data
sha256: "76d17dc707c40e552014f5a49c0afcc3f1e3f05e800cd6b7872940bfe41a5039"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
glob:
dependency: transitive
description:
@ -416,6 +554,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hex:
dependency: transitive
description:
name: hex
sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
hotreloader:
dependency: transitive
description:
@ -428,10 +574,10 @@ packages:
dependency: transitive
description:
name: http
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
@ -448,6 +594,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
io:
dependency: transitive
description:
@ -484,26 +635,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@ -524,26 +675,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@ -552,6 +703,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nb_utils:
dependency: "direct main"
description:
name: nb_utils
sha256: "2cfecbe67bc09ab31069cfe68e4eb232bc71f0670c98a91d0e73e7741a4798ed"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
package_config:
dependency: transitive
description:
@ -564,10 +731,34 @@ packages:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev"
source: hosted
version: "2.2.16"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
@ -624,6 +815,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
pub_semver:
dependency: transitive
description:
@ -640,6 +847,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
reactive_ble_mobile:
dependency: transitive
description:
name: reactive_ble_mobile
sha256: "78af92cfb184770a277a8bdaa76b396a28dbada8931f3d8d0ff552414681f5db"
url: "https://pub.dev"
source: hosted
version: "5.4.0"
reactive_ble_platform_interface:
dependency: transitive
description:
name: reactive_ble_platform_interface
sha256: d0e6f86c7ee3865b74d8a7b6deb1fb8320b24176dfd9a3e475f84b7117eb42c7
url: "https://pub.dev"
source: hosted
version: "5.4.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
riverpod:
dependency: transitive
description:
@ -688,8 +919,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
rust_lib_abawo_bt_app:
dependency: "direct main"
description:
path: rust_builder
relative: true
source: path
version: "0.0.1"
rxdart:
dependency: transitive
dependency: "direct main"
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
@ -700,10 +938,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: "846849e3e9b68f3ef4b60c60cf4b3e02e9321bc7f4d8c4692cf87ffa82fc8a3a"
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.2"
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
@ -805,14 +1043,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "310af39c40dd0bb2058538333c9d9840a2725ae0b9f77e4fd09ad6696aa8f66e"
url: "https://pub.dev"
source: hosted
version: "2.7.5"
sqlite3_flutter_libs:
dependency: transitive
description:
name: sqlite3_flutter_libs
sha256: "1a96b59227828d9eb1463191d684b37a27d66ee5ed7597fcf42eee6452c88a14"
url: "https://pub.dev"
source: hosted
version: "0.5.32"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "27dd0a9f0c02e22ac0eb42a23df9ea079ce69b52bb4a3b478d64e0ef34a263ee"
url: "https://pub.dev"
source: hosted
version: "0.41.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
state_notifier:
dependency: transitive
description:
@ -825,10 +1087,10 @@ packages:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
stream_transform:
dependency: transitive
description:
@ -845,6 +1107,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
@ -857,10 +1127,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.9"
timing:
dependency: transitive
description:
@ -889,10 +1159,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
@ -933,6 +1203,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32:
dependency: transitive
description:
name: win32
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
url: "https://pub.dev"
source: hosted
version: "5.13.0"
xdg_directories:
dependency: transitive
description:
@ -958,5 +1244,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.6.1 <4.0.0"
flutter: ">=3.27.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"

View File

@ -39,11 +39,22 @@ dependencies:
go_router: ^14.8.1
freezed_annotation: ^3.0.0
json_annotation: ^4.9.0
flutter_blue_plus: ^1.35.3
flutter_blue_plus: ^2.1.0
rust: ^3.1.0
anyhow: ^3.0.1
logging: ^1.3.0
shared_preferences: ^2.5.2
drift: ^2.26.0
drift_flutter: ^0.2.4
path_provider: ^2.1.5
rxdart: ^0.28.0
rust_lib_abawo_bt_app:
path: rust_builder
flutter_rust_bridge: 2.11.1
flutter_reactive_ble: ^5.4.0
nb_utils: ^7.2.0
cbor: ^6.3.3
file_picker: ^8.1.7
dev_dependencies:
flutter_test:
@ -61,6 +72,9 @@ dev_dependencies:
riverpod_lint: ^2.6.5
freezed: ^3.0.4
json_serializable: ^6.9.4
drift_dev: ^2.26.0
integration_test:
sdk: flutter
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -77,6 +91,8 @@ flutter:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
assets:
- assets/images/shifter-wireframe.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images

1
rust/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

729
rust/Cargo.lock generated Normal file
View File

@ -0,0 +1,729 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "addr2line"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
dependencies = [
"gimli",
]
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
dependencies = [
"memchr",
]
[[package]]
name = "allo-isolate"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550"
dependencies = [
"anyhow",
"atomic",
"backtrace",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "anyhow"
version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]]
name = "atomic"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "backtrace"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
dependencies = [
"addr2line",
"cc",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
]
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "build-target"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b"
[[package]]
name = "bumpalo"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
[[package]]
name = "bytemuck"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
dependencies = [
"libc",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "dart-sys"
version = "4.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895"
dependencies = [
"cc",
]
[[package]]
name = "dashmap"
version = "5.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "delegate-attr"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "flutter_rust_bridge"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde126295b2acc5f0a712e265e91b6fdc0ed38767496483e592ae7134db83725"
dependencies = [
"allo-isolate",
"android_logger",
"anyhow",
"build-target",
"bytemuck",
"byteorder",
"console_error_panic_hook",
"dart-sys",
"delegate-attr",
"flutter_rust_bridge_macros",
"futures",
"js-sys",
"lazy_static",
"log",
"oslog",
"portable-atomic",
"threadpool",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "flutter_rust_bridge_macros"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5f0420326b13675321b194928bb7830043b68cf8b810e1c651285c747abb080"
dependencies = [
"hex",
"md-5",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c"
[[package]]
name = "futures-executor"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa"
[[package]]
name = "futures-macro"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817"
[[package]]
name = "futures-task"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2"
[[package]]
name = "futures-util"
version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gimli"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hermit-abi"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
dependencies = [
"adler",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "oslog"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969"
dependencies = [
"cc",
"dashmap",
"log",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "portable-atomic"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[package]]
name = "proc-macro2"
version = "1.0.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "rust_lib_abawo_bt_app"
version = "0.1.0"
dependencies = [
"flutter_rust_bridge",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "threadpool"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
dependencies = [
"num_cpus",
]
[[package]]
name = "tokio"
version = "1.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9"
dependencies = [
"backtrace",
"num_cpus",
"pin-project-lite",
]
[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0"
dependencies = [
"cfg-if",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
[[package]]
name = "web-sys"
version = "0.3.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50c24a44ec86bb68fbecd1b3efed7e85ea5621b39b35ef2766b66cd984f8010f"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

13
rust/Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "rust_lib_abawo_bt_app"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
flutter_rust_bridge = "=2.11.1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }

1
rust/src/api/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod simple;

12
rust/src/api/simple.rs Normal file
View File

@ -0,0 +1,12 @@
#[flutter_rust_bridge::frb(sync)] // Synchronous mode for simplicity of the demo
pub fn greet(name: String) -> String {
format!("Hello, {name}!")
}
#[flutter_rust_bridge::frb(init)]
pub fn init_app() {
// Default utilities - feel free to customize
flutter_rust_bridge::setup_default_user_utils();
}

276
rust/src/frb_generated.rs Normal file
View File

@ -0,0 +1,276 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
#![allow(
non_camel_case_types,
unused,
non_snake_case,
clippy::needless_return,
clippy::redundant_closure_call,
clippy::redundant_closure,
clippy::useless_conversion,
clippy::unit_arg,
clippy::unused_unit,
clippy::double_parens,
clippy::let_and_return,
clippy::too_many_arguments,
clippy::match_single_binding,
clippy::clone_on_copy,
clippy::let_unit_value,
clippy::deref_addrof,
clippy::explicit_auto_deref,
clippy::borrow_deref_ref,
clippy::needless_borrow
)]
// Section: imports
use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt};
use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable};
use flutter_rust_bridge::{Handler, IntoIntoDart};
// Section: boilerplate
flutter_rust_bridge::frb_generated_boilerplate!(
default_stream_sink_codec = SseCodec,
default_rust_opaque = RustOpaqueMoi,
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1918914929;
// Section: executor
flutter_rust_bridge::frb_generated_default_handler!();
// Section: wire_funcs
fn wire__crate__api__simple__greet_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "greet",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_name = <String>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok(crate::api::simple::greet(api_name))?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__simple__init_app_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "init_app",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| {
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::simple::init_app();
})?;
Ok(output_ok)
})())
}
},
)
}
// Section: dart2rust
impl SseDecode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut inner = <Vec<u8>>::sse_decode(deserializer);
return String::from_utf8(inner).unwrap();
}
}
impl SseDecode for Vec<u8> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = vec![];
for idx_ in 0..len_ {
ans_.push(<u8>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for u8 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
deserializer.cursor.read_u8().unwrap()
}
}
impl SseDecode for () {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {}
}
impl SseDecode for i32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
deserializer.cursor.read_i32::<NativeEndian>().unwrap()
}
}
impl SseDecode for bool {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
deserializer.cursor.read_u8().unwrap() != 0
}
}
fn pde_ffi_dispatcher_primary_impl(
func_id: i32,
port: flutter_rust_bridge::for_generated::MessagePort,
ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len: i32,
data_len: i32,
) {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
2 => wire__crate__api__simple__init_app_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
fn pde_ffi_dispatcher_sync_impl(
func_id: i32,
ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len: i32,
data_len: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
1 => wire__crate__api__simple__greet_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
// Section: rust2dart
impl SseEncode for String {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<Vec<u8>>::sse_encode(self.into_bytes(), serializer);
}
}
impl SseEncode for Vec<u8> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<u8>::sse_encode(item, serializer);
}
}
}
impl SseEncode for u8 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
serializer.cursor.write_u8(self).unwrap();
}
}
impl SseEncode for () {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {}
}
impl SseEncode for i32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
serializer.cursor.write_i32::<NativeEndian>(self).unwrap();
}
}
impl SseEncode for bool {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
serializer.cursor.write_u8(self as _).unwrap();
}
}
#[cfg(not(target_family = "wasm"))]
mod io {
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// Section: imports
use super::*;
use flutter_rust_bridge::for_generated::byteorder::{
NativeEndian, ReadBytesExt, WriteBytesExt,
};
use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable};
use flutter_rust_bridge::{Handler, IntoIntoDart};
// Section: boilerplate
flutter_rust_bridge::frb_generated_boilerplate_io!();
}
#[cfg(not(target_family = "wasm"))]
pub use io::*;
/// cbindgen:ignore
#[cfg(target_family = "wasm")]
mod web {
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// Section: imports
use super::*;
use flutter_rust_bridge::for_generated::byteorder::{
NativeEndian, ReadBytesExt, WriteBytesExt,
};
use flutter_rust_bridge::for_generated::wasm_bindgen;
use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*;
use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable};
use flutter_rust_bridge::{Handler, IntoIntoDart};
// Section: boilerplate
flutter_rust_bridge::frb_generated_boilerplate_web!();
}
#[cfg(target_family = "wasm")]
pub use web::*;

2
rust/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod api;
mod frb_generated;

29
rust_builder/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
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
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
build/

1
rust_builder/README.md Normal file
View File

@ -0,0 +1 @@
Please ignore this folder, which is just glue to build Rust with Flutter.

9
rust_builder/android/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

View File

@ -0,0 +1,56 @@
// The Android Gradle Plugin builds the native code with the Android NDK.
group 'com.flutter_rust_bridge.rust_lib_abawo_bt_app'
version '1.0'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
// The Android Gradle Plugin knows how to build native code with the NDK.
classpath 'com.android.tools.build:gradle:7.3.0'
}
}
rootProject.allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: 'com.android.library'
android {
if (project.android.hasProperty("namespace")) {
namespace 'com.flutter_rust_bridge.rust_lib_abawo_bt_app'
}
// Bumping the plugin compileSdkVersion requires all clients of this plugin
// to bump the version in their app.
compileSdkVersion 33
// Use the NDK version
// declared in /android/app/build.gradle file of the Flutter project.
// Replace it with a version number if this plugin requires a specfic NDK version.
// (e.g. ndkVersion "23.1.7779620")
ndkVersion android.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
minSdkVersion 19
}
}
apply from: "../cargokit/gradle/plugin.gradle"
cargokit {
manifestDir = "../../rust"
libname = "rust_lib_abawo_bt_app"
}

View File

@ -0,0 +1 @@
rootProject.name = 'rust_lib_abawo_bt_app'

View File

@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.flutter_rust_bridge.rust_lib_abawo_bt_app">
</manifest>

4
rust_builder/cargokit/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
target
.dart_tool
*.iml
!pubspec.lock

View File

@ -0,0 +1,42 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Copyright 2022 Matej Knopp
================================================================================
MIT LICENSE
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================================================
APACHE LICENSE, VERSION 2.0
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,11 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
Experimental repository to provide glue for seamlessly integrating cargo build
with flutter plugins and packages.
See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/
for a tutorial on how to use Cargokit.
Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin.

View File

@ -0,0 +1,58 @@
#!/bin/sh
set -e
BASEDIR=$(dirname "$0")
# Workaround for https://github.com/dart-lang/pub/issues/4010
BASEDIR=$(cd "$BASEDIR" ; pwd -P)
# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project
NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"`
export PATH=${NEW_PATH%?} # remove trailing :
env
# Platform name (macosx, iphoneos, iphonesimulator)
export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME
# Arctive architectures (arm64, armv7, x86_64), space separated.
export CARGOKIT_DARWIN_ARCHS=$ARCHS
# Current build configuration (Debug, Release)
export CARGOKIT_CONFIGURATION=$CONFIGURATION
# Path to directory containing Cargo.toml.
export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1
# Temporary directory for build artifacts.
export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR
# Output directory for final artifacts.
export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME
# Directory to store built tool artifacts.
export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool
# Directory inside root project. Not necessarily the top level directory of root project.
export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT
FLUTTER_EXPORT_BUILD_ENVIRONMENT=(
"$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS
"$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS
)
for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}"
do
if [[ -f "$path" ]]; then
source "$path"
fi
done
sh "$BASEDIR/run_build_tool.sh" build-pod "$@"
# Make a symlink from built framework to phony file, which will be used as input to
# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate
# attribute on custom build phase)
ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony"
ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out"

View File

@ -0,0 +1,5 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
A sample command-line application with an entrypoint in `bin/`, library code
in `lib/`, and example unit test in `test/`.

View File

@ -0,0 +1,34 @@
# This is copied from Cargokit (which is the official way to use it currently)
# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
linter:
rules:
- prefer_relative_imports
- directives_ordering
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options

View File

@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'package:build_tool/build_tool.dart' as build_tool;
void main(List<String> arguments) {
build_tool.runMain(arguments);
}

View File

@ -0,0 +1,8 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'src/build_tool.dart' as build_tool;
Future<void> runMain(List<String> args) async {
return build_tool.runMain(args);
}

View File

@ -0,0 +1,195 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'dart:isolate';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'package:version/version.dart';
import 'target.dart';
import 'util.dart';
class AndroidEnvironment {
AndroidEnvironment({
required this.sdkPath,
required this.ndkVersion,
required this.minSdkVersion,
required this.targetTempDir,
required this.target,
});
static void clangLinkerWrapper(List<String> args) {
final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG'];
if (clang == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var");
}
final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET'];
if (target == null) {
throw Exception(
"cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var");
}
runCommand(clang, [
target,
...args,
]);
}
/// Full path to Android SDK.
final String sdkPath;
/// Full version of Android NDK.
final String ndkVersion;
/// Minimum supported SDK version.
final int minSdkVersion;
/// Target directory for build artifacts.
final String targetTempDir;
/// Target being built.
final Target target;
bool ndkIsInstalled() {
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final ndkPackageXml = File(path.join(ndkPath, 'package.xml'));
return ndkPackageXml.existsSync();
}
void installNdk({
required String javaHome,
}) {
final sdkManagerExtension = Platform.isWindows ? '.bat' : '';
final sdkManager = path.join(
sdkPath,
'cmdline-tools',
'latest',
'bin',
'sdkmanager$sdkManagerExtension',
);
log.info('Installing NDK $ndkVersion');
runCommand(sdkManager, [
'--install',
'ndk;$ndkVersion',
], environment: {
'JAVA_HOME': javaHome,
});
}
Future<Map<String, String>> buildEnvironment() async {
final hostArch = Platform.isMacOS
? "darwin-x86_64"
: (Platform.isLinux ? "linux-x86_64" : "windows-x86_64");
final ndkPath = path.join(sdkPath, 'ndk', ndkVersion);
final toolchainPath = path.join(
ndkPath,
'toolchains',
'llvm',
'prebuilt',
hostArch,
'bin',
);
final minSdkVersion =
math.max(target.androidMinSdkVersion!, this.minSdkVersion);
final exe = Platform.isWindows ? '.exe' : '';
final arKey = 'AR_${target.rust}';
final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe']
.map((e) => path.join(toolchainPath, e))
.firstWhereOrNull((element) => File(element).existsSync());
if (arValue == null) {
throw Exception('Failed to find ar for $target in $toolchainPath');
}
final targetArg = '--target=${target.rust}$minSdkVersion';
final ccKey = 'CC_${target.rust}';
final ccValue = path.join(toolchainPath, 'clang$exe');
final cfFlagsKey = 'CFLAGS_${target.rust}';
final cFlagsValue = targetArg;
final cxxKey = 'CXX_${target.rust}';
final cxxValue = path.join(toolchainPath, 'clang++$exe');
final cxxFlagsKey = 'CXXFLAGS_${target.rust}';
final cxxFlagsValue = targetArg;
final linkerKey =
'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase();
final ranlibKey = 'RANLIB_${target.rust}';
final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe');
final ndkVersionParsed = Version.parse(ndkVersion);
final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS';
final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed);
final runRustTool =
Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh';
final packagePath = (await Isolate.resolvePackageUri(
Uri.parse('package:build_tool/buildtool.dart')))!
.toFilePath();
final selfPath = path.canonicalize(path.join(
packagePath,
'..',
'..',
'..',
runRustTool,
));
// Make sure that run_build_tool is working properly even initially launched directly
// through dart run.
final toolTempDir =
Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir;
return {
arKey: arValue,
ccKey: ccValue,
cfFlagsKey: cFlagsValue,
cxxKey: cxxValue,
cxxFlagsKey: cxxFlagsValue,
ranlibKey: ranlibValue,
rustFlagsKey: rustFlagsValue,
linkerKey: selfPath,
// Recognized by main() so we know when we're acting as a wrapper
'_CARGOKIT_NDK_LINK_TARGET': targetArg,
'_CARGOKIT_NDK_LINK_CLANG': ccValue,
'CARGOKIT_TOOL_TEMP_DIR': toolTempDir,
};
}
// Workaround for libgcc missing in NDK23, inspired by cargo-ndk
String _libGccWorkaround(String buildDir, Version ndkVersion) {
final workaroundDir = path.join(
buildDir,
'cargokit',
'libgcc_workaround',
'${ndkVersion.major}',
);
Directory(workaroundDir).createSync(recursive: true);
if (ndkVersion.major >= 23) {
File(path.join(workaroundDir, 'libgcc.a'))
.writeAsStringSync('INPUT(-lunwind)');
} else {
// Other way around, untested, forward libgcc.a from libunwind once Rust
// gets updated for NDK23+.
File(path.join(workaroundDir, 'libunwind.a'))
.writeAsStringSync('INPUT(-lgcc)');
}
var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? '';
if (rustFlags.isNotEmpty) {
rustFlags = '$rustFlags\x1f';
}
rustFlags = '$rustFlags-L\x1f$workaroundDir';
return rustFlags;
}
}

View File

@ -0,0 +1,266 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:http/http.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'builder.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'rustup.dart';
import 'target.dart';
class Artifact {
/// File system location of the artifact.
final String path;
/// Actual file name that the artifact should have in destination folder.
final String finalFileName;
AritifactType get type {
if (finalFileName.endsWith('.dll') ||
finalFileName.endsWith('.dll.lib') ||
finalFileName.endsWith('.pdb') ||
finalFileName.endsWith('.so') ||
finalFileName.endsWith('.dylib')) {
return AritifactType.dylib;
} else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) {
return AritifactType.staticlib;
} else {
throw Exception('Unknown artifact type for $finalFileName');
}
}
Artifact({
required this.path,
required this.finalFileName,
});
}
final _log = Logger('artifacts_provider');
class ArtifactProvider {
ArtifactProvider({
required this.environment,
required this.userOptions,
});
final BuildEnvironment environment;
final CargokitUserOptions userOptions;
Future<Map<Target, List<Artifact>>> getArtifacts(List<Target> targets) async {
final result = await _getPrecompiledArtifacts(targets);
final pendingTargets = List.of(targets);
pendingTargets.removeWhere((element) => result.containsKey(element));
if (pendingTargets.isEmpty) {
return result;
}
final rustup = Rustup();
for (final target in targets) {
final builder = RustBuilder(target: target, environment: environment);
builder.prepare(rustup);
_log.info('Building ${environment.crateInfo.packageName} for $target');
final targetDir = await builder.build();
// For local build accept both static and dynamic libraries.
final artifactNames = <String>{
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.dylib,
remote: false,
),
...getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
aritifactType: AritifactType.staticlib,
remote: false,
)
};
final artifacts = artifactNames
.map((artifactName) => Artifact(
path: path.join(targetDir, artifactName),
finalFileName: artifactName,
))
.where((element) => File(element.path).existsSync())
.toList();
result[target] = artifacts;
}
return result;
}
Future<Map<Target, List<Artifact>>> _getPrecompiledArtifacts(
List<Target> targets) async {
if (userOptions.usePrecompiledBinaries == false) {
_log.info('Precompiled binaries are disabled');
return {};
}
if (environment.crateOptions.precompiledBinaries == null) {
_log.fine('Precompiled binaries not enabled for this crate');
return {};
}
final start = Stopwatch()..start();
final crateHash = CrateHash.compute(environment.manifestDir,
tempStorage: environment.targetTempDir);
_log.fine(
'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms');
final downloadedArtifactsDir =
path.join(environment.targetTempDir, 'precompiled', crateHash);
Directory(downloadedArtifactsDir).createSync(recursive: true);
final res = <Target, List<Artifact>>{};
for (final target in targets) {
final requiredArtifacts = getArtifactNames(
target: target,
libraryName: environment.crateInfo.packageName,
remote: true,
);
final artifactsForTarget = <Artifact>[];
for (final artifact in requiredArtifacts) {
final fileName = PrecompileBinaries.fileName(target, artifact);
final downloadedPath = path.join(downloadedArtifactsDir, fileName);
if (!File(downloadedPath).existsSync()) {
final signatureFileName =
PrecompileBinaries.signatureFileName(target, artifact);
await _tryDownloadArtifacts(
crateHash: crateHash,
fileName: fileName,
signatureFileName: signatureFileName,
finalPath: downloadedPath,
);
}
if (File(downloadedPath).existsSync()) {
artifactsForTarget.add(Artifact(
path: downloadedPath,
finalFileName: artifact,
));
} else {
break;
}
}
// Only provide complete set of artifacts.
if (artifactsForTarget.length == requiredArtifacts.length) {
_log.fine('Found precompiled artifacts for $target');
res[target] = artifactsForTarget;
}
}
return res;
}
static Future<Response> _get(Uri url, {Map<String, String>? headers}) async {
int attempt = 0;
const maxAttempts = 10;
while (true) {
try {
return await get(url, headers: headers);
} on SocketException catch (e) {
// Try to detect reset by peer error and retry.
if (attempt++ < maxAttempts &&
(e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) {
_log.severe(
'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...');
await Future.delayed(Duration(seconds: 1));
continue;
} else {
rethrow;
}
}
}
}
Future<void> _tryDownloadArtifacts({
required String crateHash,
required String fileName,
required String signatureFileName,
required String finalPath,
}) async {
final precompiledBinaries = environment.crateOptions.precompiledBinaries!;
final prefix = precompiledBinaries.uriPrefix;
final url = Uri.parse('$prefix$crateHash/$fileName');
final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName');
_log.fine('Downloading signature from $signatureUrl');
final signature = await _get(signatureUrl);
if (signature.statusCode == 404) {
_log.warning(
'Precompiled binaries not available for crate hash $crateHash ($fileName)');
return;
}
if (signature.statusCode != 200) {
_log.severe(
'Failed to download signature $signatureUrl: status ${signature.statusCode}');
return;
}
_log.fine('Downloading binary from $url');
final res = await _get(url);
if (res.statusCode != 200) {
_log.severe('Failed to download binary $url: status ${res.statusCode}');
return;
}
if (verify(
precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) {
File(finalPath).writeAsBytesSync(res.bodyBytes);
} else {
_log.shout('Signature verification failed! Ignoring binary.');
}
}
}
enum AritifactType {
staticlib,
dylib,
}
AritifactType artifactTypeForTarget(Target target) {
if (target.darwinPlatform != null) {
return AritifactType.staticlib;
} else {
return AritifactType.dylib;
}
}
List<String> getArtifactNames({
required Target target,
required String libraryName,
required bool remote,
AritifactType? aritifactType,
}) {
aritifactType ??= artifactTypeForTarget(target);
if (target.darwinArch != null) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
} else {
return ['lib$libraryName.dylib'];
}
} else if (target.rust.contains('-windows-')) {
if (aritifactType == AritifactType.staticlib) {
return ['$libraryName.lib'];
} else {
return [
'$libraryName.dll',
'$libraryName.dll.lib',
if (!remote) '$libraryName.pdb'
];
}
} else if (target.rust.contains('-linux-')) {
if (aritifactType == AritifactType.staticlib) {
return ['lib$libraryName.a'];
} else {
return ['lib$libraryName.so'];
}
} else {
throw Exception("Unsupported target: ${target.rust}");
}
}

View File

@ -0,0 +1,40 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
class BuildCMake {
final CargokitUserOptions userOptions;
BuildCMake({required this.userOptions});
Future<void> build() async {
final targetPlatform = Environment.targetPlatform;
final target = Target.forFlutterName(Environment.targetPlatform);
if (target == null) {
throw Exception("Unknown target platform: $targetPlatform");
}
final environment = BuildEnvironment.fromEnvironment(isAndroid: false);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts([target]);
final libs = artifacts[target]!;
for (final lib in libs) {
if (lib.type == AritifactType.dylib) {
File(lib.path)
.copySync(path.join(Environment.outputDir, lib.finalFileName));
}
}
}
}

View File

@ -0,0 +1,49 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
final log = Logger('build_gradle');
class BuildGradle {
BuildGradle({required this.userOptions});
final CargokitUserOptions userOptions;
Future<void> build() async {
final targets = Environment.targetPlatforms.map((arch) {
final target = Target.forFlutterName(arch);
if (target == null) {
throw Exception(
"Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}");
}
return target;
}).toList();
final environment = BuildEnvironment.fromEnvironment(isAndroid: true);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts(targets);
for (final target in targets) {
final libs = artifacts[target]!;
final outputDir = path.join(Environment.outputDir, target.android!);
Directory(outputDir).createSync(recursive: true);
for (final lib in libs) {
if (lib.type == AritifactType.dylib) {
File(lib.path).copySync(path.join(outputDir, lib.finalFileName));
}
}
}
}
}

View File

@ -0,0 +1,89 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'environment.dart';
import 'options.dart';
import 'target.dart';
import 'util.dart';
class BuildPod {
BuildPod({required this.userOptions});
final CargokitUserOptions userOptions;
Future<void> build() async {
final targets = Environment.darwinArchs.map((arch) {
final target = Target.forDarwin(
platformName: Environment.darwinPlatformName, darwinAarch: arch);
if (target == null) {
throw Exception(
"Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}");
}
return target;
}).toList();
final environment = BuildEnvironment.fromEnvironment(isAndroid: false);
final provider =
ArtifactProvider(environment: environment, userOptions: userOptions);
final artifacts = await provider.getArtifacts(targets);
void performLipo(String targetFile, Iterable<String> sourceFiles) {
runCommand("lipo", [
'-create',
...sourceFiles,
'-output',
targetFile,
]);
}
final outputDir = Environment.outputDir;
Directory(outputDir).createSync(recursive: true);
final staticLibs = artifacts.values
.expand((element) => element)
.where((element) => element.type == AritifactType.staticlib)
.toList();
final dynamicLibs = artifacts.values
.expand((element) => element)
.where((element) => element.type == AritifactType.dylib)
.toList();
final libName = environment.crateInfo.packageName;
// If there is static lib, use it and link it with pod
if (staticLibs.isNotEmpty) {
final finalTargetFile = path.join(outputDir, "lib$libName.a");
performLipo(finalTargetFile, staticLibs.map((e) => e.path));
} else {
// Otherwise try to replace bundle dylib with our dylib
final bundlePaths = [
'$libName.framework/Versions/A/$libName',
'$libName.framework/$libName',
];
for (final bundlePath in bundlePaths) {
final targetFile = path.join(outputDir, bundlePath);
if (File(targetFile).existsSync()) {
performLipo(targetFile, dynamicLibs.map((e) => e.path));
// Replace absolute id with @rpath one so that it works properly
// when moved to Frameworks.
runCommand("install_name_tool", [
'-id',
'@rpath/$bundlePath',
targetFile,
]);
return;
}
}
throw Exception('Unable to find bundle for dynamic library');
}
}
}

View File

@ -0,0 +1,271 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:github/github.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'android_environment.dart';
import 'build_cmake.dart';
import 'build_gradle.dart';
import 'build_pod.dart';
import 'logging.dart';
import 'options.dart';
import 'precompile_binaries.dart';
import 'target.dart';
import 'util.dart';
import 'verify_binaries.dart';
final log = Logger('build_tool');
abstract class BuildCommand extends Command {
Future<void> runBuildCommand(CargokitUserOptions options);
@override
Future<void> run() async {
final options = CargokitUserOptions.load();
if (options.verboseLogging ||
Platform.environment['CARGOKIT_VERBOSE'] == '1') {
enableVerboseLogging();
}
await runBuildCommand(options);
}
}
class BuildPodCommand extends BuildCommand {
@override
final name = 'build-pod';
@override
final description = 'Build cocoa pod library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildPod(userOptions: options);
await build.build();
}
}
class BuildGradleCommand extends BuildCommand {
@override
final name = 'build-gradle';
@override
final description = 'Build android library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildGradle(userOptions: options);
await build.build();
}
}
class BuildCMakeCommand extends BuildCommand {
@override
final name = 'build-cmake';
@override
final description = 'Build CMake library';
@override
Future<void> runBuildCommand(CargokitUserOptions options) async {
final build = BuildCMake(userOptions: options);
await build.build();
}
}
class GenKeyCommand extends Command {
@override
final name = 'gen-key';
@override
final description = 'Generate key pair for signing precompiled binaries';
@override
void run() {
final kp = generateKey();
final private = HEX.encode(kp.privateKey.bytes);
final public = HEX.encode(kp.publicKey.bytes);
print("Private Key: $private");
print("Public Key: $public");
}
}
class PrecompileBinariesCommand extends Command {
PrecompileBinariesCommand() {
argParser
..addOption(
'repository',
mandatory: true,
help: 'Github repository slug in format owner/name',
)
..addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml',
)
..addMultiOption('target',
help: 'Rust target triple of artifact to build.\n'
'Can be specified multiple times or omitted in which case\n'
'all targets for current platform will be built.')
..addOption(
'android-sdk-location',
help: 'Location of Android SDK (if available)',
)
..addOption(
'android-ndk-version',
help: 'Android NDK version (if available)',
)
..addOption(
'android-min-sdk-version',
help: 'Android minimum rquired version (if available)',
)
..addOption(
'temp-dir',
help: 'Directory to store temporary build artifacts',
)
..addFlag(
"verbose",
abbr: "v",
defaultsTo: false,
help: "Enable verbose logging",
);
}
@override
final name = 'precompile-binaries';
@override
final description = 'Prebuild and upload binaries\n'
'Private key must be passed through PRIVATE_KEY environment variable. '
'Use gen_key through generate priave key.\n'
'Github token must be passed as GITHUB_TOKEN environment variable.\n';
@override
Future<void> run() async {
final verbose = argResults!['verbose'] as bool;
if (verbose) {
enableVerboseLogging();
}
final privateKeyString = Platform.environment['PRIVATE_KEY'];
if (privateKeyString == null) {
throw ArgumentError('Missing PRIVATE_KEY environment variable');
}
final githubToken = Platform.environment['GITHUB_TOKEN'];
if (githubToken == null) {
throw ArgumentError('Missing GITHUB_TOKEN environment variable');
}
final privateKey = HEX.decode(privateKeyString);
if (privateKey.length != 64) {
throw ArgumentError('Private key must be 64 bytes long');
}
final manifestDir = argResults!['manifest-dir'] as String;
if (!Directory(manifestDir).existsSync()) {
throw ArgumentError('Manifest directory does not exist: $manifestDir');
}
String? androidMinSdkVersionString =
argResults!['android-min-sdk-version'] as String?;
int? androidMinSdkVersion;
if (androidMinSdkVersionString != null) {
androidMinSdkVersion = int.tryParse(androidMinSdkVersionString);
if (androidMinSdkVersion == null) {
throw ArgumentError(
'Invalid android-min-sdk-version: $androidMinSdkVersionString');
}
}
final targetStrigns = argResults!['target'] as List<String>;
final targets = targetStrigns.map((target) {
final res = Target.forRustTriple(target);
if (res == null) {
throw ArgumentError('Invalid target: $target');
}
return res;
}).toList(growable: false);
final precompileBinaries = PrecompileBinaries(
privateKey: PrivateKey(privateKey),
githubToken: githubToken,
manifestDir: manifestDir,
repositorySlug: RepositorySlug.full(argResults!['repository'] as String),
targets: targets,
androidSdkLocation: argResults!['android-sdk-location'] as String?,
androidNdkVersion: argResults!['android-ndk-version'] as String?,
androidMinSdkVersion: androidMinSdkVersion,
tempDir: argResults!['temp-dir'] as String?,
);
await precompileBinaries.run();
}
}
class VerifyBinariesCommand extends Command {
VerifyBinariesCommand() {
argParser.addOption(
'manifest-dir',
mandatory: true,
help: 'Directory containing Cargo.toml',
);
}
@override
final name = "verify-binaries";
@override
final description = 'Verifies published binaries\n'
'Checks whether there is a binary published for each targets\n'
'and checks the signature.';
@override
Future<void> run() async {
final manifestDir = argResults!['manifest-dir'] as String;
final verifyBinaries = VerifyBinaries(
manifestDir: manifestDir,
);
await verifyBinaries.run();
}
}
Future<void> runMain(List<String> args) async {
try {
// Init logging before options are loaded
initLogging();
if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) {
return AndroidEnvironment.clangLinkerWrapper(args);
}
final runner = CommandRunner('build_tool', 'Cargokit built_tool')
..addCommand(BuildPodCommand())
..addCommand(BuildGradleCommand())
..addCommand(BuildCMakeCommand())
..addCommand(GenKeyCommand())
..addCommand(PrecompileBinariesCommand())
..addCommand(VerifyBinariesCommand());
await runner.run(args);
} on ArgumentError catch (e) {
stderr.writeln(e.toString());
exit(1);
} catch (e, s) {
log.severe(kDoubleSeparator);
log.severe('Cargokit BuildTool failed with error:');
log.severe(kSeparator);
log.severe(e);
// This tells user to install Rust, there's no need to pollute the log with
// stack trace.
if (e is! RustupNotFoundException) {
log.severe(kSeparator);
log.severe(s);
log.severe(kSeparator);
log.severe('BuildTool arguments: $args');
}
log.severe(kDoubleSeparator);
exit(1);
}
}

View File

@ -0,0 +1,198 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'android_environment.dart';
import 'cargo.dart';
import 'environment.dart';
import 'options.dart';
import 'rustup.dart';
import 'target.dart';
import 'util.dart';
final _log = Logger('builder');
enum BuildConfiguration {
debug,
release,
profile,
}
extension on BuildConfiguration {
bool get isDebug => this == BuildConfiguration.debug;
String get rustName => switch (this) {
BuildConfiguration.debug => 'debug',
BuildConfiguration.release => 'release',
BuildConfiguration.profile => 'release',
};
}
class BuildException implements Exception {
final String message;
BuildException(this.message);
@override
String toString() {
return 'BuildException: $message';
}
}
class BuildEnvironment {
final BuildConfiguration configuration;
final CargokitCrateOptions crateOptions;
final String targetTempDir;
final String manifestDir;
final CrateInfo crateInfo;
final bool isAndroid;
final String? androidSdkPath;
final String? androidNdkVersion;
final int? androidMinSdkVersion;
final String? javaHome;
BuildEnvironment({
required this.configuration,
required this.crateOptions,
required this.targetTempDir,
required this.manifestDir,
required this.crateInfo,
required this.isAndroid,
this.androidSdkPath,
this.androidNdkVersion,
this.androidMinSdkVersion,
this.javaHome,
});
static BuildConfiguration parseBuildConfiguration(String value) {
// XCode configuration adds the flavor to configuration name.
final firstSegment = value.split('-').first;
final buildConfiguration = BuildConfiguration.values.firstWhereOrNull(
(e) => e.name == firstSegment,
);
if (buildConfiguration == null) {
_log.warning('Unknown build configuraiton $value, will assume release');
return BuildConfiguration.release;
}
return buildConfiguration;
}
static BuildEnvironment fromEnvironment({
required bool isAndroid,
}) {
final buildConfiguration =
parseBuildConfiguration(Environment.configuration);
final manifestDir = Environment.manifestDir;
final crateOptions = CargokitCrateOptions.load(
manifestDir: manifestDir,
);
final crateInfo = CrateInfo.load(manifestDir);
return BuildEnvironment(
configuration: buildConfiguration,
crateOptions: crateOptions,
targetTempDir: Environment.targetTempDir,
manifestDir: manifestDir,
crateInfo: crateInfo,
isAndroid: isAndroid,
androidSdkPath: isAndroid ? Environment.sdkPath : null,
androidNdkVersion: isAndroid ? Environment.ndkVersion : null,
androidMinSdkVersion:
isAndroid ? int.parse(Environment.minSdkVersion) : null,
javaHome: isAndroid ? Environment.javaHome : null,
);
}
}
class RustBuilder {
final Target target;
final BuildEnvironment environment;
RustBuilder({
required this.target,
required this.environment,
});
void prepare(
Rustup rustup,
) {
final toolchain = _toolchain;
if (rustup.installedTargets(toolchain) == null) {
rustup.installToolchain(toolchain);
}
if (toolchain == 'nightly') {
rustup.installRustSrcForNightly();
}
if (!rustup.installedTargets(toolchain)!.contains(target.rust)) {
rustup.installTarget(target.rust, toolchain: toolchain);
}
}
CargoBuildOptions? get _buildOptions =>
environment.crateOptions.cargo[environment.configuration];
String get _toolchain => _buildOptions?.toolchain.name ?? 'stable';
/// Returns the path of directory containing build artifacts.
Future<String> build() async {
final extraArgs = _buildOptions?.flags ?? [];
final manifestPath = path.join(environment.manifestDir, 'Cargo.toml');
runCommand(
'rustup',
[
'run',
_toolchain,
'cargo',
'build',
...extraArgs,
'--manifest-path',
manifestPath,
'-p',
environment.crateInfo.packageName,
if (!environment.configuration.isDebug) '--release',
'--target',
target.rust,
'--target-dir',
environment.targetTempDir,
],
environment: await _buildEnvironment(),
);
return path.join(
environment.targetTempDir,
target.rust,
environment.configuration.rustName,
);
}
Future<Map<String, String>> _buildEnvironment() async {
if (target.android == null) {
return {};
} else {
final sdkPath = environment.androidSdkPath;
final ndkVersion = environment.androidNdkVersion;
final minSdkVersion = environment.androidMinSdkVersion;
if (sdkPath == null) {
throw BuildException('androidSdkPath is not set');
}
if (ndkVersion == null) {
throw BuildException('androidNdkVersion is not set');
}
if (minSdkVersion == null) {
throw BuildException('androidMinSdkVersion is not set');
}
final env = AndroidEnvironment(
sdkPath: sdkPath,
ndkVersion: ndkVersion,
minSdkVersion: minSdkVersion,
targetTempDir: environment.targetTempDir,
target: target,
);
if (!env.ndkIsInstalled() && environment.javaHome != null) {
env.installNdk(javaHome: environment.javaHome!);
}
return env.buildEnvironment();
}
}
}

View File

@ -0,0 +1,48 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:toml/toml.dart';
class ManifestException {
ManifestException(this.message, {required this.fileName});
final String? fileName;
final String message;
@override
String toString() {
if (fileName != null) {
return 'Failed to parse package manifest at $fileName: $message';
} else {
return 'Failed to parse package manifest: $message';
}
}
}
class CrateInfo {
CrateInfo({required this.packageName});
final String packageName;
static CrateInfo parseManifest(String manifest, {final String? fileName}) {
final toml = TomlDocument.parse(manifest);
final package = toml.toMap()['package'];
if (package == null) {
throw ManifestException('Missing package section', fileName: fileName);
}
final name = package['name'];
if (name == null) {
throw ManifestException('Missing package name', fileName: fileName);
}
return CrateInfo(packageName: name);
}
static CrateInfo load(String manifestDir) {
final manifestFile = File(path.join(manifestDir, 'Cargo.toml'));
final manifest = manifestFile.readAsStringSync();
return parseManifest(manifest, fileName: manifestFile.path);
}
}

View File

@ -0,0 +1,124 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as path;
class CrateHash {
/// Computes a hash uniquely identifying crate content. This takes into account
/// content all all .rs files inside the src directory, as well as Cargo.toml,
/// Cargo.lock, build.rs and cargokit.yaml.
///
/// If [tempStorage] is provided, computed hash is stored in a file in that directory
/// and reused on subsequent calls if the crate content hasn't changed.
static String compute(String manifestDir, {String? tempStorage}) {
return CrateHash._(
manifestDir: manifestDir,
tempStorage: tempStorage,
)._compute();
}
CrateHash._({
required this.manifestDir,
required this.tempStorage,
});
String _compute() {
final files = getFiles();
final tempStorage = this.tempStorage;
if (tempStorage != null) {
final quickHash = _computeQuickHash(files);
final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash'));
quickHashFolder.createSync(recursive: true);
final quickHashFile = File(path.join(quickHashFolder.path, quickHash));
if (quickHashFile.existsSync()) {
return quickHashFile.readAsStringSync();
}
final hash = _computeHash(files);
quickHashFile.writeAsStringSync(hash);
return hash;
} else {
return _computeHash(files);
}
}
/// Computes a quick hash based on files stat (without reading contents). This
/// is used to cache the real hash, which is slower to compute since it involves
/// reading every single file.
String _computeQuickHash(List<File> files) {
final output = AccumulatorSink<Digest>();
final input = sha256.startChunkedConversion(output);
final data = ByteData(8);
for (final file in files) {
input.add(utf8.encode(file.path));
final stat = file.statSync();
data.setUint64(0, stat.size);
input.add(data.buffer.asUint8List());
data.setUint64(0, stat.modified.millisecondsSinceEpoch);
input.add(data.buffer.asUint8List());
}
input.close();
return base64Url.encode(output.events.single.bytes);
}
String _computeHash(List<File> files) {
final output = AccumulatorSink<Digest>();
final input = sha256.startChunkedConversion(output);
void addTextFile(File file) {
// text Files are hashed by lines in case we're dealing with github checkout
// that auto-converts line endings.
final splitter = LineSplitter();
if (file.existsSync()) {
final data = file.readAsStringSync();
final lines = splitter.convert(data);
for (final line in lines) {
input.add(utf8.encode(line));
}
}
}
for (final file in files) {
addTextFile(file);
}
input.close();
final res = output.events.single;
// Truncate to 128bits.
final hash = res.bytes.sublist(0, 16);
return hex.encode(hash);
}
List<File> getFiles() {
final src = Directory(path.join(manifestDir, 'src'));
final files = src
.listSync(recursive: true, followLinks: false)
.whereType<File>()
.toList();
files.sortBy((element) => element.path);
void addFile(String relative) {
final file = File(path.join(manifestDir, relative));
if (file.existsSync()) {
files.add(file);
}
}
addFile('Cargo.toml');
addFile('Cargo.lock');
addFile('build.rs');
addFile('cargokit.yaml');
return files;
}
final String manifestDir;
final String? tempStorage;
}

View File

@ -0,0 +1,68 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
extension on String {
String resolveSymlink() => File(this).resolveSymbolicLinksSync();
}
class Environment {
/// Current build configuration (debug or release).
static String get configuration =>
_getEnv("CARGOKIT_CONFIGURATION").toLowerCase();
static bool get isDebug => configuration == 'debug';
static bool get isRelease => configuration == 'release';
/// Temporary directory where Rust build artifacts are placed.
static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR");
/// Final output directory where the build artifacts are placed.
static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR');
/// Path to the crate manifest (containing Cargo.toml).
static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR');
/// Directory inside root project. Not necessarily root folder. Symlinks are
/// not resolved on purpose.
static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR');
// Pod
/// Platform name (macosx, iphoneos, iphonesimulator).
static String get darwinPlatformName =>
_getEnv("CARGOKIT_DARWIN_PLATFORM_NAME");
/// List of architectures to build for (arm64, armv7, x86_64).
static List<String> get darwinArchs =>
_getEnv("CARGOKIT_DARWIN_ARCHS").split(' ');
// Gradle
static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION");
static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION");
static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR");
static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME");
static List<String> get targetPlatforms =>
_getEnv("CARGOKIT_TARGET_PLATFORMS").split(',');
// CMAKE
static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM");
static String _getEnv(String key) {
final res = Platform.environment[key];
if (res == null) {
throw Exception("Missing environment variable $key");
}
return res;
}
static String _getEnvPath(String key) {
final res = _getEnv(key);
if (Directory(res).existsSync()) {
return res.resolveSymlink();
} else {
return res;
}
}
}

View File

@ -0,0 +1,52 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:logging/logging.dart';
const String kSeparator = "--";
const String kDoubleSeparator = "==";
bool _lastMessageWasSeparator = false;
void _log(LogRecord rec) {
final prefix = '${rec.level.name}: ';
final out = rec.level == Level.SEVERE ? stderr : stdout;
if (rec.message == kSeparator) {
if (!_lastMessageWasSeparator) {
out.write(prefix);
out.writeln('-' * 80);
_lastMessageWasSeparator = true;
}
return;
} else if (rec.message == kDoubleSeparator) {
out.write(prefix);
out.writeln('=' * 80);
_lastMessageWasSeparator = true;
return;
}
out.write(prefix);
out.writeln(rec.message);
_lastMessageWasSeparator = false;
}
void initLogging() {
Logger.root.level = Level.INFO;
Logger.root.onRecord.listen((LogRecord rec) {
final lines = rec.message.split('\n');
for (final line in lines) {
if (line.isNotEmpty || lines.length == 1 || line != lines.last) {
_log(LogRecord(
rec.level,
line,
rec.loggerName,
));
}
}
});
}
void enableVerboseLogging() {
Logger.root.level = Level.ALL;
}

View File

@ -0,0 +1,309 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';
import 'builder.dart';
import 'environment.dart';
import 'rustup.dart';
final _log = Logger('options');
/// A class for exceptions that have source span information attached.
class SourceSpanException implements Exception {
// This is a getter so that subclasses can override it.
/// A message describing the exception.
String get message => _message;
final String _message;
// This is a getter so that subclasses can override it.
/// The span associated with this exception.
///
/// This may be `null` if the source location can't be determined.
SourceSpan? get span => _span;
final SourceSpan? _span;
SourceSpanException(this._message, this._span);
/// Returns a string representation of `this`.
///
/// [color] may either be a [String], a [bool], or `null`. If it's a string,
/// it indicates an ANSI terminal color escape that should be used to
/// highlight the span's text. If it's `true`, it indicates that the text
/// should be highlighted using the default color. If it's `false` or `null`,
/// it indicates that the text shouldn't be highlighted.
@override
String toString({Object? color}) {
if (span == null) return message;
return 'Error on ${span!.message(message, color: color)}';
}
}
enum Toolchain {
stable,
beta,
nightly,
}
class CargoBuildOptions {
final Toolchain toolchain;
final List<String> flags;
CargoBuildOptions({
required this.toolchain,
required this.flags,
});
static Toolchain _toolchainFromNode(YamlNode node) {
if (node case YamlScalar(value: String name)) {
final toolchain =
Toolchain.values.firstWhereOrNull((element) => element.name == name);
if (toolchain != null) {
return toolchain;
}
}
throw SourceSpanException(
'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.',
node.span);
}
static CargoBuildOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargo options must be a map', node.span);
}
Toolchain toolchain = Toolchain.stable;
List<String> flags = [];
for (final MapEntry(:key, :value) in node.nodes.entries) {
if (key case YamlScalar(value: 'toolchain')) {
toolchain = _toolchainFromNode(value);
} else if (key case YamlScalar(value: 'extra_flags')) {
if (value case YamlList(nodes: List<YamlNode> list)) {
if (list.every((element) {
if (element case YamlScalar(value: String _)) {
return true;
}
return false;
})) {
flags = list.map((e) => e.value as String).toList();
continue;
}
}
throw SourceSpanException(
'Extra flags must be a list of strings', value.span);
} else {
throw SourceSpanException(
'Unknown cargo option type. Must be "toolchain" or "extra_flags".',
key.span);
}
}
return CargoBuildOptions(toolchain: toolchain, flags: flags);
}
}
extension on YamlMap {
/// Map that extracts keys so that we can do map case check on them.
Map<dynamic, YamlNode> get valueMap =>
nodes.map((key, value) => MapEntry(key.value, value));
}
class PrecompiledBinaries {
final String uriPrefix;
final PublicKey publicKey;
PrecompiledBinaries({
required this.uriPrefix,
required this.publicKey,
});
static PublicKey _publicKeyFromHex(String key, SourceSpan? span) {
final bytes = HEX.decode(key);
if (bytes.length != 32) {
throw SourceSpanException(
'Invalid public key. Must be 32 bytes long.', span);
}
return PublicKey(bytes);
}
static PrecompiledBinaries parse(YamlNode node) {
if (node case YamlMap(valueMap: Map<dynamic, YamlNode> map)) {
if (map
case {
'url_prefix': YamlNode urlPrefixNode,
'public_key': YamlNode publicKeyNode,
}) {
final urlPrefix = switch (urlPrefixNode) {
YamlScalar(value: String urlPrefix) => urlPrefix,
_ => throw SourceSpanException(
'Invalid URL prefix value.', urlPrefixNode.span),
};
final publicKey = switch (publicKeyNode) {
YamlScalar(value: String publicKey) =>
_publicKeyFromHex(publicKey, publicKeyNode.span),
_ => throw SourceSpanException(
'Invalid public key value.', publicKeyNode.span),
};
return PrecompiledBinaries(
uriPrefix: urlPrefix,
publicKey: publicKey,
);
}
}
throw SourceSpanException(
'Invalid precompiled binaries value. '
'Expected Map with "url_prefix" and "public_key".',
node.span);
}
}
/// Cargokit options specified for Rust crate.
class CargokitCrateOptions {
CargokitCrateOptions({
this.cargo = const {},
this.precompiledBinaries,
});
final Map<BuildConfiguration, CargoBuildOptions> cargo;
final PrecompiledBinaries? precompiledBinaries;
static CargokitCrateOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargokit options must be a map', node.span);
}
final options = <BuildConfiguration, CargoBuildOptions>{};
PrecompiledBinaries? precompiledBinaries;
for (final entry in node.nodes.entries) {
if (entry
case MapEntry(
key: YamlScalar(value: 'cargo'),
value: YamlNode node,
)) {
if (node is! YamlMap) {
throw SourceSpanException('Cargo options must be a map', node.span);
}
for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) {
if (key case YamlScalar(value: String name)) {
final configuration = BuildConfiguration.values
.firstWhereOrNull((element) => element.name == name);
if (configuration != null) {
options[configuration] = CargoBuildOptions.parse(value);
continue;
}
}
throw SourceSpanException(
'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.',
key.span);
}
} else if (entry.key case YamlScalar(value: 'precompiled_binaries')) {
precompiledBinaries = PrecompiledBinaries.parse(entry.value);
} else {
throw SourceSpanException(
'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".',
entry.key.span);
}
}
return CargokitCrateOptions(
cargo: options,
precompiledBinaries: precompiledBinaries,
);
}
static CargokitCrateOptions load({
required String manifestDir,
}) {
final uri = Uri.file(path.join(manifestDir, "cargokit.yaml"));
final file = File.fromUri(uri);
if (file.existsSync()) {
final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri);
return parse(contents);
} else {
return CargokitCrateOptions();
}
}
}
class CargokitUserOptions {
// When Rustup is installed always build locally unless user opts into
// using precompiled binaries.
static bool defaultUsePrecompiledBinaries() {
return Rustup.executablePath() == null;
}
CargokitUserOptions({
required this.usePrecompiledBinaries,
required this.verboseLogging,
});
CargokitUserOptions._()
: usePrecompiledBinaries = defaultUsePrecompiledBinaries(),
verboseLogging = false;
static CargokitUserOptions parse(YamlNode node) {
if (node is! YamlMap) {
throw SourceSpanException('Cargokit options must be a map', node.span);
}
bool usePrecompiledBinaries = defaultUsePrecompiledBinaries();
bool verboseLogging = false;
for (final entry in node.nodes.entries) {
if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) {
if (entry.value case YamlScalar(value: bool value)) {
usePrecompiledBinaries = value;
continue;
}
throw SourceSpanException(
'Invalid value for "use_precompiled_binaries". Must be a boolean.',
entry.value.span);
} else if (entry.key case YamlScalar(value: 'verbose_logging')) {
if (entry.value case YamlScalar(value: bool value)) {
verboseLogging = value;
continue;
}
throw SourceSpanException(
'Invalid value for "verbose_logging". Must be a boolean.',
entry.value.span);
} else {
throw SourceSpanException(
'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".',
entry.key.span);
}
}
return CargokitUserOptions(
usePrecompiledBinaries: usePrecompiledBinaries,
verboseLogging: verboseLogging,
);
}
static CargokitUserOptions load() {
String fileName = "cargokit_options.yaml";
var userProjectDir = Directory(Environment.rootProjectDir);
while (userProjectDir.parent.path != userProjectDir.path) {
final configFile = File(path.join(userProjectDir.path, fileName));
if (configFile.existsSync()) {
final contents = loadYamlNode(
configFile.readAsStringSync(),
sourceUrl: configFile.uri,
);
final res = parse(contents);
if (res.verboseLogging) {
_log.info('Found user options file at ${configFile.path}');
}
return res;
}
userProjectDir = userProjectDir.parent;
}
return CargokitUserOptions._();
}
final bool usePrecompiledBinaries;
final bool verboseLogging;
}

View File

@ -0,0 +1,202 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:ed25519_edwards/ed25519_edwards.dart';
import 'package:github/github.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'artifacts_provider.dart';
import 'builder.dart';
import 'cargo.dart';
import 'crate_hash.dart';
import 'options.dart';
import 'rustup.dart';
import 'target.dart';
final _log = Logger('precompile_binaries');
class PrecompileBinaries {
PrecompileBinaries({
required this.privateKey,
required this.githubToken,
required this.repositorySlug,
required this.manifestDir,
required this.targets,
this.androidSdkLocation,
this.androidNdkVersion,
this.androidMinSdkVersion,
this.tempDir,
});
final PrivateKey privateKey;
final String githubToken;
final RepositorySlug repositorySlug;
final String manifestDir;
final List<Target> targets;
final String? androidSdkLocation;
final String? androidNdkVersion;
final int? androidMinSdkVersion;
final String? tempDir;
static String fileName(Target target, String name) {
return '${target.rust}_$name';
}
static String signatureFileName(Target target, String name) {
return '${target.rust}_$name.sig';
}
Future<void> run() async {
final crateInfo = CrateInfo.load(manifestDir);
final targets = List.of(this.targets);
if (targets.isEmpty) {
targets.addAll([
...Target.buildableTargets(),
if (androidSdkLocation != null) ...Target.androidTargets(),
]);
}
_log.info('Precompiling binaries for $targets');
final hash = CrateHash.compute(manifestDir);
_log.info('Computed crate hash: $hash');
final String tagName = 'precompiled_$hash';
final github = GitHub(auth: Authentication.withToken(githubToken));
final repo = github.repositories;
final release = await _getOrCreateRelease(
repo: repo,
tagName: tagName,
packageName: crateInfo.packageName,
hash: hash,
);
final tempDir = this.tempDir != null
? Directory(this.tempDir!)
: Directory.systemTemp.createTempSync('precompiled_');
tempDir.createSync(recursive: true);
final crateOptions = CargokitCrateOptions.load(
manifestDir: manifestDir,
);
final buildEnvironment = BuildEnvironment(
configuration: BuildConfiguration.release,
crateOptions: crateOptions,
targetTempDir: tempDir.path,
manifestDir: manifestDir,
crateInfo: crateInfo,
isAndroid: androidSdkLocation != null,
androidSdkPath: androidSdkLocation,
androidNdkVersion: androidNdkVersion,
androidMinSdkVersion: androidMinSdkVersion,
);
final rustup = Rustup();
for (final target in targets) {
final artifactNames = getArtifactNames(
target: target,
libraryName: crateInfo.packageName,
remote: true,
);
if (artifactNames.every((name) {
final fileName = PrecompileBinaries.fileName(target, name);
return (release.assets ?? []).any((e) => e.name == fileName);
})) {
_log.info("All artifacts for $target already exist - skipping");
continue;
}
_log.info('Building for $target');
final builder =
RustBuilder(target: target, environment: buildEnvironment);
builder.prepare(rustup);
final res = await builder.build();
final assets = <CreateReleaseAsset>[];
for (final name in artifactNames) {
final file = File(path.join(res, name));
if (!file.existsSync()) {
throw Exception('Missing artifact: ${file.path}');
}
final data = file.readAsBytesSync();
final create = CreateReleaseAsset(
name: PrecompileBinaries.fileName(target, name),
contentType: "application/octet-stream",
assetData: data,
);
final signature = sign(privateKey, data);
final signatureCreate = CreateReleaseAsset(
name: signatureFileName(target, name),
contentType: "application/octet-stream",
assetData: signature,
);
bool verified = verify(public(privateKey), data, signature);
if (!verified) {
throw Exception('Signature verification failed');
}
assets.add(create);
assets.add(signatureCreate);
}
_log.info('Uploading assets: ${assets.map((e) => e.name)}');
for (final asset in assets) {
// This seems to be failing on CI so do it one by one
int retryCount = 0;
while (true) {
try {
await repo.uploadReleaseAssets(release, [asset]);
break;
} on Exception catch (e) {
if (retryCount == 10) {
rethrow;
}
++retryCount;
_log.shout(
'Upload failed (attempt $retryCount, will retry): ${e.toString()}');
await Future.delayed(Duration(seconds: 2));
}
}
}
}
_log.info('Cleaning up');
tempDir.deleteSync(recursive: true);
}
Future<Release> _getOrCreateRelease({
required RepositoriesService repo,
required String tagName,
required String packageName,
required String hash,
}) async {
Release release;
try {
_log.info('Fetching release $tagName');
release = await repo.getReleaseByTagName(repositorySlug, tagName);
} on ReleaseNotFound {
_log.info('Release not found - creating release $tagName');
release = await repo.createRelease(
repositorySlug,
CreateRelease.from(
tagName: tagName,
name: 'Precompiled binaries ${hash.substring(0, 8)}',
targetCommitish: null,
isDraft: false,
isPrerelease: false,
body: 'Precompiled binaries for crate $packageName, '
'crate hash $hash.',
));
}
return release;
}
}

View File

@ -0,0 +1,136 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
import 'util.dart';
class _Toolchain {
_Toolchain(
this.name,
this.targets,
);
final String name;
final List<String> targets;
}
class Rustup {
List<String>? installedTargets(String toolchain) {
final targets = _installedTargets(toolchain);
return targets != null ? List.unmodifiable(targets) : null;
}
void installToolchain(String toolchain) {
log.info("Installing Rust toolchain: $toolchain");
runCommand("rustup", ['toolchain', 'install', toolchain]);
_installedToolchains
.add(_Toolchain(toolchain, _getInstalledTargets(toolchain)));
}
void installTarget(
String target, {
required String toolchain,
}) {
log.info("Installing Rust target: $target");
runCommand("rustup", [
'target',
'add',
'--toolchain',
toolchain,
target,
]);
_installedTargets(toolchain)?.add(target);
}
final List<_Toolchain> _installedToolchains;
Rustup() : _installedToolchains = _getInstalledToolchains();
List<String>? _installedTargets(String toolchain) => _installedToolchains
.firstWhereOrNull(
(e) => e.name == toolchain || e.name.startsWith('$toolchain-'))
?.targets;
static List<_Toolchain> _getInstalledToolchains() {
String extractToolchainName(String line) {
// ignore (default) after toolchain name
final parts = line.split(' ');
return parts[0];
}
final res = runCommand("rustup", ['toolchain', 'list']);
// To list all non-custom toolchains, we need to filter out lines that
// don't start with "stable", "beta", or "nightly".
Pattern nonCustom = RegExp(r"^(stable|beta|nightly)");
final lines = res.stdout
.toString()
.split('\n')
.where((e) => e.isNotEmpty && e.startsWith(nonCustom))
.map(extractToolchainName)
.toList(growable: true);
return lines
.map(
(name) => _Toolchain(
name,
_getInstalledTargets(name),
),
)
.toList(growable: true);
}
static List<String> _getInstalledTargets(String toolchain) {
final res = runCommand("rustup", [
'target',
'list',
'--toolchain',
toolchain,
'--installed',
]);
final lines = res.stdout
.toString()
.split('\n')
.where((e) => e.isNotEmpty)
.toList(growable: true);
return lines;
}
bool _didInstallRustSrcForNightly = false;
void installRustSrcForNightly() {
if (_didInstallRustSrcForNightly) {
return;
}
// Useful for -Z build-std
runCommand(
"rustup",
['component', 'add', 'rust-src', '--toolchain', 'nightly'],
);
_didInstallRustSrcForNightly = true;
}
static String? executablePath() {
final envPath = Platform.environment['PATH'];
final envPathSeparator = Platform.isWindows ? ';' : ':';
final home = Platform.isWindows
? Platform.environment['USERPROFILE']
: Platform.environment['HOME'];
final paths = [
if (home != null) path.join(home, '.cargo', 'bin'),
if (envPath != null) ...envPath.split(envPathSeparator),
];
for (final p in paths) {
final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup';
final rustupPath = path.join(p, rustup);
if (File(rustupPath).existsSync()) {
return rustupPath;
}
}
return null;
}
}

View File

@ -0,0 +1,140 @@
/// This is copied from Cargokit (which is the official way to use it currently)
/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin
import 'dart:io';
import 'package:collection/collection.dart';
import 'util.dart';
class Target {
Target({
required this.rust,
this.flutter,
this.android,
this.androidMinSdkVersion,
this.darwinPlatform,
this.darwinArch,
});
static final all = [
Target(
rust: 'armv7-linux-androideabi',
flutter: 'android-arm',
android: 'armeabi-v7a',
androidMinSdkVersion: 16,
),
Target(
rust: 'aarch64-linux-android',
flutter: 'android-arm64',
android: 'arm64-v8a',
androidMinSdkVersion: 21,
),
Target(
rust: 'i686-linux-android',
flutter: 'android-x86',
android: 'x86',
androidMinSdkVersion: 16,
),
Target(
rust: 'x86_64-linux-android',
flutter: 'android-x64',
android: 'x86_64',
androidMinSdkVersion: 21,
),
Target(
rust: 'x86_64-pc-windows-msvc',
flutter: 'windows-x64',
),
Target(
rust: 'x86_64-unknown-linux-gnu',
flutter: 'linux-x64',
),
Target(
rust: 'aarch64-unknown-linux-gnu',
flutter: 'linux-arm64',
),
Target(
rust: 'x86_64-apple-darwin',
darwinPlatform: 'macosx',
darwinArch: 'x86_64',
),
Target(
rust: 'aarch64-apple-darwin',
darwinPlatform: 'macosx',
darwinArch: 'arm64',
),
Target(
rust: 'aarch64-apple-ios',
darwinPlatform: 'iphoneos',
darwinArch: 'arm64',
),
Target(
rust: 'aarch64-apple-ios-sim',
darwinPlatform: 'iphonesimulator',
darwinArch: 'arm64',
),
Target(
rust: 'x86_64-apple-ios',
darwinPlatform: 'iphonesimulator',
darwinArch: 'x86_64',
),
];
static Target? forFlutterName(String flutterName) {
return all.firstWhereOrNull((element) => element.flutter == flutterName);
}
static Target? forDarwin({
required String platformName,
required String darwinAarch,
}) {
return all.firstWhereOrNull((element) => //
element.darwinPlatform == platformName &&
element.darwinArch == darwinAarch);
}
static Target? forRustTriple(String triple) {
return all.firstWhereOrNull((element) => element.rust == triple);
}
static List<Target> androidTargets() {
return all
.where((element) => element.android != null)
.toList(growable: false);
}
/// Returns buildable targets on current host platform ignoring Android targets.
static List<Target> buildableTargets() {
if (Platform.isLinux) {
// Right now we don't support cross-compiling on Linux. So we just return
// the host target.
final arch = runCommand('arch', []).stdout as String;
if (arch.trim() == 'aarch64') {
return [Target.forRustTriple('aarch64-unknown-linux-gnu')!];
} else {
return [Target.forRustTriple('x86_64-unknown-linux-gnu')!];
}
}
return all.where((target) {
if (Platform.isWindows) {
return target.rust.contains('-windows-');
} else if (Platform.isMacOS) {
return target.darwinPlatform != null;
}
return false;
}).toList(growable: false);
}
@override
String toString() {
return rust;
}
final String? flutter;
final String rust;
final String? android;
final int? androidMinSdkVersion;
final String? darwinPlatform;
final String? darwinArch;
}

Some files were not shown because too many files have changed in this diff Show More