Compare commits

..

282 Commits

Author SHA1 Message Date
Janez T
f7b6143a57 chore: bump app version and update Android/iOS build tooling
- Bump version to 2026.0613.2+57
- Gradle wrapper 8.12 -> 8.14; Flutter migrator flags (builtInKotlin/newDsl)
  and relax kotlin.jvm.target.validation to warning for third-party plugins
- Refresh iOS Xcode project, scheme, Info.plist, Podfile.lock and SwiftPM
  Package.resolved

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:19:20 +02:00
Janez T
3c08e150ef feat: align fast-GPS beacons with MeshUI firmware (16-byte format + cadence parity)
Switch the fast-GPS beacon to the firmware's canonical 16-byte wire format
(magic, key6, lat/lon microdegrees, speed km/h) — no on-wire timestamp; the
receiver stamps its own RX time. Drops compatibility with the older 19-byte app
format.

Mirror the firmware's adaptive send cadence in the app-fallback path
(LocationTrackingService): anchor + dwell motion hysteresis, EMA ground speed,
9-min stationary keepalive, fix-quality (accuracy) gate, and a post-RX hold-off
to avoid channel collisions. Parked nodes now beacon the stable anchor instead
of GPS wander.

Add the fast_gps_region config: region-scope picker in settings backed by the
firmware's fast_gps_region / fast_gps_regions custom vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:17:02 +02:00
Janez T
3283d4ca20 docs: Add Windows BLE pairing instructions to README
Document pairing the MeshCore radio via the Windows 11 system Bluetooth
menu (with PIN) before connecting in-app, per user report.

Fixes #41

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:41:35 +02:00
Janez T
bdbceece75 fix: Normalize raw transport path for firmware v1.15 photo/audio transfer
CMD_SEND_RAW_DATA (0x19) was passed the encoded MeshCore route descriptor
(e.g. 0x41 for a 1-hop 2-byte-hash route), but the v1.15 companion firmware
raw-data handler treats the path byte as a legacy literal hop count, not an
encoded descriptor. It misread 0x41 as "65 path bytes" and rejected the
command (ERROR: Unsupported command), so media fetch always failed while
normal messaging worked.

- Add ContactRouteCodec.toLegacyRawPath() converting encoded descriptor +
  path into legacy format (literal hop count + first byte of each hop hash).
- Apply it in ConnectionProvider.sendRawVoicePacket, the single chokepoint
  all raw media (voice/image fragments, swarm, route probes) flows through.
- Pace served fragments 350ms apart to avoid firmware "ERROR: Table full"
  when bursting many raw packets.
- Tests for the descriptor conversion.

Fixes #43

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:38:05 +02:00
Janez T
365bd76cbc feat: Add Mapy.cz Outdoor (OSM + LIDAR) base map layer
- New MapLayerType.mapyOutdoor with Referer header support
  (tile server rejects requests without mapy.com Referer)
- Headered layers get a dedicated NetworkTileProvider sharing
  the offline tile cache
- Offline region downloads pass layer headers through
  TileDownloadService

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:28:56 +02:00
Janez T
ba27843166 feat: UX polish, localization, advert relocation, and map filter fixes
- i18n: translate connection screen + chat tab keys into 13 locales;
  add "Send my contact" (advert) strings in all locales
- feat: move self-advert from home header into composer "+" action menu
  (new lib/utils/advert_helper.dart, always shows Flood/Direct sheet)
- fix: hide-repeaters map toggle was bypassed in simple mode
- fix: simple mode map now shows only favourite chat contacts
- fix: wrap ListTiles in Material (contact_tile secondary actions,
  inferred contact group card) to satisfy Flutter ink assertions
- device-authoritative contact/channel sync and UX review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:01:10 +02:00
Janez T
3e1113035d Merge branch 'chore/app-store-screenshots' 2026-05-27 10:53:43 +02:00
Janez T
89359c5e34 Merge remote-tracking branch 'origin/main' 2026-05-27 10:53:31 +02:00
Janez T
0406b59d3b chore: Add App Store screenshots
Refs:
2026-05-27 10:45:51 +02:00
Janez Troha
c176ed0775 Update iOS TestFlight link in README 2026-05-22 12:46:29 +02:00
Janez T
6062f91c2d Throttle message notifications during sync 2026-05-10 17:48:13 +02:00
Janez T
e1400cf1ec feat: Modernize map selection and tile cache 2026-04-26 08:41:50 +02:00
Janez Troha
b040a8bd3a Merge pull request #39 from dz0ny/feat/reported-issues-ci
Feat/reported issues ci
2026-04-26 08:40:12 +02:00
Janez T
c847bd6465 feat: Add UTM coordinates #38
ref:
2026-04-26 08:30:28 +02:00
Janez T
132a576785 fix: Split trail recording #32
ref:
2026-04-26 08:30:28 +02:00
Janez T
9b8b1bf473 fix: Add background service #37
ref: #31
2026-04-26 08:30:28 +02:00
Janez T
0008eff1b8 fix: Stabilize reported tests #31
ref: #32 #37
2026-04-26 08:30:28 +02:00
Janez T
2fe125d6ba chore: Add Play release cycle #31
ref:
2026-04-26 08:30:28 +02:00
Janez T
5834454087 feat: Add UTM coordinates #38
ref:
2026-04-26 08:28:41 +02:00
Janez T
73e78691ed fix: Split trail recording #32
ref:
2026-04-26 08:24:11 +02:00
Janez T
d067095345 fix: Add background service #37
ref: #31
2026-04-26 08:24:09 +02:00
Janez T
0b463f6454 fix: Stabilize reported tests #31
ref: #32 #37
2026-04-26 08:24:07 +02:00
Janez T
a472b0c05c chore: Add Play release cycle #31
ref:
2026-04-26 08:24:04 +02:00
Janez T
4181b33da1 Fix echo metadata and bump build number #123 2026-04-26 08:02:53 +02:00
Janez T
f9dfdc2340 fix: Add manual TCP connect flow 2026-04-18 08:59:36 +02:00
Janez T
b333b3737e feat: Open per-metric sensor history 2026-04-12 21:07:53 +02:00
Janez T
7245d76e17 chore: Bump iOS build number 2026-04-08 18:45:52 +02:00
Janez T
7a76ba97f8 fix: Reset test viewport state 2026-04-08 18:43:37 +02:00
Janez T
d00198c829 Fix radio preset resync 2026-04-08 18:43:12 +02:00
Janez T
e6cd59e96f fix: Remove GPS interval option 2026-04-08 18:41:58 +02:00
Janez T
b863cda139 fix: Prefer cached map tiles offline ref: 2026-04-08 09:46:31 +02:00
Janez T
4bd7f36b98 chore: Bump iOS build version 2026-04-07 14:21:39 +02:00
Janez T
44a668df93 fix: Reset fallback sharing state 2026-04-07 14:04:45 +02:00
Janez T
e5514b4ef9 Fix fast location cadence ref: 2026-04-07 10:35:31 +02:00
Janez T
d92e52c5c2 fix: Dedupe replayed messages #123 2026-04-06 21:00:07 +02:00
Janez T
daa88fb471 chore: Update fastlane report 2026-04-06 20:55:27 +02:00
Janez T
c182813c35 fix: Add manual TCP IP 2026-04-05 21:51:17 +02:00
Janez T
81944bfd3c feat: Refresh generated API snapshots #0 2026-04-05 21:48:19 +02:00
Janez T
43eb857663 Bump iOS build to 133 2026-04-05 20:07:19 +02:00
Janez T
faa396769e feat: Add buzzer alerts toggle 2026-04-05 20:02:54 +02:00
Janez T
1a35b790a7 feat: Add GPS diagnostics #0 2026-04-05 19:58:34 +02:00
Janez T
573921e002 fix: Clarify shared location labels and onboarding copy #123 2026-04-05 19:54:48 +02:00
Janez T
14a41b210e feat: Add manual TCP IP entry 2026-04-05 19:45:16 +02:00
Janez T
56a7fa2fc6 fix: Use location pin icon 2026-04-05 19:39:40 +02:00
Janez T
7f4b4c4560 feat: Add message location details 2026-04-05 19:33:41 +02:00
Janez T
7f7f4aa975 fix: Widen worker charts 2026-04-05 18:01:05 +02:00
Janez T
1baab06627 fix: Widen worker stats graph 2026-04-04 20:20:36 +02:00
Janez T
5320fa1a2b fix: Guard null history decode #123 2026-04-04 15:58:21 +02:00
Janez T
512b64db9f fix: Preserve custom path priority 2026-04-04 15:57:41 +02:00
Janez T
4690ceead1 chore: Remove reporter map dependencies 2026-04-04 10:01:03 +02:00
Janez T
d431a79aa8 Fix: Limit stats retention to 7 days 2026-04-03 22:42:52 +02:00
Janez T
d66bbca82c feat: Add dark theme charts ref: 2026-04-03 22:38:21 +02:00
Janez T
d2fee94678 feat: Reorder stats cards cache dashboard 2026-04-03 22:34:06 +02:00
Janez T
61a856e84c feat: Clarify stats dashboard ref: 2026-04-03 22:29:46 +02:00
Janez T
5feb07aad7 feat: Enable stats by default and enrich public dashboard 2026-04-03 22:24:09 +02:00
Janez T
59931f402a feat: Add traffic over time chart 2026-04-03 22:19:23 +02:00
Janez T
0cd563063a feat: Add Leaflet map to stats dashboard #123 2026-04-03 22:17:23 +02:00
Janez T
9244368b49 chore: Rename worker stats #0 2026-04-03 20:00:14 +02:00
Janez T
c089db5c62 feat: Add anonymous RX stats
ref:
2026-04-03 19:52:45 +02:00
Janez T
7c1bc3b84f feat: Add shadcn worker UI 2026-04-03 19:46:51 +02:00
Janez T
724f4b38e8 fix: Tighten ping sheet timing 2026-04-03 12:04:33 +02:00
Janez T
caefbdbcef fix: Clamp auto ping history 2026-04-03 12:01:39 +02:00
Janez T
79f8f8dd04 fix: Reuse Ping sheet for Sensors #123 2026-04-03 11:55:52 +02:00
Janez T
e4eb40cd34 fix: Hide Cleared Paths, Unify Sensor Actions #0 2026-04-03 11:38:57 +02:00
Janez T
7c9dfd6f63 fix: Promote confirmed DM paths 2026-04-03 10:27:06 +02:00
Janez T
3e8d4c2d11 fix: Use GitHub client #1928
ref:
2026-04-02 08:52:25 +02:00
Janez T
3a28acac2d feat: Improve live traffic packet help #0 2026-04-01 20:58:13 +02:00
Janez T
5ee03d4b2f chore: Pin Meshcore client git ref #0 2026-04-01 15:11:52 +02:00
Janez T
3dfdbd7700 fix: Preserve contacts and sensor updates 2026-04-01 13:48:40 +02:00
Janez T
043faea06a fix: Dedupe recent messages and wasm 2026-04-01 13:35:02 +02:00
Janez T
05ddb6841a fix: Use zero-hop relay ping history 2026-03-31 18:13:53 +02:00
Janez T
3392e5f9c1 feat: Add relay ping and lock message destination 2026-03-31 17:52:44 +02:00
Janez T
7df750c8d5 feat: Add send mode localization #123 2026-03-31 09:41:45 +02:00
Janez T
e09d418084 fix: Correct neighbor times ref: 2026-03-29 11:10:25 +02:00
Janez T
96aeec1301 chore: Bump iOS build number ref: 2026-03-24 19:29:20 +01:00
Janez T
6978f24440 fix: Merge self replay and refresh self telemetry 2026-03-24 19:07:44 +01:00
Janez T
ba0f1fc141 feat: Add fast GPS send logs 2026-03-23 21:00:41 +01:00
Janez T
28a9235168 fix: Tame rapid GPS updates #41 2026-03-23 20:51:48 +01:00
Janez T
3e3c9a34d5 fix: Keep received replays separate 2026-03-23 20:07:06 +01:00
Janez T
e85dccea35 feat: Add location-based DM retries 2026-03-23 15:16:59 +01:00
Janez T
019224d955 fix: Hide public channel and show signal pills #123 2026-03-23 15:04:45 +01:00
Janez T
4c048a5a50 fix: Show recipient activity previews 2026-03-23 10:44:20 +01:00
Janez T
2b42e7dd15 feat: Add compass and region support 2026-03-22 19:04:43 +01:00
Janez T
7ea6334f02 fix: Keep composer and sensor sheets closable #123 2026-03-22 18:27:57 +01:00
Janez Troha
6f0a66dc68 Merge pull request #35 from dz0ny/feat/channel-datagrams
feat: Add repeater discovery onboarding flow
2026-03-22 18:06:59 +01:00
Janez T
a26ae96acd feat: Add repeater discovery onboarding flow 2026-03-22 18:06:24 +01:00
Janez Troha
a6dc137e51 Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
2026-03-22 17:08:34 +01:00
Janez Troha
c2501442e3 Merge pull request #33 from dz0ny/feat/update-changelog-with-highlights
Update changelog with weekly highlights and PR links
2026-03-22 17:08:13 +01:00
Janez T
71eac21df9 feat: Add channel datagrams #1928
ref:
2026-03-22 16:46:58 +01:00
Janez T
c299349b6a chore: Update changelog #0 2026-03-22 16:02:17 +01:00
Janez T
ab05f13de9 fix: Remove contact trails from map #123 2026-03-22 15:48:55 +01:00
Janez T
0e61dc5bf1 fix: Revert Darwin BLE override 2026-03-22 13:57:14 +01:00
Janez T
d45924d7b4 fix: Vendor Darwin BLE override 2026-03-22 13:56:23 +01:00
Janez T
a24e699e22 fix: Refresh MeshCore lock #31
ref: dz0ny/meshcore-sar#31
2026-03-22 09:49:16 +01:00
Janez T
7486837ad0 fix: Compact recipient selector #0 2026-03-22 09:37:53 +01:00
Janez T
059125c714 fix: Restore telemetry refresh behavior
ref:
2026-03-22 09:19:33 +01:00
Janez T
9279ff2158 chore: Bump iOS build number #123 2026-03-21 20:45:09 +01:00
Janez T
dc297b0b9f fix: Tighten device settings layout 2026-03-21 20:44:29 +01:00
Janez T
2d24481aba feat: Add MET history isolation 2026-03-21 19:22:43 +01:00
Janez T
20a1df94eb fix: Support MeshCore message links #123 2026-03-21 18:36:56 +01:00
Janez T
5cc81bef65 feat: Show self sensor default 2026-03-20 19:08:24 +01:00
Janez T
6145462555 chore: Bump iOS build #123 2026-03-19 21:39:51 +01:00
Janez T
721fb7e7a2 feat: Add serial compass support #0 2026-03-19 21:38:54 +01:00
Janez T
5a58e45e13 fix: Add contacts test providers 2026-03-19 14:28:10 +01:00
Janez T
de4258c0a4 fix: Resolve DeviceInfo import clash 2026-03-19 14:18:48 +01:00
Janez T
d8cb35c595 fix: Simplify device settings styling #123 2026-03-19 14:18:29 +01:00
Janez T
8da69705ee fix: Align contact sorting labels #0 2026-03-19 14:06:05 +01:00
Janez T
e474168ba8 feat: Show self telemetry in device info sheet
Tapping signal/battery in header now also shows device's own telemetry
(temperature, humidity, pressure, GPS) when available. Triggers a self
telemetry request on sheet open. Data comes from the device contact's
telemetry field.
2026-03-19 13:52:15 +01:00
Janez T
03dba8b3ce feat: Device info sheet on signal/battery tap
Tapping the signal bars or battery indicator in the header now shows
a bottom sheet with device details: BLE signal (dBm), battery %,
voltage, storage usage, firmware version, frequency, and TX power.

Fixes #22 (partial — device telemetry display)
2026-03-19 11:07:08 +01:00
Janez T
bc9e37bd22 fix: Count RX data and speed up Flutter builds #123 2026-03-19 10:33:20 +01:00
Janez T
a32d5d2c85 fix: Show irradiance values #123 2026-03-19 09:24:53 +01:00
Janez T
e683ab933f fix: Show original poster name in room messages, not room server name
Room servers forward messages with the original author's 4-byte public
key prefix (roomPostAuthorPrefix). Was ignoring this — all room messages
showed the room server's name as sender.

Now resolves the actual poster's name from roomPostAuthorPrefix by
matching against the contacts list. Falls back to room server name if
the author isn't a known contact.

Also fixes findContactByPrefix to accept 4-byte prefixes (was rejecting
anything shorter than 6 bytes).

Fixes #22
2026-03-19 09:20:22 +01:00
Janez T
f286b0afd1 fix: Show signal chips on double tap 2026-03-18 16:59:02 +01:00
Janez T
68ec72534d fix: Sensor tiles — move channel label to bottom-right, smaller
Channel chip (ch2) was inline with the label, stealing horizontal
space and causing label truncation ('Temper...'). Now positioned as
a tiny 9px label anchored bottom-right of the tile, giving the full
width to the metric label and value.
2026-03-18 16:58:48 +01:00
Janez T
6af8b62d9b fix: Show message signal chips by default #123 2026-03-18 16:38:19 +01:00
Janez T
01fbb7de6c fix: Profile duplication on device connect
DeviceKey resolver was falling back to deviceId when publicKey wasn't
yet available during early connection. This created two different keys
(id:xxx then pk:yyy) for the same device, causing a new profile to be
created each time.

Now returns null until publicKey is available, so profile sync waits
for the stable identifier.
2026-03-18 14:45:55 +01:00
Janez T
9303d24b69 fix: Remove duplicate sensor cards for temperature, humidity, pressure
Core telemetry fields (temperature, humidity, pressure, voltage) were
shown twice: once from the ContactTelemetry top-level fields and again
from extraSensorData channel-keyed entries (e.g. temperature_2).

Now skips extraSensorData keys that are already represented by core
telemetry cards.
2026-03-18 14:37:06 +01:00
Janez T
140088fe2f fix: Improve message signal chip behavior 2026-03-18 14:29:33 +01:00
Janez T
67d8e1c906 fix: Deduplicate telemetry from 0x8B + 0x8C double delivery
When firmware sends telemetry via both pushTelemetryResponse (0x8B)
and pushBinaryResponse (0x8C), the same LPP data was parsed and
applied twice, causing duplicate sensor entries.

Added hash-based dedup: same payload for same contact within 2s is
skipped. Also added minimum length check for binary responses.
2026-03-18 14:24:38 +01:00
Janez T
29b75a3155 feat: Save profile defaults per device 2026-03-18 13:34:50 +01:00
Janez T
87cb890877 chore: Linter fixes for USB serial transport 2026-03-17 14:43:47 +01:00
Janez T
095458ece4 feat: USB serial companion support (Android)
- Add MeshCoreSerialService to meshcore_client (0x3C/0x3E framing)
- Add usb_serial package for Android USB OTG
- Add UsbSerialTransport wrapper for Android
- Add USB tab to connection dialog (scan, select, connect)
- Add ConnectionMode.usb + connectSerial/disconnectSerial
- Web Serial stub (not yet implemented)
- Fix message limits: DM 156→150, Channel 127→160 (firmware MAX_TEXT_LEN=160)
2026-03-17 13:46:50 +01:00
Janez T
5659242672 fix: Remove stale analyze warnings #123
ref:
2026-03-17 11:26:59 +01:00
Janez T
3da4ba8fb2 fix: Remove local client override #123 2026-03-17 11:24:01 +01:00
Janez T
89dc1f2862 chore: Bump version to 2026.0317.1+33 2026-03-17 11:22:23 +01:00
Janez T
251932bb47 fix: Use first hop of received path (near sender), not outbound path
Received message path: Sender → hop1 (near sender) → ... → Us
Outbound path: Us → hop1 (near us) → ... → Contact

Was using outbound path which placed contacts near OUR repeater.
Now uses first hop of the received message path — the repeater
nearest to the sender. Only triggers on received messages, not sends.
2026-03-17 11:19:37 +01:00
Janez T
b48139964a feat: RSSI trilateration from multiple repeaters
When a contact has no GPS, estimate their position from RSSI
observations through multiple repeaters:

- 1 repeater: offset by RSSI distance in deterministic direction
- 2 repeaters: weighted midpoint between circle intersections
- 3+ repeaters: weighted centroid with inverse-square weighting
  (closer repeater observations dominate)

Observations stored per-repeater (latest wins), max 8 per contact,
expire after 30 minutes. Each incoming message updates the estimate.
2026-03-17 11:15:51 +01:00
Janez T
d29b85feb8 feat: Estimate contact location from RSSI + last-hop repeater
For contacts without GPS, estimate their position by:
1. Finding the first-hop repeater from their routing path
2. Estimating distance from RSSI using log-distance path loss model
3. Placing them at a deterministic offset from the repeater

Uses LoRa outdoor path loss exponent (n=3.0) with reference RSSI
-30dBm at 1m. Distance clamped to 10m-50km range. Bearing derived
from contact public key hash for stable positioning.
2026-03-17 11:08:56 +01:00
Janez T
c01ef7a059 fix: DM retry stuck — reduce jitter, fix flood-only retry count
- maxRetryAttemptsFloodOnly: 1→0 (was causing an extra retry for
  flood contacts that the official app doesn't do)
- isLastAttempt: fix off-by-one (retryAttempt already incremented
  when called from _scheduleRetry)
- Reduce ACK timeout jitter from 1-8s to 0.5-2s to avoid long waits
  that make the UI appear stuck
2026-03-17 10:48:53 +01:00
Janez T
fa2b586ab7 i18n: Add 247 missing translation keys across all 13 languages
Replace hardcoded English strings in 32 Dart source files with l10n
references. Covers settings labels, UI actions, map/SAR features,
device config, notifications, profiles, spectrum scan, and status
messages for sl, de, hr, pl, es, fr, it, el, ru, tr, uk, zh, pt.
2026-03-17 10:36:58 +01:00
Janez T
bbd5d108cb fix: Show neighbours in bottom sheet instead of dialog 2026-03-17 10:20:28 +01:00
Janez T
e17f24a6bc feat: View Neighbours option for repeater contacts
Sends 'neighbors' text command to the repeater and displays the
response as a list with resolved names and SNR values.
2026-03-17 10:16:11 +01:00
Janez T
372224a62c feat: Share contact as meshcore:// URL via clipboard 2026-03-17 10:07:02 +01:00
Janez T
e4c8709ee2 chore: Point meshcore_client to main branch 2026-03-17 09:54:16 +01:00
Janez T
3c469b62d0 chore: Update meshcore_client ref after rebase 2026-03-17 09:53:14 +01:00
Janez T
1e97ba9831 feat: Favourites support using firmware flags bit 0
- Contact.isFavourite reads bit 0 of firmware flags field
- Toggle favourite via contact action sheet (writes to device)
- Favourites section shown first in contacts tab
- Path size options corrected to [1, 2, 3] bytes
2026-03-17 09:44:38 +01:00
Janez T
6187067017 fix: Pass selfPoint to _routeCandidates after signature change 2026-03-17 09:44:38 +01:00
Janez T
cd165d2372 feat: Add GPS toggle and node sorting 2026-03-17 09:44:38 +01:00
Janez T
59f6df4260 feat: GPS module toggle in device settings 2026-03-17 09:44:38 +01:00
Janez T
464fc5e715 feat: Per-contact path size selector in route editor 2026-03-17 09:44:38 +01:00
Janez T
a76ee4f8f6 fix: Profile coordinator null safety and data loss 2026-03-17 09:44:38 +01:00
Janez T
c884f81ca5 fix: Channel message dedup uses senderTimestamp as primary key 2026-03-16 22:17:52 +01:00
Janez T
993563f059 fix: Prevent invalid map bounds #123 2026-03-16 22:16:36 +01:00
Janez T
cfd233d38e fix: Remove relay labels, add signal bars #123 2026-03-16 22:01:41 +01:00
Janez T
9b14ff4e1a Remove relay info from contact card 2026-03-16 21:54:53 +01:00
Janez T
84cd8eec00 Refine contact refresh flow 2026-03-16 21:52:46 +01:00
Janez T
434d3df53e Fix BottomSheet ancestor lookup 2026-03-16 21:46:37 +01:00
Janez T
1fe260b95e feat: Match official app's advert import and discovery flow
- pushAdvert (0x80): import advert into firmware via cmdGetAdvertPath
  before fetching contact, so name/type/location are available
- pushNewAdvert (0x8A): fire-and-forget importReceivedAdvert to store
  contact in firmware (data already in push payload)
- controlData (0x8E) discovery: no firmware import (matches official app),
  just lookup existing contacts and show generic labels for unknown nodes
- Update meshcore_client git ref to bcbeff0 (burst sync + advert import)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:19:40 +01:00
Janez T
d3d53995a6 Add wakelock plus initialization 2026-03-16 20:38:26 +01:00
Janez T
efbddc8831 Add wakelock plus package 2026-03-16 20:37:04 +01:00
Janez T
0e3acfa4ca Ensure new profile starts empty 2026-03-16 14:04:07 +01:00
Janez T
450b8382e8 Add custom cave map mode 2026-03-16 11:30:53 +01:00
Janez T
420690d683 Enable profiles switch UI 2026-03-16 11:25:51 +01:00
Janez T
fedd7b9a2b Add custom cave map mode 2026-03-16 10:50:44 +01:00
Janez T
c82e0c3452 Update iOS project version to 118 2026-03-16 10:02:43 +01:00
Janez T
a55cfffa59 Redesign contact import screen 2026-03-15 21:02:17 +01:00
Janez T
2bb5b353bd Fix channel refresh telemetry how 2026-03-15 21:00:02 +01:00
Janez T
991421d097 Fix telemetry merge bug 2026-03-15 20:58:12 +01:00
Janez Troha
1dcd85f178 Merge pull request #26 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 20:14:31 +01:00
Janez T
5d2f61749e Update sensor parsing layout 2026-03-15 20:14:01 +01:00
Janez T
69377d90a4 Update auto-refresh and UI 2026-03-15 11:15:19 +01:00
Janez T
3e559a4f4e Enforce theme colors on switches 2026-03-15 10:56:47 +01:00
Janez T
a124e3d9f2 Improve sensor settings UI 2026-03-15 10:48:48 +01:00
Janez T
3bf4971279 Update sensor preview interactions 2026-03-15 10:43:37 +01:00
Janez T
e355f28ca9 Update sensor customization view 2026-03-15 10:40:16 +01:00
Janez Troha
7a9be3eb76 Merge pull request #25 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 10:34:36 +01:00
Janez T
650f3c66aa Show values in sensor fields 2026-03-15 10:22:53 +01:00
Janez T
df993cd176 Enhance sensor telemetry cards 2026-03-15 10:10:22 +01:00
Janez T
feb3145854 Add sensor contact preview sheet 2026-03-15 09:36:31 +01:00
Janez T
da40acf950 chore: Update Meshcore client pin
ref:
2026-03-15 09:31:18 +01:00
Janez T
66eb32bf44 Fix BLE contact add failure 2026-03-15 09:24:41 +01:00
Janez T
c2e0fa952e Fix contact add failure 2026-03-15 09:19:59 +01:00
Janez T
4557fb4b9e feat: Add Sensor telemetry alerts
ref:
2026-03-15 09:13:32 +01:00
Janez T
c05de2cf0a Update iOS project version 2026-03-14 21:42:05 +01:00
Janez Troha
c5145c2fca Merge pull request #24 from dz0ny/feat/update-changelog-highlights
Add weekly changelog highlights
2026-03-14 21:36:10 +01:00
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
62bf33c9d1 fix: Replace web unsafe hash
ref:
2026-03-14 21:34:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
Janez T
e7a481f97b Warn channels for delayed log rx 2026-03-13 20:32:07 +01:00
Janez T
9f31f50e61 Warn channels about delayed message 2026-03-13 20:14:07 +01:00
Janez T
d4237b7569 Add channel echo warning tracking 2026-03-13 20:07:39 +01:00
Janez T
b35c11c804 fix: bump meshcore client
ref:
2026-03-13 20:02:13 +01:00
Janez T
be2cf53374 Investigate Command 0x1f timeout 2026-03-13 19:58:41 +01:00
Janez T
13a729492f fix: update meshcore client ref
ref:
2026-03-13 19:56:50 +01:00
Janez T
9d8594fe05 Fix channel message dedup logic 2026-03-13 19:39:26 +01:00
Janez T
249268dd00 Prevent duplicate channel messages 2026-03-13 19:39:03 +01:00
Janez T
63aa9619e6 Clear message input on send 2026-03-13 19:38:05 +01:00
Janez T
a7ad4bb8ec Clear message input after send 2026-03-13 19:36:20 +01:00
Janez T
636e114428 Fix channel syncing and logging 2026-03-13 19:27:14 +01:00
Janez T
d5684c51c7 Fix channel sync and logging 2026-03-13 19:24:42 +01:00
Janez T
ced39b6992 Fix channel sync errors 2026-03-13 19:15:41 +01:00
Janez T
083b715039 Add clearChannelMessages to Messages 2026-03-13 18:55:05 +01:00
Janez T
36584be0ab Fix channel deletion state 2026-03-13 18:49:54 +01:00
Janez T
dc19296804 Fix channel overwrite bug 2026-03-13 18:48:17 +01:00
Janez T
40072175e3 Prevent duplicate hash channels 2026-03-13 18:46:33 +01:00
Janez T
11f3bf9f97 Fix duplicate self messages 2026-03-13 18:44:17 +01:00
Janez T
24e715d01e Bump iOS project version 2026-03-13 14:18:27 +01:00
Janez T
6e17de3866 Add profile storage and sync 2026-03-13 10:34:52 +01:00
Janez T
90877a1891 Add bidirectional refresh physics 2026-03-13 10:31:37 +01:00
Janez T
77e43843bf Update contacts tab widgets 2026-03-13 10:25:23 +01:00
Janez T
1e70f52069 Allow overriding contact name 2026-03-13 10:16:04 +01:00
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
Janez T
d113f7a881 Match sensors bg and hide counts 2026-03-08 21:03:44 +01:00
Janez T
2ab8681ac4 Add multilingual localization 2026-03-08 20:50:53 +01:00
Janez T
de5fb1e164 Translate missing localization 2026-03-08 20:46:09 +01:00
Janez T
dae7e67a8a Translate missing strings 2026-03-08 20:42:21 +01:00
Janez T
d51924021f Update German localization 2026-03-08 20:38:41 +01:00
Janez T
0f91292fd7 Update messaging options and UI 2026-03-08 20:34:38 +01:00
Janez T
b471ab0990 Add message font scale setting 2026-03-08 20:29:55 +01:00
Janez T
771cddb80d Display unread counts per channel 2026-03-08 20:22:10 +01:00
Janez T
bfd15065f0 Add message filters and counts 2026-03-08 20:15:48 +01:00
Janez T
f72db76538 Add reply shortcut for messages 2026-03-08 20:10:51 +01:00
Janez T
a96f96ca4c Add nearestrelay fallback settings 2026-03-08 20:05:24 +01:00
Janez T
9f62055eef Enable route hash settings 2026-03-08 17:00:54 +01:00
Janez T
6b2f18130c Add nearest relay fallback toggle 2026-03-08 16:56:57 +01:00
Janez T
922b40b755 Implement meshcore route rotation 2026-03-08 16:46:46 +01:00
Janez T
17d7c43745 Implement meshcore-open route reload 2026-03-08 14:26:05 +01:00
Janez T
e026c1dbbd Fix invalid simulator slice upload 2026-03-08 09:42:07 +01:00
Janez T
9ff24c1fcb Fix invalid objective_c framework 2026-03-08 09:40:10 +01:00
Janez Troha
948a22c4d2 Merge pull request #16 from MGJ520/main
Update Chinese translation
2026-03-08 09:33:25 +01:00
Janez T
05153c9ceb Clarify path_provider override 2026-03-08 09:30:30 +01:00
Janez T
cd508d5f74 Modernize device settings screen 2026-03-08 09:24:26 +01:00
Janez T
937f91e496 Preserve contact telemetry data 2026-03-08 09:23:07 +01:00
Janez T
1f826ae4c2 Set simple mode default 2026-03-08 09:19:13 +01:00
Janez T
2e15ccb3f3 Fix outdated packages 2026-03-08 09:11:30 +01:00
MGJ
aa0777d4a3 Update Chinese translation 2026-03-08 12:39:57 +08:00
Janez T
e904d53d8b Bump Flutter and iOS build versions 2026-03-07 21:13:44 +01:00
Janez T
c4aef05dcf Use flutter_map main source 2026-03-07 21:10:54 +01:00
Janez T
1c3af4f927 Upgrade flutter packages and limit 2026-03-07 20:59:01 +01:00
Janez T
fe2e08c4e4 Add faster location update channel 2026-03-07 20:38:49 +01:00
Janez T
3433eae3a6 Remove unused path_provider entry 2026-03-07 20:23:09 +01:00
336 changed files with 124845 additions and 40221 deletions

View File

@@ -12,7 +12,7 @@ permissions:
id-token: write
env:
FLUTTER_VERSION: "3.35.6"
FLUTTER_VERSION: "3.41.4"
APP_NAME: meshcore-sar
ANDROID_NDK_VERSION: "27.0.12077973"
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
@@ -40,6 +40,17 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Setup Ruby
if: github.event_name == 'release'
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
working-directory: android
- name: Cache Android NDK
uses: actions/cache@v4
@@ -52,22 +63,76 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate Android release secrets
if: github.event_name == 'release'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
- name: Create Android signing files
if: github.event_name == 'release'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/release-keystore.jks
cat <<EOF > android/key.properties
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyPassword=$ANDROID_KEY_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
storeFile=../release-keystore.jks
EOF
- name: Build APK
run: flutter build apk --release
- name: Prepare APK artifact
- name: Build Play release AAB
if: github.event_name == 'release'
run: flutter build appbundle --release
- name: Prepare Android artifacts
run: |
RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}"
TAG="${RAW_TAG//\//-}"
cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk"
if [ -f build/app/outputs/bundle/release/app-release.aab ]; then
cp build/app/outputs/bundle/release/app-release.aab "${APP_NAME}-${TAG}-play.aab"
fi
- name: Upload APK artifact
- name: Upload Android artifacts
uses: actions/upload-artifact@v4
with:
name: release-android
path: "${{ env.APP_NAME }}-*-android.apk"
path: |
${{ env.APP_NAME }}-*-android.apk
${{ env.APP_NAME }}-*-play.aab
if-no-files-found: error
- name: Upload Play release to internal testing
if: github.event_name == 'release'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
SKIP_ANDROID_BUILD: "1"
run: cd android && bundle exec fastlane android internal
- name: Upload Play release to production
if: github.event_name == 'release' && !github.event.release.prerelease
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
SKIP_ANDROID_BUILD: "1"
run: cd android && bundle exec fastlane android production
build-linux:
name: Build Linux App
runs-on: ubuntu-latest
@@ -95,6 +160,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Linux desktop
run: flutter config --enable-linux-desktop
@@ -132,6 +200,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Windows desktop
shell: pwsh
@@ -174,6 +245,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable macOS desktop
run: flutter config --enable-macos-desktop
@@ -181,6 +255,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Install macOS native build tools
run: brew install automake libtool
- name: Build macOS release
run: flutter build macos --release
@@ -216,6 +293,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies
run: flutter pub get
@@ -272,6 +352,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable web
run: flutter config --enable-web
@@ -280,7 +363,7 @@ jobs:
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/
run: flutter build web --wasm --release --base-href /meshcore-sar/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3
@@ -327,6 +410,7 @@ jobs:
body: |
Release assets are published for all supported build targets:
- Android (`.apk`)
- Google Play (`.aab`)
- Linux (`.tar.gz`)
- macOS (`.dmg`)
- Windows (`.zip`)
@@ -341,6 +425,7 @@ jobs:
4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators).
files: |
dist/*.apk
dist/*.aab
dist/*.tar.gz
dist/*.zip
dist/*.dmg

View File

@@ -10,7 +10,7 @@ on:
workflow_dispatch:
env:
FLUTTER_VERSION: '3.35.6'
FLUTTER_VERSION: '3.41.4'
jobs:
analyze:
@@ -27,6 +27,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies
run: flutter pub get

12
.gitignore vendored
View File

@@ -28,6 +28,7 @@ migrate_working_dir/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
pubspec_overrides.yaml
.pub-cache/
.pub/
/build/
@@ -72,18 +73,27 @@ GoogleService-Info.plist
**/fastlane/.env
**/fastlane/.env.*
fastlane/README.md
ios/.bundle/
ios/vendor/
# Play Store release assets & scripts
play-store/
create_feature_graphic.py
# Tool caches and local state
.devenv/
.cachebro/
.osgrep/
third_party/lpcnet_flutter/
worker/node_modules/
worker/.wrangler/
worker/.bun/
worker/.astro/
worker/dist/
# Claude Code local settings (permissions, personal config)
.claude/settings.local.json
# Dart code coverage
coverage/
lcov.info
lcov.info

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
## 2026-03-15 to 2026-03-22
- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags.
- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails.
- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior.
- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector.
- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers.
## Key PRs
- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview`
## 2026-03-08 to 2026-03-14
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
## Key PRs
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`

View File

@@ -135,6 +135,9 @@ clean: ## Clean build artifacts
flutter clean
rm -rf $(BUILD_DIR)
build-web: ## Build web with WebAssembly
flutter build web --wasm --release
# Build for all platforms
build-all: bump ## Build for Android and iOS
flutter build apk --release

View File

@@ -16,7 +16,7 @@
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
`iOS TestFlight:` https://testflight.apple.com/join/tngpPF12
## Highlights
@@ -75,6 +75,24 @@ It uses the MeshCore protocol over LoRa for long-range, infrastructure-free comm
- Incident command and coordination roles
- Operators working in weak/no cellular coverage
## Connecting Your Radio (BLE)
The app pairs with your MeshCore radio over Bluetooth Low Energy.
### Windows
On Windows 11 (tested on 25H2), pair the radio through the system Bluetooth menu **before** connecting in the app:
1. Open **Settings → Bluetooth & devices → Add device → Bluetooth**.
2. Select your MeshCore radio and complete pairing, entering the **PIN** when prompted.
3. Launch MeshCore SAR and connect — the radio now appears as a paired device.
If the radio does not show up or fails to connect in-app, remove it from the Windows Bluetooth menu and repeat the pairing step.
### iOS / Android
Connect directly from within the app — no separate system pairing step is required.
## Permissions (App Use)
- Bluetooth: mesh device communication

4
android/Gemfile Normal file
View File

@@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "fastlane"
gem "ostruct"

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
@@ -62,6 +63,12 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false"
tools:replace="android:exported" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -16,6 +16,24 @@ subprojects {
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
// flutter_avif_android 3.1.0 (latest) ships duplicate Java and Kotlin
// copies of FlutterAvifPlugin. Kotlin 2.2+ rejects the redeclaration. The
// plugin's Java source dir contains only this redundant copy, so empty it
// and let the identical Kotlin implementation compile. kotlinc reads the
// module's Java source roots for joint compilation, so clearing srcDirs
// (rather than an exclude filter, which AGP doesn't forward to kotlinc) is
// what actually removes the duplicate from its view. Registered before
// evaluationDependsOn so it also covers :app, which that call evaluates
// eagerly.
afterEvaluate {
if (project.name == "flutter_avif_android") {
extensions.findByName("android")?.let { ext ->
(ext as com.android.build.gradle.BaseExtension)
.sourceSets.getByName("main").java
.setSrcDirs(emptyList<String>())
}
}
}
project.evaluationDependsOn(":app")
}

View File

@@ -16,44 +16,72 @@
default_platform(:android)
platform :android do
def project_root
File.expand_path("../..", __dir__)
end
def play_service_account_json
ENV.fetch("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")
end
def play_build_command
"cd #{project_root} && flutter build appbundle --release"
end
def play_aab_path
File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
end
def play_package_name
"com.meshcore.sar.meshcore_sar_app"
end
def direct_apk_build_command
"cd #{project_root} && flutter build apk --release"
end
def direct_apk_path
File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
end
def skip_android_build?
ENV["SKIP_ANDROID_BUILD"] == "1"
end
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Build release APK"
lane :beta do
# Get the project root directory (two levels up from android/fastlane/)
project_root = File.expand_path("../..", __dir__)
lane :direct_apk do
sh(direct_apk_build_command)
# Build APK with Flutter
sh("cd #{project_root} && flutter build apk --release")
apk_path = File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
# Uncomment to distribute via Firebase App Distribution:
# firebase_app_distribution(
# app: "YOUR_FIREBASE_APP_ID",
# apk_path: apk_path,
# groups: "testers"
# )
UI.success("APK built at: #{apk_path}")
UI.success("APK built successfully at: #{direct_apk_path}")
end
desc "Deploy a new version to the Google Play"
lane :deploy do
# Get the project root directory (two levels up from android/fastlane/)
project_root = File.expand_path("../..", __dir__)
# Build AAB with Flutter (required for Play Store since 2021)
sh("cd #{project_root} && flutter build appbundle --release")
aab_path = File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
desc "Build Play release AAB and upload to internal testing"
lane :internal do
sh(play_build_command) unless skip_android_build?
upload_to_play_store(
aab: aab_path,
track: "internal"
aab: play_aab_path,
package_name: play_package_name,
track: "internal",
release_status: "draft",
json_key_data: play_service_account_json
)
end
desc "Build Play release AAB and upload to production"
lane :production do
sh(play_build_command) unless skip_android_build?
upload_to_play_store(
aab: play_aab_path,
package_name: play_package_name,
track: "production",
json_key_data: play_service_account_json
)
end
end

View File

@@ -1,3 +1,13 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
# Kotlin 2.2+ fails the build when a module's Java and Kotlin compile tasks
# target different JVM versions. Several Flutter plugins still pin mismatched
# targets (e.g. Java 1.8 vs Kotlin 1.8, or Java 11 vs Kotlin 17). On Android
# every class is dexed by D8/R8, so the class-file version mismatch is benign;
# downgrade the strict check to a warning so third-party plugins build.
kotlin.jvm.target.validation.mode=warning

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.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

128
docs/google-play-release.md Normal file
View File

@@ -0,0 +1,128 @@
# Google Play Release Runbook
## Purpose
This runbook prepares and ships the Android release build for `com.meshcore.sar.meshcore_sar_app`.
## Distribution Modes
- GitHub release: signed APK for direct install or manual distribution.
- Google Play: signed AAB uploaded through Fastlane to Play internal testing and, for non-prerelease GitHub releases, production.
## Required Secrets and Local Files
GitHub Actions secrets for `dz0ny/meshcore-sar`:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
Local files used to seed the secrets:
- `android/key.properties`
- release keystore referenced by `android/key.properties`
- `/Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json`
The Play Console service account must have release access for `com.meshcore.sar.meshcore_sar_app`.
## CI Release Flow
The release flow runs from `.github/workflows/build-artifacts.yml` when a GitHub release is published.
Android release steps:
1. Validate all Android signing and Google Play secrets.
2. Recreate `android/release-keystore.jks` and `android/key.properties` from GitHub secrets.
3. Build the signed APK with `flutter build apk --release`.
4. Build the signed Play AAB with `flutter build appbundle --release`.
5. Upload the APK and AAB as GitHub release assets.
6. Upload the AAB to Play internal testing with `bundle exec fastlane android internal`.
7. Upload the AAB to Play production when the GitHub release is not marked as prerelease.
The same workflow also builds Linux, macOS, Windows, iOS unsigned, and web artifacts.
## Versioning Rules
- Version source of truth: `pubspec.yaml`.
- Android `versionCode` is the number after `+`.
- `versionCode` must always increase for Play uploads.
- `make bump`, `make build`, and `make bundle` increment the version.
- Use `make build-no-bump` or `make bundle-no-bump` only when intentionally rebuilding the same version locally.
## Manual Build Commands
Run commands from the repo root.
```bash
flutter build apk --release
flutter build appbundle --release
```
Repo shortcuts:
```bash
make build
make bundle
make build-no-bump
make bundle-no-bump
```
## Fastlane Lanes
Run commands from `android/`.
```bash
bundle exec fastlane android direct_apk
bundle exec fastlane android internal
bundle exec fastlane android production
```
Set `SKIP_ANDROID_BUILD=1` when an AAB has already been built and Fastlane should only upload it.
```bash
SKIP_ANDROID_BUILD=1 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON="$(cat /Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json)" bundle exec fastlane android internal
```
## Expected Artifacts
- APK: `build/app/outputs/flutter-apk/app-release.apk`
- AAB: `build/app/outputs/bundle/release/app-release.aab`
- Release APK asset: `meshcore-sar-<tag>-android.apk`
- Release AAB asset: `meshcore-sar-<tag>-play.aab`
## Internal Testing Release Checklist
1. Confirm `pubspec.yaml` version is correct and `versionCode` increased.
2. Confirm `android/key.properties` points to the release keystore for local builds.
3. Confirm all five GitHub Actions secrets exist.
4. Publish a prerelease in GitHub to build assets and upload Play internal testing without production promotion.
5. Install from the internal testing track on a real Android device.
6. Smoke test startup, permissions, map, messaging, telemetry, and offline map behavior.
## Production Release Checklist
1. Complete internal testing validation first.
2. Confirm Play Console forms are current:
- App content
- Data safety
- App access
- Ads declaration
- Content rating
3. Confirm store assets are current:
- app icon
- feature graphic
- phone screenshots
- tablet screenshots if used
- support URL
- privacy policy URL
4. Publish a non-prerelease GitHub release.
5. Confirm the GitHub release has the APK and AAB assets.
6. Confirm Play Console has the new internal and production release entries.
## Rollback Rules
- Never reuse or lower an Android `versionCode`.
- If a Play release is bad, halt rollout in Play Console and ship a new higher-version fix.
- Keep GitHub direct APK and Play AAB artifacts separate; do not upload APKs to Play.

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/main.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/screens/home_screen.dart';
import 'package:meshcore_sar_app/services/wizard_preferences.dart';
import 'package:meshcore_sar_app/utils/sample_data_generator.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const screenshotPrefix = String.fromEnvironment('SCREENSHOT_PREFIX');
testWidgets('captures App Store screenshots', (tester) async {
SharedPreferences.setMockInitialValues({
'wizard_completed': true,
'wizard_version': 1,
});
await WizardPreferences.setWizardCompleted(true);
await tester.pumpWidget(const MeshCoreSarApp());
await _pumpUntilFound(tester, find.byType(HomeScreen));
await binding.convertFlutterSurfaceToImage();
final homeContext = tester.element(find.byType(HomeScreen));
_loadSampleData(homeContext);
await tester.pumpAndSettle(const Duration(seconds: 1));
await _takeScreenshot(binding, tester, '${screenshotPrefix}01-messages');
await _tapTab(tester, 'Contacts');
await _takeScreenshot(binding, tester, '${screenshotPrefix}02-contacts');
await _tapTab(tester, 'Map');
await tester.pumpAndSettle(const Duration(seconds: 2));
await _takeScreenshot(binding, tester, '${screenshotPrefix}03-map');
await _openOverflowItem(tester, Icons.more_vert, 'Settings');
await _takeScreenshot(binding, tester, '${screenshotPrefix}04-settings');
});
}
void _loadSampleData(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
const centerLocation = LatLng(46.0569, 14.5058);
final contacts = SampleDataGenerator.generateContacts(
centerLocation: centerLocation,
l10n: l10n,
teamMemberCount: 5,
channelCount: 2,
);
final sampleMessages = SampleDataGenerator.generateAllMessages(
centerLocation: centerLocation,
l10n: l10n,
foundPersonCount: 2,
fireCount: 1,
stagingCount: 1,
objectCount: 1,
generalChannelMessages: 8,
emergencyChannelMessages: 5,
);
context.read<ContactsProvider>().addContacts(contacts);
for (final message in sampleMessages.messages) {
context.read<MessagesProvider>().addMessage(
message,
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
);
}
}
Future<void> _pumpUntilFound(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
if (finder.evaluate().isNotEmpty) {
return;
}
}
expect(finder, findsOneWidget);
}
Future<void> _tapTab(WidgetTester tester, String label) async {
final tab = find.text(label).last;
await tester.tap(tab);
await tester.pumpAndSettle(const Duration(milliseconds: 500));
}
Future<void> _openOverflowItem(
WidgetTester tester,
IconData buttonIcon,
String label,
) async {
await tester.tap(find.byIcon(buttonIcon));
await tester.pumpAndSettle(const Duration(milliseconds: 500));
await tester.tap(find.text(label));
await tester.pumpAndSettle(const Duration(seconds: 1));
}
Future<void> _takeScreenshot(
IntegrationTestWidgetsFlutterBinding binding,
WidgetTester tester,
String name,
) async {
await tester.pumpAndSettle(const Duration(milliseconds: 500));
final bytes = await binding.takeScreenshot(name);
expect(bytes.isNotEmpty, isTrue);
}

View File

@@ -1,49 +1,47 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
CFPropertyList (3.0.9)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1172.0)
aws-sdk-core (3.233.0)
aws-eventstream (1.3.2)
aws-partitions (1.1109.0)
aws-sdk-core (3.224.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.113.0)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.199.1)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sdk-s3 (1.188.0)
aws-sdk-core (~> 3, >= 3.224.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (3.3.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
excon (0.109.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -55,25 +53,26 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
fastimage (2.4.1)
fastlane (2.229.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
@@ -81,6 +80,7 @@ GEM
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -100,6 +100,7 @@ GEM
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
@@ -114,8 +115,7 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
@@ -131,19 +131,19 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-storage_v1 (0.32.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-core (1.6.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.37.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-apis-storage_v1 (~> 0.1)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
@@ -159,42 +159,40 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.15.1)
jwt (2.10.2)
json (2.7.6)
jwt (2.10.3)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.17.0)
multi_json (1.15.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.0)
public_suffix (5.1.1)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
signet (0.18.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
jwt (>= 1.5, < 3.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -204,6 +202,7 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
@@ -226,4 +225,4 @@ DEPENDENCIES
fastlane
BUNDLED WITH
2.7.2
2.4.22

View File

@@ -1,204 +1,51 @@
PODS:
- audioplayers_darwin (0.0.1):
- Flutter
- FlutterMacOS
- codec2_flutter (0.1.0):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_avif_ios (0.0.1):
- Flutter
- flutter_background_service_ios (0.0.3):
- Flutter
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_compass (0.0.1):
- Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- image_picker_ios (0.0.1):
- Flutter
- nsd_ios (0.0.1):
- Flutter
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- Flutter
- ObjectBox (= 4.4.1)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- record_ios (1.2.0):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- vibration (3.0.0):
- Flutter
DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
- codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`)
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_compass (from `.symlinks/plugins/flutter_compass/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- ObjectBox
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
audioplayers_darwin:
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
codec2_flutter:
:path: ".symlinks/plugins/codec2_flutter/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_avif_ios:
:path: ".symlinks/plugins/flutter_avif_ios/ios"
flutter_background_service_ios:
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
flutter_compass:
:path: ".symlinks/plugins/flutter_compass/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
nsd_ios:
:path: ".symlinks/plugins/nsd_ios/ios"
objectbox_flutter_libs:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios:
:path: ".symlinks/plugins/record_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
vibration:
:path: ".symlinks/plugins/vibration/ios"
SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878

View File

@@ -16,6 +16,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -65,6 +66,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -80,6 +82,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -111,6 +114,7 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -187,6 +191,9 @@
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@@ -213,6 +220,9 @@
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -489,7 +499,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +521,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +540,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +557,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +689,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +712,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = 142;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -752,6 +762,18 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View File

@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"

View File

@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}

View File

@@ -1,5 +1,6 @@
import UIKit
import Flutter
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -8,6 +9,7 @@ import Flutter
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>100</string>
<string>142</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -102,6 +102,7 @@
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<key>UILaunchStoryboardName</key>

View File

@@ -16,17 +16,66 @@
default_platform(:ios)
platform :ios do
def project_root
File.expand_path("../..", __dir__)
end
def beta_app_info
{
"en-US" => {
description: "Field-ready SAR coordination over MeshCore with chat, voice, images, offline maps, and live team context.",
feedback_email: "hey@dz0ny.dev",
marketing_url: "https://github.com/dz0ny/meshcore-sar",
privacy_policy_url: "https://github.com/dz0ny/meshcore-sar"
}
}
end
def beta_build_info
{
"en-US" => {
whats_new: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers."
}
}
end
desc "Push a new release build to the App Store"
lane :release do
increment_build_number(xcodeproj: "Runner.xcodeproj")
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
upload_to_app_store(skip_metadata: true, skip_screenshots: true)
upload_to_testflight(
skip_waiting_for_build_processing: true,
localized_app_info: beta_app_info,
localized_build_info: beta_build_info,
changelog: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers."
)
end
desc "Push a new beta build to TestFlight"
lane :beta do
increment_build_number(xcodeproj: "Runner.xcodeproj")
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
upload_to_testflight(skip_waiting_for_build_processing: true)
upload_to_testflight(
skip_waiting_for_build_processing: true,
localized_app_info: beta_app_info,
localized_build_info: beta_build_info,
changelog: "Test MeshCore SAR field coordination flows: messaging, maps, contacts, voice, images, and SAR markers."
)
end
desc "Capture iOS screenshots for App Store Connect"
lane :screenshots do
sh("cd #{project_root} && scripts/take_screenshots.sh --ios")
end
desc "Upload App Store screenshots"
lane :store_assets do
upload_to_app_store(
skip_binary_upload: true,
skip_metadata: true,
skip_screenshots: false,
submit_for_review: false,
force: true
)
end
end

View File

@@ -31,6 +31,22 @@ Push a new release build to the App Store
Push a new beta build to TestFlight
### ios screenshots
```sh
[bundle exec] fastlane ios screenshots
```
Capture iOS screenshots for App Store Connect
### ios store_assets
```sh
[bundle exec] fastlane ios store_assets
```
Upload App Store screenshots
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -0,0 +1 @@
Copyright 2026 Janez Troha

View File

@@ -0,0 +1,14 @@
MeshCore SAR gives search and rescue teams a field-ready coordination workspace for low-connectivity operations.
Use mesh chat, live location context, SAR markers, offline-first maps, tactical drawings, voice clips, and compressed image transfer in one app. The app is built around MeshCore and BLE so teams can coordinate over LoRa links when normal infrastructure is weak or unavailable.
Key capabilities:
- Direct and group mesh messaging
- Live team contacts with signal, battery, and location context
- Offline maps with SAR markers and tactical overlays
- Voice clips optimized for low-bandwidth links
- Image transfer with compact encoding
- GPX trail import/export
- Incident, staging, and discovery workflows for field teams
MeshCore SAR is intended for search and rescue field teams, incident command, and operators who need practical coordination tools outside normal coverage.

View File

@@ -0,0 +1 @@
meshcore,sar,search,rescue,mesh,lora,offline,maps,radio,gps

View File

@@ -0,0 +1 @@
https://github.com/dz0ny/meshcore-sar

View File

@@ -0,0 +1 @@
MeshCore SAR

View File

@@ -0,0 +1 @@
https://github.com/dz0ny/meshcore-sar

View File

@@ -0,0 +1 @@
Field coordination for search and rescue teams using MeshCore, BLE, offline maps, messaging, voice, and SAR markers.

View File

@@ -0,0 +1 @@
Adds App Store screenshots and TestFlight metadata for MeshCore SAR publishing.

View File

@@ -0,0 +1 @@
SAR mesh field coordination

View File

@@ -0,0 +1 @@
https://github.com/dz0ny/meshcore-sar/issues

View File

@@ -5,17 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000199">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000247">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.436347">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.741448">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="49.579697">
<testcase classname="fastlane.lanes" name="2: build_app" time="110.585223">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="353.311697">
</testcase>

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2617
lib/l10n/app_pl.arb Normal file

File diff suppressed because it is too large Load Diff

2617
lib/l10n/app_pt.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2617
lib/l10n/app_tr.arb Normal file

File diff suppressed because it is too large Load Diff

2617
lib/l10n/app_uk.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,14 @@
import 'dart:io' show Platform;
import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:geolocator/geolocator.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'providers/connection_provider.dart';
import 'providers/contacts_provider.dart';
import 'providers/messages_provider.dart';
@@ -14,20 +18,28 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart';
import 'providers/offline_tiles_provider.dart';
import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
import 'services/tile_cache_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/profile_manager.dart';
import 'services/profile_workspace_coordinator.dart';
import 'services/profiles_feature_service.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/discovery_screen.dart';
import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart';
import 'utils/toast_logger.dart';
import 'l10n/app_localizations.dart';
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await WakelockPlus.enable();
runApp(const MeshCoreSarApp());
}
@@ -39,11 +51,16 @@ class MeshCoreSarApp extends StatefulWidget {
}
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
static const bool _isScreenshotRun = bool.fromEnvironment(
'MESHCORE_SCREENSHOTS',
);
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale;
bool _isInitialized = false;
bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp()
String? _pendingNotificationPayload;
@override
void initState() {
@@ -52,6 +69,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
}
Future<void> _initializeApp() async {
final prefs = await SharedPreferences.getInstance();
final profilesEnabled = await ProfilesFeatureService.isEnabled();
final activeProfileId =
prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default';
await ProfileStorageScope.bootstrap(
profilesEnabled: profilesEnabled,
activeProfileId: activeProfileId,
);
await _loadThemePreference();
await _loadLocalePreference();
@@ -63,18 +88,32 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap;
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
// Check if we need to request location permissions
await _checkLocationPermissions();
if (!_isScreenshotRun) {
await _checkLocationPermissions();
}
// Check for app updates (Android only) - runs in background
// Shows notification if update is available
_checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
});
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
}
/// Handle notification tap
@@ -83,10 +122,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
debugPrint('[Main] Notification tapped: $payload');
if (!_wizardCompleted) {
_pendingNotificationPayload = payload;
return;
}
// Handle update notification tap
if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl);
return;
}
if (payload.startsWith('discovery:')) {
final navigator = _navigatorKey.currentState;
if (navigator == null) {
_pendingNotificationPayload = payload;
return;
}
navigator.push(
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
);
}
// SAR and message notifications handled by their respective providers
}
@@ -159,7 +215,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available
Future<void> _checkForUpdates() async {
// Only check for updates on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)');
return;
}
@@ -236,6 +292,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
ChangeNotifierProvider(
create: (_) {
final manager = ProfileManager();
manager.initialize();
return manager;
},
),
// Voice provider (packet reassembly + playback)
ChangeNotifierProvider(
@@ -248,18 +312,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Image provider (fragment reassembly + outgoing session cache)
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything
// VoiceProvider is read via context.read inside create since it's already registered above
ChangeNotifierProxyProvider6<
ChangeNotifierProxyProvider5<
ConnectionProvider,
ContactsProvider,
MessagesProvider,
DrawingProvider,
ChannelsProvider,
TileCacheService,
AppProvider
>(
create: (context) => AppProvider(
@@ -270,7 +330,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: context.read<TileCacheService>(),
),
update:
(
@@ -280,7 +339,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messages,
drawings,
channels,
tileCache,
previous,
) =>
previous ??
@@ -292,9 +350,41 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: tileCache,
),
),
ProxyProvider6<
ProfileManager,
AppProvider,
ConnectionProvider,
ContactsProvider,
MessagesProvider,
MapProvider,
ProfileWorkspaceCoordinator
>(
update:
(
context,
profileManager,
appProvider,
connectionProvider,
contactsProvider,
messagesProvider,
mapProvider,
previous,
) => ProfileWorkspaceCoordinator(
profileManager: profileManager,
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
sensorsProvider: context.read<SensorsProvider>(),
mapProvider: mapProvider,
drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
appProvider: appProvider,
),
),
],
child: _buildMaterialApp(),
);
@@ -305,10 +395,12 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp(
navigatorKey: _navigatorKey,
scaffoldMessengerKey: rootScaffoldMessengerKey,
key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
),
title: 'MeshCore SAR',
onGenerateTitle: (context) => AppLocalizations.of(context)?.appTitle ?? 'MeshCore SAR',
debugShowCheckedModeBanner: false,
theme: AppTheme.getTheme(_themeMode, systemBrightness),
locale: _locale,
@@ -332,7 +424,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea(
left: false,
right: false,

View File

@@ -57,9 +57,7 @@ class Channel {
} else {
// Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError(
'Normal channels require a 16-byte secret',
);
throw ArgumentError('Normal channels require a 16-byte secret');
}
return Channel(
index: index,
@@ -78,6 +76,18 @@ class Channel {
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
static bool isHashChannelName(String channelName) {
return channelName.trim().startsWith('#');
}
static String pskBase64ForHashChannelName(String channelName) {
final normalized = channelName.trim();
if (!isHashChannelName(normalized)) {
throw ArgumentError('Only #channels can export derived psk_base64');
}
return base64.encode(_generateHashChannelSecret(normalized));
}
/// Create the default public channel (channel 0)
/// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() {
@@ -85,8 +95,22 @@ class Channel {
index: 0,
name: 'Public Channel',
secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
0x8b,
0x33,
0x87,
0xe9,
0xc5,
0xcd,
0xea,
0x6a,
0xc9,
0xe5,
0xed,
0xba,
0xa1,
0x15,
0xcd,
0x72,
]),
flags: null,
);
@@ -95,6 +119,18 @@ class Channel {
/// Check if this is a hash-based channel (name starts with '#')
bool get isHashChannel => name.startsWith('#');
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
String get pskBase64 => base64.encode(secret);
/// MeshCore group packets carry the first byte of SHA256(channel secret).
int get hashByte {
final digest = sha256.convert(secret);
return digest.bytes.first;
}
String get hashHex =>
hashByte.toRadixString(16).padLeft(2, '0').toUpperCase();
/// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName {
@@ -131,12 +167,7 @@ class Channel {
}
/// Create a copy with modified fields
Channel copyWith({
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
return Channel(
index: index ?? this.index,
name: name ?? this.name,

View File

@@ -0,0 +1,518 @@
import 'package:flutter/foundation.dart';
import 'channel.dart';
@immutable
class DeviceConfigProfileSection {
final int? frequencyKhz;
final int? bandwidth;
final int? spreadingFactor;
final int? codingRate;
final bool? repeatEnabled;
final int? txPower;
final int? telemetryModes;
final int? advertLocationPolicy;
final int? multiAcks;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final double? publicLatitude;
final double? publicLongitude;
const DeviceConfigProfileSection({
this.frequencyKhz,
this.bandwidth,
this.spreadingFactor,
this.codingRate,
this.repeatEnabled,
this.txPower,
this.telemetryModes,
this.advertLocationPolicy,
this.multiAcks,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.publicLatitude,
this.publicLongitude,
});
bool get isEmpty =>
frequencyKhz == null &&
bandwidth == null &&
spreadingFactor == null &&
codingRate == null &&
repeatEnabled == null &&
txPower == null &&
telemetryModes == null &&
advertLocationPolicy == null &&
multiAcks == null &&
manualAddContacts == null &&
autoAddUsers == null &&
autoAddRepeaters == null &&
autoAddRoomServers == null &&
autoAddSensors == null &&
autoAddOverwriteOldest == null &&
publicLatitude == null &&
publicLongitude == null;
Map<String, dynamic> toJson() => {
'frequencyKhz': frequencyKhz,
'bandwidth': bandwidth,
'spreadingFactor': spreadingFactor,
'codingRate': codingRate,
'repeatEnabled': repeatEnabled,
'txPower': txPower,
'telemetryModes': telemetryModes,
'advertLocationPolicy': advertLocationPolicy,
'multiAcks': multiAcks,
'manualAddContacts': manualAddContacts,
'autoAddUsers': autoAddUsers,
'autoAddRepeaters': autoAddRepeaters,
'autoAddRoomServers': autoAddRoomServers,
'autoAddSensors': autoAddSensors,
'autoAddOverwriteOldest': autoAddOverwriteOldest,
'publicLatitude': publicLatitude,
'publicLongitude': publicLongitude,
};
factory DeviceConfigProfileSection.fromJson(Map<String, dynamic> json) {
return DeviceConfigProfileSection(
frequencyKhz: json['frequencyKhz'] as int?,
bandwidth: json['bandwidth'] as int?,
spreadingFactor: json['spreadingFactor'] as int?,
codingRate: json['codingRate'] as int?,
repeatEnabled: json['repeatEnabled'] as bool?,
txPower: json['txPower'] as int?,
telemetryModes: json['telemetryModes'] as int?,
advertLocationPolicy: json['advertLocationPolicy'] as int?,
multiAcks: json['multiAcks'] as int?,
manualAddContacts: json['manualAddContacts'] as bool?,
autoAddUsers: json['autoAddUsers'] as bool?,
autoAddRepeaters: json['autoAddRepeaters'] as bool?,
autoAddRoomServers: json['autoAddRoomServers'] as bool?,
autoAddSensors: json['autoAddSensors'] as bool?,
autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?,
publicLatitude: (json['publicLatitude'] as num?)?.toDouble(),
publicLongitude: (json['publicLongitude'] as num?)?.toDouble(),
);
}
}
@immutable
class AppSettingsProfileSection {
final bool? mapEnabled;
final bool? contactsEnabled;
final bool? sensorsEnabled;
final bool? voiceSilenceTrimmingEnabled;
final bool? voiceBandPassFilterEnabled;
final bool? voiceCompressorEnabled;
final bool? voiceLimiterEnabled;
final bool? voiceAutoGainEnabled;
final bool? voiceEchoCancellationEnabled;
final bool? voiceNoiseSuppressionEnabled;
final double? messageFontScale;
final bool? clearPathOnMaxRetry;
final bool? nearestRelayFallbackEnabled;
final int? voiceBitrate;
final int? routeHashSize;
final int? imageMaxSize;
final int? imageCompression;
final bool? imageGrayscale;
final bool? imageUltraMode;
final bool? showRxTxIndicators;
final bool? fastLocationUpdatesEnabled;
final double? fastLocationMovementThresholdMeters;
final int? fastLocationActiveCadenceSeconds;
const AppSettingsProfileSection({
this.mapEnabled,
this.contactsEnabled,
this.sensorsEnabled,
this.voiceSilenceTrimmingEnabled,
this.voiceBandPassFilterEnabled,
this.voiceCompressorEnabled,
this.voiceLimiterEnabled,
this.voiceAutoGainEnabled,
this.voiceEchoCancellationEnabled,
this.voiceNoiseSuppressionEnabled,
this.messageFontScale,
this.clearPathOnMaxRetry,
this.nearestRelayFallbackEnabled,
this.voiceBitrate,
this.routeHashSize,
this.imageMaxSize,
this.imageCompression,
this.imageGrayscale,
this.imageUltraMode,
this.showRxTxIndicators,
this.fastLocationUpdatesEnabled,
this.fastLocationMovementThresholdMeters,
this.fastLocationActiveCadenceSeconds,
});
bool get isEmpty =>
mapEnabled == null &&
contactsEnabled == null &&
sensorsEnabled == null &&
voiceSilenceTrimmingEnabled == null &&
voiceBandPassFilterEnabled == null &&
voiceCompressorEnabled == null &&
voiceLimiterEnabled == null &&
voiceAutoGainEnabled == null &&
voiceEchoCancellationEnabled == null &&
voiceNoiseSuppressionEnabled == null &&
messageFontScale == null &&
clearPathOnMaxRetry == null &&
nearestRelayFallbackEnabled == null &&
voiceBitrate == null &&
routeHashSize == null &&
imageMaxSize == null &&
imageCompression == null &&
imageGrayscale == null &&
imageUltraMode == null &&
showRxTxIndicators == null &&
fastLocationUpdatesEnabled == null &&
fastLocationMovementThresholdMeters == null &&
fastLocationActiveCadenceSeconds == null;
Map<String, dynamic> toJson() => {
'mapEnabled': mapEnabled,
'contactsEnabled': contactsEnabled,
'sensorsEnabled': sensorsEnabled,
'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled,
'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled,
'voiceCompressorEnabled': voiceCompressorEnabled,
'voiceLimiterEnabled': voiceLimiterEnabled,
'voiceAutoGainEnabled': voiceAutoGainEnabled,
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
'messageFontScale': messageFontScale,
'clearPathOnMaxRetry': clearPathOnMaxRetry,
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
'voiceBitrate': voiceBitrate,
'routeHashSize': routeHashSize,
'imageMaxSize': imageMaxSize,
'imageCompression': imageCompression,
'imageGrayscale': imageGrayscale,
'imageUltraMode': imageUltraMode,
'showRxTxIndicators': showRxTxIndicators,
'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled,
'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters,
'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds,
};
factory AppSettingsProfileSection.fromJson(Map<String, dynamic> json) {
return AppSettingsProfileSection(
mapEnabled: json['mapEnabled'] as bool?,
contactsEnabled: json['contactsEnabled'] as bool?,
sensorsEnabled: json['sensorsEnabled'] as bool?,
voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?,
voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?,
voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?,
voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?,
voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?,
voiceEchoCancellationEnabled:
json['voiceEchoCancellationEnabled'] as bool?,
voiceNoiseSuppressionEnabled:
json['voiceNoiseSuppressionEnabled'] as bool?,
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
voiceBitrate: json['voiceBitrate'] as int?,
routeHashSize: json['routeHashSize'] as int?,
imageMaxSize: json['imageMaxSize'] as int?,
imageCompression: json['imageCompression'] as int?,
imageGrayscale: json['imageGrayscale'] as bool?,
imageUltraMode: json['imageUltraMode'] as bool?,
showRxTxIndicators: json['showRxTxIndicators'] as bool?,
fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?,
fastLocationMovementThresholdMeters:
(json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(),
fastLocationActiveCadenceSeconds:
json['fastLocationActiveCadenceSeconds'] as int?,
);
}
}
@immutable
class MapWorkspaceProfileSection {
final Map<String, dynamic>? mapPrefs;
final List<Map<String, dynamic>> drawings;
final Map<String, dynamic>? currentTrail;
final List<Map<String, dynamic>> trailHistory;
final Map<String, dynamic>? importedTrail;
final bool? isTrailVisible;
final bool? showCadastralOverlay;
final bool? showForestRoadsOverlay;
final bool? showHikingTrailsOverlay;
final bool? showMainRoadsOverlay;
final bool? showHouseNumbersOverlay;
final bool? showFireHazardZonesOverlay;
final bool? showHistoricalFiresOverlay;
final bool? showFirebreaksOverlay;
final bool? showKrasFireZonesOverlay;
final bool? showPlaceNamesOverlay;
final bool? showMunicipalityBordersOverlay;
final bool? hideRepeatersOnMap;
const MapWorkspaceProfileSection({
this.mapPrefs,
this.drawings = const [],
this.currentTrail,
this.trailHistory = const [],
this.importedTrail,
this.isTrailVisible,
this.showCadastralOverlay,
this.showForestRoadsOverlay,
this.showHikingTrailsOverlay,
this.showMainRoadsOverlay,
this.showHouseNumbersOverlay,
this.showFireHazardZonesOverlay,
this.showHistoricalFiresOverlay,
this.showFirebreaksOverlay,
this.showKrasFireZonesOverlay,
this.showPlaceNamesOverlay,
this.showMunicipalityBordersOverlay,
this.hideRepeatersOnMap,
});
bool get isEmpty =>
mapPrefs == null &&
drawings.isEmpty &&
currentTrail == null &&
trailHistory.isEmpty &&
importedTrail == null &&
isTrailVisible == null &&
showCadastralOverlay == null &&
showForestRoadsOverlay == null &&
showHikingTrailsOverlay == null &&
showMainRoadsOverlay == null &&
showHouseNumbersOverlay == null &&
showFireHazardZonesOverlay == null &&
showHistoricalFiresOverlay == null &&
showFirebreaksOverlay == null &&
showKrasFireZonesOverlay == null &&
showPlaceNamesOverlay == null &&
showMunicipalityBordersOverlay == null &&
hideRepeatersOnMap == null;
Map<String, dynamic> toJson() => {
'mapPrefs': mapPrefs,
'drawings': drawings,
'currentTrail': currentTrail,
'trailHistory': trailHistory,
'importedTrail': importedTrail,
'isTrailVisible': isTrailVisible,
'showCadastralOverlay': showCadastralOverlay,
'showForestRoadsOverlay': showForestRoadsOverlay,
'showHikingTrailsOverlay': showHikingTrailsOverlay,
'showMainRoadsOverlay': showMainRoadsOverlay,
'showHouseNumbersOverlay': showHouseNumbersOverlay,
'showFireHazardZonesOverlay': showFireHazardZonesOverlay,
'showHistoricalFiresOverlay': showHistoricalFiresOverlay,
'showFirebreaksOverlay': showFirebreaksOverlay,
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
'showPlaceNamesOverlay': showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
'hideRepeatersOnMap': hideRepeatersOnMap,
};
factory MapWorkspaceProfileSection.fromJson(Map<String, dynamic> json) {
List<Map<String, dynamic>> decodeList(String key) {
final raw = json[key] as List<dynamic>? ?? const [];
return raw.whereType<Map<String, dynamic>>().toList();
}
return MapWorkspaceProfileSection(
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
drawings: decodeList('drawings'),
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
trailHistory: decodeList('trailHistory'),
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
isTrailVisible: json['isTrailVisible'] as bool?,
showCadastralOverlay: json['showCadastralOverlay'] as bool?,
showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?,
showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?,
showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?,
showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?,
showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?,
showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?,
showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?,
showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?,
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
showMunicipalityBordersOverlay:
json['showMunicipalityBordersOverlay'] as bool?,
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
);
}
}
@immutable
class ConfigProfileSections {
final DeviceConfigProfileSection? deviceConfig;
final AppSettingsProfileSection? appSettings;
final MapWorkspaceProfileSection? mapWorkspace;
final List<Channel> channels;
const ConfigProfileSections({
this.deviceConfig,
this.appSettings,
this.mapWorkspace,
this.channels = const [],
});
bool get isEmpty =>
(deviceConfig == null || deviceConfig!.isEmpty) &&
(appSettings == null || appSettings!.isEmpty) &&
(mapWorkspace == null || mapWorkspace!.isEmpty) &&
channels.isEmpty;
Map<String, dynamic> toJson() => {
'deviceConfig': deviceConfig?.toJson(),
'appSettings': appSettings?.toJson(),
'mapWorkspace': mapWorkspace?.toJson(),
'channels': channels.map((channel) => channel.toJson()).toList(),
};
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
return ConfigProfileSections(
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
? DeviceConfigProfileSection.fromJson(
json['deviceConfig'] as Map<String, dynamic>,
)
: null,
appSettings: json['appSettings'] is Map<String, dynamic>
? AppSettingsProfileSection.fromJson(
json['appSettings'] as Map<String, dynamic>,
)
: null,
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
? MapWorkspaceProfileSection.fromJson(
json['mapWorkspace'] as Map<String, dynamic>,
)
: null,
channels: rawChannels
.whereType<Map<String, dynamic>>()
.map(Channel.fromJson)
.toList(),
);
}
}
@immutable
class ConfigProfile {
static const String defaultProfileId = 'default';
final String id;
final String name;
final DateTime createdAt;
final DateTime updatedAt;
final String? notes;
final ConfigProfileSections sections;
const ConfigProfile({
required this.id,
required this.name,
required this.createdAt,
required this.updatedAt,
required this.sections,
this.notes,
});
bool get isDefault => id == defaultProfileId;
factory ConfigProfile.defaultProfile() {
final now = DateTime.now();
return ConfigProfile(
id: defaultProfileId,
name: 'Default',
createdAt: now,
updatedAt: now,
sections: const ConfigProfileSections(),
);
}
ConfigProfile copyWith({
String? id,
String? name,
DateTime? createdAt,
DateTime? updatedAt,
String? notes,
ConfigProfileSections? sections,
}) {
return ConfigProfile(
id: id ?? this.id,
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
notes: notes ?? this.notes,
sections: sections ?? this.sections,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'notes': notes,
'sections': sections.toJson(),
};
factory ConfigProfile.fromJson(Map<String, dynamic> json) {
return ConfigProfile(
id: json['id'] as String,
name: json['name'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
notes: json['notes'] as String?,
sections: ConfigProfileSections.fromJson(
json['sections'] as Map<String, dynamic>? ?? const {},
),
);
}
}
@immutable
class ProfileDiff {
final List<String> changedSections;
const ProfileDiff({required this.changedSections});
}
@immutable
class ProfileTransferRecord {
final String profileId;
final String direction;
final DateTime timestamp;
final String detail;
const ProfileTransferRecord({
required this.profileId,
required this.direction,
required this.timestamp,
required this.detail,
});
Map<String, dynamic> toJson() => {
'profileId': profileId,
'direction': direction,
'timestamp': timestamp.toIso8601String(),
'detail': detail,
};
factory ProfileTransferRecord.fromJson(Map<String, dynamic> json) {
return ProfileTransferRecord(
profileId: json['profileId'] as String,
direction: json['direction'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
detail: json['detail'] as String,
);
}
}

View File

@@ -141,6 +141,23 @@ class ContactRouteCodec {
);
}
static ParsedContactRoute direct({int hashSize = 1}) {
if (hashSize < 1 || hashSize > maxHashSize) {
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
}
final encodedPathLen = (hashSize - 1) << 6;
return ParsedContactRoute(
canonicalText: '',
hashSize: hashSize,
hopCount: 0,
encodedPathLen: encodedPathLen,
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
pathBytes: Uint8List(0),
paddedPathBytes: Uint8List(maxPathBytes),
);
}
static ParsedContactRoute? fromContact(Contact contact) {
if (!contact.routeHasPath || contact.routeHopCount == 0) {
return null;
@@ -179,6 +196,54 @@ class ContactRouteCodec {
final hopCount = raw & 0x3F;
return hopCount * hashSize <= maxPathBytes;
}
/// Converts an encoded MeshCore route descriptor + path into the legacy raw
/// path format expected by the `CMD_SEND_RAW_DATA` (0x19) handler in
/// companion firmware v1.15.
///
/// The raw-data handler treats the path-length byte as a *literal* hop count
/// (one byte per hop) rather than as an encoded descriptor, so a 1-hop route
/// using 2-byte hashes (descriptor `0x41`) would otherwise be misread as
/// "65 path bytes" and rejected with `ERROR: Unsupported command`. We
/// down-sample every hop to its first hash byte and emit a plain hop count.
///
/// Routes that are already legacy (hash size 1, descriptor `< 0x40`) pass
/// through unchanged. Unknown/flood descriptors collapse to a zero-hop
/// direct path (raw transport does not support flood routing).
static LegacyRawPath toLegacyRawPath(int descriptor, Uint8List path) {
final raw = toUnsignedDescriptor(descriptor);
if (raw == _unknownDescriptor) {
return const LegacyRawPath(length: 0, path: <int>[]);
}
if (raw < 0x40) {
// Hash size 1: the encoded path is already one byte per hop.
final hopCount = raw;
final available = math.min(hopCount, path.length);
return LegacyRawPath(
length: hopCount,
path: Uint8List.fromList(path.sublist(0, available)),
);
}
final hopCount = raw & 0x3F;
final hashSize = ((raw >> 6) & 0x03) + 1;
final legacy = Uint8List(hopCount);
for (var hop = 0; hop < hopCount; hop++) {
final srcIndex = hop * hashSize;
if (srcIndex < path.length) {
legacy[hop] = path[srcIndex];
}
}
return LegacyRawPath(length: hopCount, path: legacy);
}
}
/// Legacy raw-transport path: a literal hop count plus one byte per hop, as
/// expected by the firmware `CMD_SEND_RAW_DATA` handler.
class LegacyRawPath {
const LegacyRawPath({required this.length, required this.path});
final int length;
final List<int> path;
}
extension ContactLocalization on Contact {
@@ -231,15 +296,14 @@ extension ContactLocalization on Contact {
}
String get routeSummary {
if (routeIsUnknown || !routeHasPath) {
if (routeIsUnknown) {
return 'Flood/Unknown';
}
if (routeHopCount == 0) {
if (!routeHasPath || routeHopCount == 0) {
return 'Direct';
}
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport =>
routeHasPath && routeSignedPathLen >= 0;
bool get routeSupportsLegacyRawTransport => routeHasPath;
}

View File

@@ -0,0 +1,39 @@
class SavedContactGroup {
final String id;
final String sectionKey;
final String label;
final String query;
final DateTime createdAt;
final List<String>? matchPrefixes;
final bool isAutoGroup;
const SavedContactGroup({
required this.id,
required this.sectionKey,
required this.label,
required this.query,
required this.createdAt,
this.matchPrefixes,
this.isAutoGroup = false,
});
SavedContactGroup copyWith({
String? id,
String? sectionKey,
String? label,
String? query,
DateTime? createdAt,
List<String>? matchPrefixes,
bool? isAutoGroup,
}) {
return SavedContactGroup(
id: id ?? this.id,
sectionKey: sectionKey ?? this.sectionKey,
label: label ?? this.label,
query: query ?? this.query,
createdAt: createdAt ?? this.createdAt,
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
);
}
}

View File

@@ -0,0 +1,151 @@
import 'dart:math' as math;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../utils/custom_map_id.dart';
class CustomMapConfig {
final String filePath;
final String displayName;
final String mapId;
final int imageWidth;
final int imageHeight;
final double? metersPerPixel;
final LatLng? calibrationPointA;
final LatLng? calibrationPointB;
const CustomMapConfig({
required this.filePath,
required this.displayName,
required this.mapId,
required this.imageWidth,
required this.imageHeight,
this.metersPerPixel,
this.calibrationPointA,
this.calibrationPointB,
});
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
double get _displayScale {
final heightScale = imageHeight / 90.0;
final widthScale = imageWidth / 180.0;
return math.max(1.0, math.max(heightScale, widthScale));
}
double get _displayHeight => imageHeight / _displayScale;
double get _displayWidth => imageWidth / _displayScale;
LatLngBounds get bounds =>
LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth));
LatLngBounds get displayBounds =>
LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth));
LatLng toDisplayPoint(LatLng point) {
return LatLng(
_displayHeight - (point.latitude / _displayScale),
point.longitude / _displayScale,
);
}
LatLng fromDisplayPoint(LatLng point) {
return LatLng(
(_displayHeight - point.latitude) * _displayScale,
point.longitude * _displayScale,
);
}
Map<String, dynamic> toJson() {
return {
'filePath': filePath,
'displayName': displayName,
'mapId': mapId,
'imageWidth': imageWidth,
'imageHeight': imageHeight,
'metersPerPixel': metersPerPixel,
'calibrationPointA': calibrationPointA != null
? {
'lat': calibrationPointA!.latitude,
'lon': calibrationPointA!.longitude,
}
: null,
'calibrationPointB': calibrationPointB != null
? {
'lat': calibrationPointB!.latitude,
'lon': calibrationPointB!.longitude,
}
: null,
};
}
CustomMapConfig copyWith({
String? filePath,
String? displayName,
String? mapId,
int? imageWidth,
int? imageHeight,
double? metersPerPixel,
bool clearMetersPerPixel = false,
LatLng? calibrationPointA,
bool clearCalibrationPointA = false,
LatLng? calibrationPointB,
bool clearCalibrationPointB = false,
}) {
return CustomMapConfig(
filePath: filePath ?? this.filePath,
displayName: displayName ?? this.displayName,
mapId: normalizeCustomMapId(mapId) ?? this.mapId,
imageWidth: imageWidth ?? this.imageWidth,
imageHeight: imageHeight ?? this.imageHeight,
metersPerPixel: clearMetersPerPixel
? null
: (metersPerPixel ?? this.metersPerPixel),
calibrationPointA: clearCalibrationPointA
? null
: (calibrationPointA ?? this.calibrationPointA),
calibrationPointB: clearCalibrationPointB
? null
: (calibrationPointB ?? this.calibrationPointB),
);
}
static CustomMapConfig? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
final filePath = json['filePath'];
final displayName = json['displayName'];
final mapId = json['mapId'];
final imageWidth = json['imageWidth'];
final imageHeight = json['imageHeight'];
if (filePath is! String ||
displayName is! String ||
mapId is! String ||
imageWidth is! int ||
imageHeight is! int) {
return null;
}
LatLng? parsePoint(dynamic raw) {
if (raw is! Map<String, dynamic>) return null;
final lat = raw['lat'];
final lon = raw['lon'];
if (lat is! num || lon is! num) return null;
return LatLng(lat.toDouble(), lon.toDouble());
}
final metersPerPixel = json['metersPerPixel'];
return CustomMapConfig(
filePath: filePath,
displayName: displayName,
mapId: normalizeCustomMapId(mapId) ?? mapId,
imageWidth: imageWidth,
imageHeight: imageHeight,
metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null,
calibrationPointA: parsePoint(json['calibrationPointA']),
calibrationPointB: parsePoint(json['calibrationPointB']),
);
}
}

View File

@@ -14,11 +14,11 @@ enum ConnectionMode {
/// Direct BLE connection to MeshCore device (default)
ble,
/// Act as SSE server - share BLE device with multiple clients
sseServer,
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
tcp,
/// USB serial connection (Android OTG or Web Serial API)
usb,
}
extension ConnectionModeExtension on ConnectionMode {
@@ -26,10 +26,10 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct (BLE)';
case ConnectionMode.sseServer:
return 'Share Device (Server)';
case ConnectionMode.tcp:
return 'Direct (WiFi)';
case ConnectionMode.usb:
return 'Direct (Serial)';
}
}
@@ -37,10 +37,10 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network';
case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device';
case ConnectionMode.usb:
return 'Direct serial connection to MeshCore device';
}
}
}
@@ -66,6 +66,12 @@ class DeviceInfo {
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? autoAddMaxHops;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -90,6 +96,7 @@ class DeviceInfo {
// Repeat mode (firmware v9+)
final bool? clientRepeat;
final int? pathHashMode;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
@@ -113,6 +120,12 @@ class DeviceInfo {
this.advLat,
this.advLon,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.autoAddMaxHops,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -129,6 +142,7 @@ class DeviceInfo {
this.manufacturerModel,
this.semanticVersion,
this.clientRepeat,
this.pathHashMode,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
@@ -239,6 +253,12 @@ class DeviceInfo {
int? advLat,
int? advLon,
bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? autoAddMaxHops,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -255,6 +275,7 @@ class DeviceInfo {
String? manufacturerModel,
String? semanticVersion,
bool? clientRepeat,
int? pathHashMode,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
@@ -278,6 +299,13 @@ class DeviceInfo {
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
@@ -294,6 +322,7 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat,
pathHashMode: pathHashMode ?? this.pathHashMode,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,

View File

@@ -0,0 +1,11 @@
enum MapCoordinateSpace {
geo,
customMap;
static MapCoordinateSpace fromName(String? value) {
return MapCoordinateSpace.values.firstWhere(
(space) => space.name == value,
orElse: () => MapCoordinateSpace.geo,
);
}
}

View File

@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'map_coordinate_space.dart';
import '../utils/custom_map_id.dart';
/// Drawing shape type
enum DrawingShapeType {
line,
rectangle,
}
enum DrawingShapeType { line, rectangle }
/// Drawing color enum for compact network transmission
enum DrawingColor {
red, // 0
blue, // 1
green, // 2
yellow, // 3
orange, // 4
purple, // 5
pink, // 6
cyan, // 7
}
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
/// Drawing colors available for user selection
class DrawingColors {
static const List<Color> palette = [
Colors.red, // index 0
Colors.blue, // index 1
Colors.green, // index 2
Colors.yellow, // index 3
Colors.orange, // index 4
Colors.purple, // index 5
Colors.pink, // index 6
Colors.cyan, // index 7
Colors.red,
Colors.blue,
Colors.green,
Colors.yellow,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.cyan,
];
static String colorToName(Color color) {
@@ -45,37 +36,36 @@ class DrawingColors {
return 'Unknown';
}
/// Convert Color to enum index for network transmission
static int colorToIndex(Color color) {
for (int i = 0; i < palette.length; i++) {
if (palette[i].toARGB32() == color.toARGB32()) {
return i;
}
}
return 0; // Default to red if not found
return 0;
}
/// Convert enum index to Color for network reception
static Color indexToColor(int index) {
if (index >= 0 && index < palette.length) {
return palette[index];
}
return palette[0]; // Default to red if invalid index
return palette[0];
}
}
/// Base class for map drawings
abstract class MapDrawing {
final String id;
final DrawingShapeType type;
final Color color;
final DateTime createdAt;
final String? senderName; // Name of sender (null if local drawing)
final bool isReceived; // True if drawing was received from another node
final String? messageId; // ID of the source message (for navigation)
final bool isShared; // Whether drawing has been broadcast over mesh
final bool isSent; // Whether this is a sent drawing (vs received)
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
final String? senderName;
final bool isReceived;
final String? messageId;
final bool isShared;
final bool isSent;
final bool isHidden;
final MapCoordinateSpace coordinateSpace;
final String? mapId;
MapDrawing({
required this.id,
@@ -88,79 +78,79 @@ abstract class MapDrawing {
this.isShared = false,
this.isSent = false,
this.isHidden = false,
this.coordinateSpace = MapCoordinateSpace.geo,
this.mapId,
});
/// Convert to JSON for persistence
bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
Map<String, dynamic> toJson();
/// Convert to JSON for network transmission (compact format)
/// Uses short field names and excludes createdAt to minimize message size
/// Sender will be fetched from packet metadata
Map<String, dynamic> toNetworkJson();
/// Parse network JSON (compact format)
/// senderName and messageId will be populated from packet metadata
static MapDrawing? fromNetworkJson(
Map<String, dynamic> json, {
String? senderName,
String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) {
final typeNum = json['t'] as int?;
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
if (typeNum == null ||
typeNum < 0 ||
typeNum >= DrawingShapeType.values.length) {
return null;
}
try {
final type = DrawingShapeType.values[typeNum];
switch (type) {
case DrawingShapeType.line:
return LineDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: mapId,
);
case DrawingShapeType.rectangle:
return RectangleDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: mapId,
);
}
} catch (e) {
} catch (_) {
return null;
}
}
/// Create from JSON
static MapDrawing? fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String?;
if (typeStr == null) return null;
try {
final type = DrawingShapeType.values.firstWhere(
(e) => e.toString() == 'DrawingShapeType.$typeStr',
(value) => value.name == typeStr,
);
switch (type) {
case DrawingShapeType.line:
return LineDrawing.fromJson(json);
case DrawingShapeType.rectangle:
return RectangleDrawing.fromJson(json);
}
} catch (e) {
} catch (_) {
return null;
}
}
/// Get the center point of the drawing
LatLng getCenter();
/// Get the bounds of the drawing
LatLngBounds getBounds();
}
/// Line drawing on map
class LineDrawing extends MapDrawing {
final List<LatLng> points;
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
super.isShared,
super.isSent,
super.isHidden,
super.coordinateSpace,
super.mapId,
}) : super(type: DrawingShapeType.line);
@override
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
'type': type.name,
'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(),
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
'points': points
.map((point) => {'lat': point.latitude, 'lon': point.longitude})
.toList(),
'isShared': isShared,
// Note: isHidden is not persisted - it's session-only
'coordinateSpace': coordinateSpace.name,
'mapId': normalizeCustomMapId(mapId),
};
}
@override
Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
final payload = <String, dynamic>{
't': type.index,
'c': DrawingColors.colorToIndex(color),
'p': points.expand((p) => [
double.parse(p.latitude.toStringAsFixed(5)),
double.parse(p.longitude.toStringAsFixed(5)),
]).toList(),
};
if (coordinateSpace == MapCoordinateSpace.customMap) {
payload['m'] = normalizeCustomMapId(mapId);
payload['p'] = points
.expand((point) => [point.latitude.round(), point.longitude.round()])
.toList();
return payload;
}
payload['p'] = points
.expand(
(point) => [
double.parse(point.latitude.toStringAsFixed(5)),
double.parse(point.longitude.toStringAsFixed(5)),
],
)
.toList();
return payload;
}
static LineDrawing fromJson(Map<String, dynamic> json) {
final pointsJson = json['points'] as List<dynamic>;
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
final points = pointsJson
.map(
(point) => LatLng(
(point['lat'] as num).toDouble(),
(point['lon'] as num).toDouble(),
),
)
.toList();
final senderName = json['sender'] as String?;
return LineDrawing(
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
createdAt: DateTime.parse(json['createdAt'] as String),
points: points,
senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present
isReceived: senderName != null,
isShared: json['isShared'] as bool? ?? false,
coordinateSpace: MapCoordinateSpace.fromName(
json['coordinateSpace'] as String?,
),
mapId: normalizeCustomMapId(json['mapId'] as String?),
);
}
@@ -226,42 +242,60 @@ class LineDrawing extends MapDrawing {
Map<String, dynamic> json, {
String? senderName,
String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) {
// Parse ultra-compact format
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
final points = <LatLng>[];
for (int i = 0; i < pointsFlat.length; i += 2) {
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
for (int i = 0; i < flatPoints.length; i += 2) {
points.add(
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
);
}
return LineDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(),
points: points,
senderName: senderName,
isReceived: true,
messageId: messageId, // Link to source message
isShared: false, // Received drawings are not marked as shared
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: normalizeCustomMapId(mapId),
);
}
/// Create a copy with updated points
LineDrawing copyWith({List<LatLng>? points}) {
LineDrawing copyWith({
List<LatLng>? points,
bool? isHidden,
bool? isShared,
bool? isReceived,
String? messageId,
String? senderName,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return LineDrawing(
id: id,
color: color,
createdAt: createdAt,
points: points ?? this.points,
isHidden: isHidden ?? this.isHidden,
isShared: isShared ?? this.isShared,
isReceived: isReceived ?? this.isReceived,
messageId: messageId ?? this.messageId,
senderName: senderName ?? this.senderName,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
);
}
@override
LatLng getCenter() {
if (points.isEmpty) return LatLng(0, 0);
if (points.isEmpty) return const LatLng(0, 0);
if (points.length == 1) return points[0];
// Calculate center as average of all points
double sumLat = 0;
double sumLon = 0;
for (final point in points) {
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
@override
LatLngBounds getBounds() {
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
if (points.length == 1) return LatLngBounds(points[0], points[0]);
if (points.isEmpty) {
return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0));
}
if (points.length == 1) {
return LatLngBounds(points[0], points[0]);
}
double minLat = points[0].latitude;
double maxLat = points[0].latitude;
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
}
}
/// Rectangle drawing on map
class RectangleDrawing extends MapDrawing {
final LatLng topLeft;
final LatLng bottomRight;
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
super.isShared,
super.isSent,
super.isHidden,
super.coordinateSpace,
super.mapId,
}) : super(type: DrawingShapeType.rectangle);
/// Get all corner points for rendering
List<LatLng> get corners => [
topLeft,
LatLng(topLeft.latitude, bottomRight.longitude), // top right
bottomRight,
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
topLeft, // close the rectangle
];
topLeft,
LatLng(topLeft.latitude, bottomRight.longitude),
bottomRight,
LatLng(bottomRight.latitude, topLeft.longitude),
topLeft,
];
@override
Map<String, dynamic> toJson() {
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(),
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
'bottomRight': {
'lat': bottomRight.latitude,
'lon': bottomRight.longitude,
},
'isShared': isShared,
// Note: isHidden is not persisted - it's session-only
'coordinateSpace': coordinateSpace.name,
'mapId': normalizeCustomMapId(mapId),
};
}
@override
Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
final payload = <String, dynamic>{
't': type.index,
'c': DrawingColors.colorToIndex(color),
'b': [
double.parse(topLeft.latitude.toStringAsFixed(5)),
double.parse(topLeft.longitude.toStringAsFixed(5)),
double.parse(bottomRight.latitude.toStringAsFixed(5)),
double.parse(bottomRight.longitude.toStringAsFixed(5)),
],
};
if (coordinateSpace == MapCoordinateSpace.customMap) {
payload['m'] = normalizeCustomMapId(mapId);
payload['b'] = [
topLeft.latitude.round(),
topLeft.longitude.round(),
bottomRight.latitude.round(),
bottomRight.longitude.round(),
];
return payload;
}
payload['b'] = [
double.parse(topLeft.latitude.toStringAsFixed(5)),
double.parse(topLeft.longitude.toStringAsFixed(5)),
double.parse(bottomRight.latitude.toStringAsFixed(5)),
double.parse(bottomRight.longitude.toStringAsFixed(5)),
];
return payload;
}
static RectangleDrawing fromJson(Map<String, dynamic> json) {
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
id: json['id'] as String,
color: Color(json['color'] as int),
createdAt: DateTime.parse(json['createdAt'] as String),
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
topLeft: LatLng(
(topLeftJson['lat'] as num).toDouble(),
(topLeftJson['lon'] as num).toDouble(),
),
bottomRight: LatLng(
(bottomRightJson['lat'] as num).toDouble(),
(bottomRightJson['lon'] as num).toDouble(),
),
senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present
isReceived: senderName != null,
isShared: json['isShared'] as bool? ?? false,
coordinateSpace: MapCoordinateSpace.fromName(
json['coordinateSpace'] as String?,
),
mapId: normalizeCustomMapId(json['mapId'] as String?),
);
}
@@ -372,27 +434,35 @@ class RectangleDrawing extends MapDrawing {
Map<String, dynamic> json, {
String? senderName,
String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) {
// Parse ultra-compact format
final bounds = (json['b'] as List<dynamic>).cast<double>();
final bounds = (json['b'] as List<dynamic>).cast<num>();
return RectangleDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(),
topLeft: LatLng(bounds[0], bounds[1]),
bottomRight: LatLng(bounds[2], bounds[3]),
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
senderName: senderName,
isReceived: true,
messageId: messageId, // Link to source message
isShared: false, // Received drawings are not marked as shared
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: normalizeCustomMapId(mapId),
);
}
/// Create a copy with updated corners
RectangleDrawing copyWith({
LatLng? topLeft,
LatLng? bottomRight,
bool? isHidden,
bool? isShared,
bool? isReceived,
String? messageId,
String? senderName,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return RectangleDrawing(
id: id,
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
createdAt: createdAt,
topLeft: topLeft ?? this.topLeft,
bottomRight: bottomRight ?? this.bottomRight,
isHidden: isHidden ?? this.isHidden,
isShared: isShared ?? this.isShared,
isReceived: isReceived ?? this.isReceived,
messageId: messageId ?? this.messageId,
senderName: senderName ?? this.senderName,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
);
}
@override
LatLng getCenter() {
// Center is the midpoint between top-left and bottom-right
return LatLng(
(topLeft.latitude + bottomRight.latitude) / 2,
(topLeft.longitude + bottomRight.longitude) / 2,
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
@override
LatLngBounds getBounds() {
// Bounds are simply the two corners
return LatLngBounds(topLeft, bottomRight);
}
}

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../l10n/app_localizations.dart';
@@ -10,8 +9,11 @@ enum MapLayerType {
googleHybrid,
googleRoadmap,
googleTerrain,
// Kept for stored preference compatibility after MBTiles removal.
vectorMbtiles,
wmsBase,
// Appended last: stored layer preference is the enum index.
mapyOutdoor,
}
class MapLayer {
@@ -21,12 +23,8 @@ class MapLayer {
final String attribution;
final double maxZoom;
// Vector tile specific properties
final bool isVector;
final File? mbtilesFile;
final String? styleUrl;
final String? sourceName;
final bool? isGzipped;
/// Extra HTTP headers required by the tile server (e.g. Referer).
final Map<String, String>? headers;
// WMS specific properties
final bool isWms;
@@ -43,11 +41,7 @@ class MapLayer {
required this.urlTemplate,
required this.attribution,
required this.maxZoom,
this.isVector = false,
this.mbtilesFile,
this.styleUrl,
this.sourceName,
this.isGzipped,
this.headers,
this.isWms = false,
this.wmsBaseUrl,
this.wmsLayers,
@@ -74,7 +68,10 @@ class MapLayer {
case MapLayerType.googleTerrain:
return localizations.googleTerrain;
case MapLayerType.vectorMbtiles:
// For vector tiles, use the name from metadata
// Legacy value kept only for preference migration compatibility.
return name;
case MapLayerType.mapyOutdoor:
// Brand name, not translated.
return name;
case MapLayerType.wmsBase:
// For WMS layers, use the name (will be localized separately)
@@ -98,6 +95,17 @@ class MapLayer {
maxZoom: 17.49, // OpenTopoMap maximum (just below level 18)
);
/// Mapy.cz outdoor map: OSM data with LIDAR-based shaded relief.
/// The tile server rejects requests without a mapy.com Referer.
static const mapyOutdoor = MapLayer(
type: MapLayerType.mapyOutdoor,
name: 'Mapy.cz Outdoor (OSM + LIDAR)',
urlTemplate: 'https://mapserver.mapy.cz/turist-en/retina/{z}-{x}-{y}',
attribution: '© Seznam.cz, a.s., © OpenStreetMap contributors',
maxZoom: 19,
headers: {'Referer': 'https://mapy.com/'},
);
static const esriWorldImagery = MapLayer(
type: MapLayerType.esriWorldImagery,
name: 'ESRI Satellite',
@@ -172,6 +180,7 @@ class MapLayer {
static const List<MapLayer> allLayers = [
openStreetMap,
openTopoMap,
mapyOutdoor,
esriWorldImagery,
googleHybrid,
googleRoadmap,
@@ -182,28 +191,4 @@ class MapLayer {
static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type);
}
/// Create a MapLayer from an MBTiles file
static MapLayer fromMbtilesFile({
required String name,
required File mbtilesFile,
required String styleUrl,
required String sourceName,
required double maxZoom,
required bool isGzipped,
String? attribution,
}) {
return MapLayer(
type: MapLayerType.vectorMbtiles,
name: name,
urlTemplate: '', // Not used for vector tiles
attribution: attribution ?? 'MBTiles',
maxZoom: maxZoom,
isVector: true,
mbtilesFile: mbtilesFile,
styleUrl: styleUrl,
sourceName: sourceName,
isGzipped: isGzipped,
);
}
}

View File

@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'sar_marker.dart';
import 'map_coordinate_space.dart';
import '../utils/voice_message_parser.dart';
extension MessageVoiceExtension on Message {
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
/// Convert to a [SarMarker] if this message contains SAR data.
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
if (!isSarMarker || sarMarkerType == null) {
return null;
}
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
debugPrint(' message.sarMarkerType: $sarMarkerType');
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null
? MapCoordinateSpace.customMap
: MapCoordinateSpace.geo;
final location = sarCustomMapPoint ?? sarGpsCoordinates;
if (location == null) {
return null;
}
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
location: location,
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: sarNotes,
customEmoji: sarCustomEmoji,
colorIndex: sarColorIndex,
coordinateSpace: coordinateSpace,
mapId: sarCustomMapId,
);
}
}

View File

@@ -15,10 +15,14 @@ class MessageContactLocation {
String get technicalSourceLabel {
switch (source) {
case 'shared':
return 'shared location';
case 'gps':
return 'shared location';
case 'telemetry':
return 'telemetry';
return 'live telemetry';
case 'advert':
return 'advert';
return 'shared advert';
default:
return source;
}

View File

@@ -30,6 +30,7 @@ class MessageReceptionDetails {
final int? senderToReceiptMs;
final int? estimatedTransmitMs;
final int? postTransmitDelayMs;
final int receivedCopies;
const MessageReceptionDetails({
required this.capturedAt,
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
this.senderToReceiptMs,
this.estimatedTransmitMs,
this.postTransmitDelayMs,
this.receivedCopies = 1,
});
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
'receivedCopies': receivedCopies,
};
}
MessageReceptionDetails copyWith({
DateTime? capturedAt,
DateTime? packetLoggedAt,
int? rssiDbm,
double? snrDb,
List<int>? pathBytes,
int? senderToReceiptMs,
int? estimatedTransmitMs,
int? postTransmitDelayMs,
int? receivedCopies,
}) {
return MessageReceptionDetails(
capturedAt: capturedAt ?? this.capturedAt,
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
rssiDbm: rssiDbm ?? this.rssiDbm,
snrDb: snrDb ?? this.snrDb,
pathBytes: pathBytes ?? this.pathBytes,
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
receivedCopies: receivedCopies ?? this.receivedCopies,
);
}
static MessageReceptionDetails mergeDuplicate({
MessageReceptionDetails? existing,
MessageReceptionDetails? incoming,
}) {
final base =
existing ??
incoming ??
MessageReceptionDetails(capturedAt: DateTime.now());
return base.copyWith(
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
snrDb: incoming?.snrDb ?? existing?.snrDb,
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
senderToReceiptMs:
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
estimatedTransmitMs:
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
postTransmitDelayMs:
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
receivedCopies:
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
);
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) {
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
receivedCopies: json['receivedCopies'] as int? ?? 1,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'path_selection.dart';
class MessageRouteMetadata {
final PathSelectionMode mode;
final bool routerFallbackAttempted;
final String? relayName;
final String? relayKey6;
final String? canonicalPath;
final int? hopCount;
const MessageRouteMetadata({
required this.mode,
required this.routerFallbackAttempted,
this.relayName,
this.relayKey6,
this.canonicalPath,
this.hopCount,
});
factory MessageRouteMetadata.fromSelection(
PathSelection selection, {
required bool routerFallbackAttempted,
}) {
return MessageRouteMetadata(
mode: selection.mode,
routerFallbackAttempted: routerFallbackAttempted,
relayName: selection.relayName,
relayKey6: selection.relayKey6,
canonicalPath: selection.canonicalPath.isEmpty
? null
: selection.canonicalPath,
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
);
}
String get modeLabel {
switch (mode) {
case PathSelectionMode.directCurrent:
return 'Current direct path';
case PathSelectionMode.directHistorical:
return 'Rotated direct path';
case PathSelectionMode.flood:
return 'Flood route';
case PathSelectionMode.nearestRouter:
final suffix = relayName?.trim().isNotEmpty == true
? ' via $relayName'
: relayKey6?.trim().isNotEmpty == true
? ' via $relayKey6'
: '';
return 'Nearest router$suffix';
}
}
Map<String, dynamic> toJson() {
return {
'mode': mode.name,
'router_fallback_attempted': routerFallbackAttempted,
'relay_name': relayName,
'relay_key6': relayKey6,
'canonical_path': canonicalPath,
'hop_count': hopCount,
};
}
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
return MessageRouteMetadata(
mode: PathSelectionMode.values.firstWhere(
(value) => value.name == json['mode'],
orElse: () => PathSelectionMode.directCurrent,
),
routerFallbackAttempted:
json['router_fallback_attempted'] as bool? ?? false,
relayName: json['relay_name'] as String?,
relayKey6: json['relay_key6'] as String?,
canonicalPath: json['canonical_path'] as String?,
hopCount: json['hop_count'] as int?,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'dart:typed_data';
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
class PathSelection {
final PathSelectionMode mode;
final Uint8List pathBytes;
final int hopCount;
final int hashSize;
final String? relayName;
final String? relayKey6;
const PathSelection({
required this.mode,
required this.pathBytes,
required this.hopCount,
required this.hashSize,
this.relayName,
this.relayKey6,
});
PathSelection.flood()
: mode = PathSelectionMode.flood,
pathBytes = Uint8List(0),
hopCount = -1,
hashSize = 1,
relayName = null,
relayKey6 = null;
bool get usesFlood => mode == PathSelectionMode.flood;
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
String get canonicalPath {
if (!hasDirectPath) return '';
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
PathSelection copyWith({
PathSelectionMode? mode,
Uint8List? pathBytes,
int? hopCount,
int? hashSize,
String? relayName,
String? relayKey6,
}) {
return PathSelection(
mode: mode ?? this.mode,
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
relayName: relayName ?? this.relayName,
relayKey6: relayKey6 ?? this.relayKey6,
);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart';
import 'map_coordinate_space.dart';
import '../services/sar_template_service.dart';
/// SAR (Search & Rescue) marker types
@@ -78,6 +79,8 @@ class SarMarker {
final String? notes;
final String? customEmoji; // For custom SAR markers not in predefined types
final int? colorIndex; // Color index (0-7) from standard palette
final MapCoordinateSpace coordinateSpace;
final String? mapId;
SarMarker({
required this.id,
@@ -89,8 +92,13 @@ class SarMarker {
this.notes,
this.customEmoji,
this.colorIndex,
this.coordinateSpace = MapCoordinateSpace.geo,
this.mapId,
});
bool get isCustomMapMarker =>
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
@@ -167,6 +175,8 @@ class SarMarker {
String? notes,
String? customEmoji,
int? colorIndex,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return SarMarker(
id: id ?? this.id,
@@ -178,6 +188,8 @@ class SarMarker {
notes: notes ?? this.notes,
customEmoji: customEmoji ?? this.customEmoji,
colorIndex: colorIndex ?? this.colorIndex,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
);
}

View File

@@ -1,71 +0,0 @@
/// SSE Server Configuration Model
///
/// Configuration for the SSE (Server-Sent Events) web server that enables
/// multiple app instances to share a single MeshCore BLE device.
class SseServerConfig {
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
final String host;
/// Server port (default: 12929)
final int port;
/// Whether the SSE server is enabled
final bool enabled;
/// Optional authentication token for basic security
/// Clients must include this token in Authorization header
final String? authToken;
const SseServerConfig({
this.host = '0.0.0.0',
this.port = 12929,
this.enabled = false,
this.authToken,
});
/// Create a copy with updated fields
SseServerConfig copyWith({
String? host,
int? port,
bool? enabled,
String? authToken,
}) {
return SseServerConfig(
host: host ?? this.host,
port: port ?? this.port,
enabled: enabled ?? this.enabled,
authToken: authToken ?? this.authToken,
);
}
/// Get server URL for clients to connect to
String getServerUrl({String? ipAddress}) {
final ip = ipAddress ?? host;
return 'http://$ip:$port';
}
/// Convert to JSON for persistence
Map<String, dynamic> toJson() {
return {
'host': host,
'port': port,
'enabled': enabled,
'authToken': authToken,
};
}
/// Create from JSON
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
return SseServerConfig(
host: json['host'] as String? ?? '0.0.0.0',
port: json['port'] as int? ?? 12929,
enabled: json['enabled'] as bool? ?? false,
authToken: json['authToken'] as String?,
);
}
@override
String toString() {
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
int _selectedChannelIndex = 0; // Default to public channel
/// Get all channels
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
List<Channel> get channels =>
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
/// Get a specific channel by index
Channel? getChannel(int index) => _channels[index];
@@ -28,6 +29,16 @@ class ChannelsProvider with ChangeNotifier {
return index == 0 ? 'Public' : 'Channel $index';
}
List<Channel> getChannelsByHashByte(int hashByte) {
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
..sort((a, b) => a.index.compareTo(b.index));
}
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
final matches = getChannelsByHashByte(hashByte);
return matches.length == 1 ? matches.single.displayName : null;
}
/// Add or update a channel
void addOrUpdateChannel({
required int index,
@@ -54,12 +65,12 @@ class ChannelsProvider with ChangeNotifier {
void removeChannel(int index) {
if (_channels.containsKey(index)) {
_channels.remove(index);
// If the deleted channel was selected, switch to public channel
if (_selectedChannelIndex == index) {
_selectedChannelIndex = 0;
}
notifyListeners();
}
}
@@ -96,6 +107,13 @@ class ChannelsProvider with ChangeNotifier {
notifyListeners();
}
/// Clear runtime channel state before a live device sync begins.
void prepareForDeviceSync() {
_channels.clear();
_selectedChannelIndex = 0;
notifyListeners();
}
/// Check if channels have been loaded
bool get hasChannels => _channels.isNotEmpty;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,11 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/map_drawing.dart';
import '../models/map_coordinate_space.dart';
import '../services/profiles_feature_service.dart';
import '../utils/drawing_message_parser.dart';
/// Drawing mode state
@@ -11,6 +14,8 @@ enum DrawingMode { none, line, rectangle, measure }
/// Provider for managing map drawings
class DrawingProvider with ChangeNotifier {
static const String _storageKey = 'map_drawings';
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
static const String _showSarMarkersKey = 'map_show_sar_markers';
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
@@ -20,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings
final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing
MapDrawing? _currentDrawing;
@@ -30,12 +36,18 @@ class DrawingProvider with ChangeNotifier {
LatLng? _measurementPoint1;
LatLng? _measurementPoint2;
double? _measuredDistance; // in meters
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
String? _activeMapId;
double? _activeMetersPerPixel;
// Getters
DrawingMode get drawingMode => _drawingMode;
Color get selectedColor => _selectedColor;
bool get showReceivedDrawings => _showReceivedDrawings;
bool get showSarMarkers => _showSarMarkers;
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
String? get activeMapId => _activeMapId;
double? get activeMetersPerPixel => _activeMetersPerPixel;
List<MapDrawing> get drawings {
// Filter out hidden drawings first
var visibleDrawings = _drawings.where((d) => !d.isHidden);
@@ -45,19 +57,62 @@ class DrawingProvider with ChangeNotifier {
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
}
visibleDrawings = visibleDrawings.where((drawing) {
if (drawing.coordinateSpace != _activeCoordinateSpace) {
return false;
}
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
return drawing.mapId != null && drawing.mapId == _activeMapId;
}
return true;
});
return List.unmodifiable(visibleDrawings.toList());
}
MapDrawing? get currentDrawing => _currentDrawing;
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
LatLng? get rectangleStartPoint => _rectangleStartPoint;
bool get isDrawing => _drawingMode != DrawingMode.none;
bool get isInitialized => _isInitialized;
LatLng? get measurementPoint1 => _measurementPoint1;
LatLng? get measurementPoint2 => _measurementPoint2;
double? get measuredDistance => _measuredDistance;
/// Initialize and load saved drawings
Future<void> initialize() async {
await _loadPreferences();
await _loadDrawings();
_isInitialized = true;
}
Future<void> reloadProfileScopedState() async {
await _loadPreferences();
await _loadDrawings();
notifyListeners();
}
void setMapContext({
required MapCoordinateSpace coordinateSpace,
String? mapId,
double? metersPerPixel,
}) {
final changed =
coordinateSpace != _activeCoordinateSpace ||
mapId != _activeMapId ||
metersPerPixel != _activeMetersPerPixel;
_activeCoordinateSpace = coordinateSpace;
_activeMapId = mapId;
_activeMetersPerPixel = metersPerPixel;
if (_measurementPoint1 != null && _measurementPoint2 != null) {
_measuredDistance = _calculateDistance(
_measurementPoint1!,
_measurementPoint2!,
);
}
if (changed) {
notifyListeners();
}
}
/// Set drawing mode
@@ -77,15 +132,34 @@ class DrawingProvider with ChangeNotifier {
}
/// Toggle visibility of received drawings
void toggleReceivedDrawings() {
Future<void> toggleReceivedDrawings() async {
_showReceivedDrawings = !_showReceivedDrawings;
notifyListeners();
await _savePreferences();
}
/// Toggle visibility of SAR markers
void toggleSarMarkers() {
Future<void> toggleSarMarkers() async {
_showSarMarkers = !_showSarMarkers;
notifyListeners();
await _savePreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings =
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
notifyListeners();
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey(_showReceivedDrawingsKey),
_showReceivedDrawings,
);
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
}
/// Start drawing a line
@@ -116,6 +190,10 @@ class DrawingProvider with ChangeNotifier {
color: _selectedColor,
createdAt: DateTime.now(),
points: List.from(_currentLinePoints),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
_drawings.add(drawing);
@@ -159,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
? _rectangleStartPoint!.longitude
: endPoint.longitude,
),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
notifyListeners();
}
@@ -195,6 +277,10 @@ class DrawingProvider with ChangeNotifier {
createdAt: DateTime.now(),
topLeft: topLeft,
bottomRight: bottomRight,
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
_drawings.add(drawing);
@@ -216,7 +302,9 @@ class DrawingProvider with ChangeNotifier {
/// Set second measurement point and calculate distance
void setMeasurementPoint2(LatLng point) {
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
return;
}
_measurementPoint2 = point;
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
@@ -225,6 +313,13 @@ class DrawingProvider with ChangeNotifier {
/// Calculate distance between two points using Haversine formula
double _calculateDistance(LatLng point1, LatLng point2) {
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
_activeMetersPerPixel != null) {
final dy = point2.latitude - point1.latitude;
final dx = point2.longitude - point1.longitude;
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
return pixelDistance * _activeMetersPerPixel!;
}
const Distance distance = Distance();
return distance.as(LengthUnit.Meter, point1, point2);
}
@@ -281,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
final jsonList = _drawings.map((d) => d.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_storageKey, jsonString);
await prefs.setString(_scopedKey(_storageKey), jsonString);
} catch (e) {
debugPrint('Error saving drawings: $e');
}
@@ -291,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
Future<void> _loadDrawings() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey);
if (jsonString == null) return;
final jsonString = prefs.getString(_scopedKey(_storageKey));
if (jsonString == null || jsonString.isEmpty) {
_drawings.clear();
return;
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
_drawings.clear();
@@ -318,6 +416,10 @@ class DrawingProvider with ChangeNotifier {
color: _selectedColor,
createdAt: DateTime.now(),
points: _currentLinePoints,
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
} else if (_drawingMode == DrawingMode.rectangle &&
_currentDrawing != null) {
@@ -355,6 +457,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
} else if (drawing is RectangleDrawing) {
return RectangleDrawing(
@@ -369,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
return drawing;
@@ -383,9 +489,27 @@ class DrawingProvider with ChangeNotifier {
}
}
List<Map<String, dynamic>> exportDrawingsJson() {
return _drawings.map((drawing) => drawing.toJson()).toList();
}
Future<void> replaceDrawingsFromJson(
List<Map<String, dynamic>> jsonList,
) async {
_drawings
..clear()
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
await _saveDrawings();
notifyListeners();
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
/// Get all unshared drawings (local drawings not yet sent)
List<MapDrawing> getUnsharedDrawings() {
return _drawings.where((d) => !d.isShared && !d.isReceived).toList();
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
}
/// Mark a drawing as shared
@@ -407,6 +531,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
} else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing(
@@ -421,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
@@ -448,6 +576,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: !drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
} else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing(
@@ -462,6 +592,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: !drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
/// Message delivery tracking helper
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
/// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {};
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
Completer<void>? _slotFreedCompleter;
/// Track a pending message ID before sending
///
/// This is called BEFORE sending the message. When RESP_CODE_SENT
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
_messageIdToAckTag.remove(messageId);
}
_ackTagTimestamps.remove(ackCode);
_notifySlotFreed();
}
/// Remove ACK tag mapping by message ID
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
if (ackTag != null) {
_ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag);
_notifySlotFreed();
}
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
_ackTagToMessageId.clear();
_messageIdToAckTag.clear();
_ackTagTimestamps.clear();
_notifySlotFreed();
}
/// Get count of pending ACK tags
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7;
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
///
/// Returns immediately if not at the rate limit.
Future<void> waitForSlot({
Duration timeout = const Duration(milliseconds: 500),
}) async {
if (!shouldRateLimit) return;
_slotFreedCompleter ??= Completer<void>();
await _slotFreedCompleter!.future.timeout(
timeout,
onTimeout: () {},
);
}
void _notifySlotFreed() {
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
_slotFreedCompleter!.complete();
}
_slotFreedCompleter = null;
}
/// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null;

View File

@@ -1,19 +1,18 @@
import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart';
import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
/// This helper class centralizes retry logic for direct messages.
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
/// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager {
// Track retry state for each message ID
@@ -21,10 +20,19 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
/// Max retry attempts when the contact has a known path.
/// Sequence: 2 direct attempts, flood, then last successful route.
static const int maxRetryAttemptsWithPath = 3;
/// No retries for flood-only contacts (no known path).
/// Value 0 means: don't retry at all, go straight to fallback/fail.
static const int maxRetryAttemptsFloodOnly = 0;
@Deprecated('Use maxRetryAttemptsForContact instead')
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
// Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
@@ -32,76 +40,65 @@ class MessageRetryManager {
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
/// Get backoff delay for the next retry attempt.
int getDelayForAttempt(int attempt) {
if (attempt < 0 || attempt >= _retryDelays.length) {
return _retryDelays.last;
}
return _timeouts[attempt];
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
static final math.Random _rng = math.Random();
/// Calculate a delivery-ACK timeout with random jitter.
///
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
/// The jitter prevents collision when multiple messages are in flight.
int calculateAckTimeoutMs({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
int baseTimeout;
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
return suggestedTimeoutMs;
baseTimeout = suggestedTimeoutMs;
} else {
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
if (hopCount < 0) {
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
} else {
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
}
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
// Add random jitter: 500-2000ms to avoid collision
final jitterMs = 500 + _rng.nextInt(1501);
return baseTimeout + jitterMs;
}
/// Max attempts for a given contact based on whether it has a known path.
static int maxRetryAttemptsForContact(Contact? contact) {
final hasPath = contact?.routeHasPath ?? false;
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.routeHasPath;
return message.retryAttempt < maxRetryAttemptsForContact(contact);
}
/// Check if should fall back to flood mode
/// Whether this is the last retry attempt.
/// When true, the caller should reset the path to force flood mode.
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts with direct mode
/// - Contact HAS a learned path (so direct mode was used)
/// - Hasn't already used flood fallback
///
/// IMPORTANT: Only contacts WITH paths need flood fallback.
/// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
contact.routeHasPath &&
!message.usedFloodFallback;
/// Note: called AFTER retryAttempt has been incremented, so we compare
/// directly against maxAttempts (not +1).
bool isLastAttempt(Message message, Contact contact) {
final maxAttempts = maxRetryAttemptsForContact(contact);
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
}
/// Track a retry attempt for a message

View File

@@ -9,6 +9,11 @@ typedef RawPacketSender =
required Uint8List payload,
});
/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of
/// raw packets with `ERROR: Table full` when fragments are blasted too quickly,
/// so we space them out to keep the radio's raw transmit queue from overflowing.
const Duration _interFragmentDelay = Duration(milliseconds: 350);
Future<bool> serveCachedSessionFragments<T>({
required String providerLabel,
required String sessionId,
@@ -19,6 +24,7 @@ Future<bool> serveCachedSessionFragments<T>({
required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket,
Set<int>? requestedIndices,
Duration interFragmentDelay = _interFragmentDelay,
}) async {
if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
@@ -38,12 +44,6 @@ Future<bool> serveCachedSessionFragments<T>({
);
return false;
}
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
);
return false;
}
if (requester.outPath.isEmpty) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
@@ -62,9 +62,12 @@ Future<bool> serveCachedSessionFragments<T>({
continue;
}
try {
if (servedCount > 0 && interFragmentDelay > Duration.zero) {
await Future<void>.delayed(interFragmentDelay);
}
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: encodeBinary(fragment),
);
servedCount++;

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart';
@@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier {
_restore();
}
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
// ── Accessors ────────────────────────────────────────────────────────────
ImageSession? session(String sessionId) => _sessions[sessionId];
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
// ── Persistence ──────────────────────────────────────────────────────────
Future<void> clearAll() async {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
_resetInMemoryState();
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
await prefs.remove(_scopedStorageKey());
} catch (e) {
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
}
}
Future<void> reloadProfileScopedState() async {
_resetInMemoryState();
await _restore();
notifyListeners();
}
void _evictExpiredOutgoing() {
final now = DateTime.now();
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
@@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier {
)
.toList(),
};
await prefs.setString(_storageKey, jsonEncode(payload));
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
} catch (e) {
debugPrint('❌ [ImageProvider] Failed to persist: $e');
}
@@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier {
Future<void> _restore() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
final raw = prefs.getString(_scopedStorageKey());
if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier {
debugPrint('❌ [ImageProvider] Failed to restore: $e');
}
}
void _resetInMemoryState() {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
}
}
class _OutgoingSession {

View File

@@ -1,24 +1,47 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:image_picker/image_picker.dart';
import 'package:latlong2/latlong.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/custom_map_config.dart';
import '../models/location_trail.dart';
import '../models/map_coordinate_space.dart';
import '../models/map_drawing.dart';
import '../models/sar_marker.dart';
import '../services/profiles_feature_service.dart';
import '../utils/custom_map_id.dart';
class MapProvider with ChangeNotifier {
static const String _customMapConfigKey = 'custom_map_config_v1';
static const String _customMapModeKey = 'custom_map_mode_v1';
static const String _customMapsDirName = 'custom_maps';
MapProvider() {
unawaited(_loadInitialState());
}
final ImagePicker _imagePicker = ImagePicker();
LatLng? _targetLocation;
LatLngBounds? _targetBounds;
double? _targetZoom;
bool _shouldAnimate = false;
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
String? _targetMapId;
// Track which contact paths are currently visible
final Set<String> _visibleContactPaths = {};
// Location trail tracking
LocationTrail? _currentTrail;
bool _isTrailVisible = true;
bool _isTrailRecordingEnabled = true;
final List<LocationTrail> _trailHistory = [];
// WMS overlay toggles
bool _showCadastralOverlay = false;
bool _showForestRoadsOverlay = false;
bool _showHikingTrailsOverlay = false;
@@ -31,28 +54,28 @@ class MapProvider with ChangeNotifier {
bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false;
// Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails
bool _hideRepeatersOnMap = false;
// Imported trail (from GPX)
LocationTrail? _importedTrail;
// Download area selection
bool _isSelectingDownloadArea = false;
LatLngBounds? _downloadAreaBounds;
CustomMapConfig? _customMapConfig;
bool _isUsingCustomMap = false;
LatLng? get targetLocation => _targetLocation;
LatLngBounds? get targetBounds => _targetBounds;
double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
// Trail getters
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
String? get targetMapId => _targetMapId;
LocationTrail? get currentTrail => _currentTrail;
bool get isTrailVisible => _isTrailVisible;
bool get isTrailRecordingEnabled => _isTrailRecordingEnabled;
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
bool get isTrailActive => _currentTrail?.isActive ?? false;
// WMS overlay getters
bool get showCadastralOverlay => _showCadastralOverlay;
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
@@ -65,68 +88,138 @@ class MapProvider with ChangeNotifier {
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail;
// Download area getters
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
CustomMapConfig? get customMapConfig => _customMapConfig;
bool get hasCustomMap => _customMapConfig != null;
bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null;
bool get shouldHideGpsData => isUsingCustomMap;
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
Future<void> reloadProfileScopedState() async {
await _loadInitialState();
}
bool matchesActiveCustomMap(String? mapId) {
return hasCustomMap &&
normalizeCustomMapId(_customMapConfig!.mapId) ==
normalizeCustomMapId(mapId);
}
void navigateToLocation({
required LatLng location,
double zoom = 15.0,
bool animate = true,
}) {
if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetLocation = location;
_targetBounds = null;
_targetZoom = zoom;
_shouldAnimate = animate;
_targetCoordinateSpace = MapCoordinateSpace.geo;
_targetMapId = null;
notifyListeners();
}
String? navigateToMapPoint({
required LatLng point,
required MapCoordinateSpace coordinateSpace,
String? mapId,
double zoom = 15.0,
bool animate = true,
}) {
if (coordinateSpace == MapCoordinateSpace.customMap) {
if (!matchesActiveCustomMap(mapId)) {
return 'Load the matching custom map to view this item.';
}
if (!_isUsingCustomMap) {
_isUsingCustomMap = true;
unawaited(_saveCustomMapState());
}
} else if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetLocation = point;
_targetBounds = null;
_targetZoom = zoom;
_shouldAnimate = animate;
_targetCoordinateSpace = coordinateSpace;
_targetMapId = mapId;
notifyListeners();
return null;
}
String? navigateToBounds({
required LatLngBounds bounds,
required MapCoordinateSpace coordinateSpace,
String? mapId,
bool animate = true,
}) {
if (coordinateSpace == MapCoordinateSpace.customMap) {
if (!matchesActiveCustomMap(mapId)) {
return 'Load the matching custom map to view this item.';
}
if (!_isUsingCustomMap) {
_isUsingCustomMap = true;
unawaited(_saveCustomMapState());
}
} else if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetBounds = bounds;
_targetLocation = bounds.center;
_targetZoom = null;
_shouldAnimate = animate;
_targetCoordinateSpace = coordinateSpace;
_targetMapId = mapId;
notifyListeners();
return null;
}
void clearNavigation() {
_targetLocation = null;
_targetBounds = null;
_targetZoom = null;
_shouldAnimate = false;
// Don't notify listeners to avoid rebuilds
_targetCoordinateSpace = MapCoordinateSpace.geo;
_targetMapId = null;
}
/// Navigate to a drawing by its ID
void navigateToDrawing(String drawingId, dynamic drawingProvider) {
debugPrint('🗺️ [MapProvider] navigateToDrawing called with ID: $drawingId');
// Find the drawing in the provider
final drawings = drawingProvider.drawings as List;
debugPrint('🗺️ [MapProvider] Total drawings in provider: ${drawings.length}');
final drawing = drawings.cast<dynamic>().firstWhere(
(d) => d.id == drawingId,
orElse: () => null,
);
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
if (drawing == null) {
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found');
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
return;
return 'Drawing not found.';
}
// Use MapDrawing's built-in getCenter and getBounds methods
final center = drawing.getCenter();
final bounds = drawing.getBounds();
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
if (!matchesActiveCustomMap(drawing.mapId)) {
return 'Load the matching custom map to view this drawing.';
}
return navigateToBounds(
bounds: drawing.getBounds(),
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
// Calculate appropriate zoom level based on bounds
// For larger drawings, use lower zoom to fit the whole drawing
// For smaller drawings, use higher zoom for better detail
final bounds = drawing.getBounds();
final latDiff = (bounds.north - bounds.south).abs();
final lonDiff = (bounds.east - bounds.west).abs();
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
// Zoom scale: smaller drawings get higher zoom
// 0.001 degrees (~100m) -> zoom 17
// 0.005 degrees (~500m) -> zoom 16
// 0.01 degrees (~1km) -> zoom 15
// 0.05 degrees (~5km) -> zoom 13
// 0.1 degrees (~10km) -> zoom 12
double zoom = 15.0;
if (maxDiff < 0.001) {
zoom = 17.0;
@@ -142,9 +235,125 @@ class MapProvider with ChangeNotifier {
zoom = 10.0;
}
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle';
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom');
navigateToLocation(location: center, zoom: zoom, animate: true);
navigateToLocation(
location: drawing.getCenter(),
zoom: zoom,
animate: true,
);
return null;
}
String? navigateToSarMarker(SarMarker marker) {
if (marker.coordinateSpace == MapCoordinateSpace.customMap) {
return navigateToMapPoint(
point: marker.location,
coordinateSpace: MapCoordinateSpace.customMap,
mapId: marker.mapId,
);
}
navigateToLocation(location: marker.location, zoom: 15.0, animate: true);
return null;
}
Future<bool> loadCustomMapFromGallery() async {
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
if (picked == null) {
return false;
}
final bytes = await picked.readAsBytes();
await setCustomMapImage(bytes: bytes, displayName: picked.name);
return true;
}
Future<void> replaceCustomMap() async {
await loadCustomMapFromGallery();
}
Future<void> setCustomMapImage({
required Uint8List bytes,
required String displayName,
}) async {
final dimensions = await _decodeImageSize(bytes);
final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!;
final documentsDir = await getApplicationDocumentsDirectory();
final dir = Directory('${documentsDir.path}/$_customMapsDirName');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final extension = _normalizedExtension(displayName);
final nextPath = '${dir.path}/custom_map_$mapId.$extension';
final nextFile = File(nextPath);
await nextFile.writeAsBytes(bytes, flush: true);
final previousPath = _customMapConfig?.filePath;
_customMapConfig = CustomMapConfig(
filePath: nextPath,
displayName: displayName,
mapId: mapId,
imageWidth: dimensions.$1,
imageHeight: dimensions.$2,
);
_isUsingCustomMap = true;
await _saveCustomMapState();
if (previousPath != null && previousPath != nextPath) {
unawaited(_deleteFileIfExists(previousPath));
}
notifyListeners();
}
Future<void> setCustomMapCalibration({
required LatLng pointA,
required LatLng pointB,
required double metersPerPixel,
}) async {
if (_customMapConfig == null) return;
_customMapConfig = _customMapConfig!.copyWith(
calibrationPointA: pointA,
calibrationPointB: pointB,
metersPerPixel: metersPerPixel,
);
await _saveCustomMapState();
notifyListeners();
}
Future<void> clearCustomMapCalibration() async {
if (_customMapConfig == null) return;
_customMapConfig = _customMapConfig!.copyWith(
clearMetersPerPixel: true,
clearCalibrationPointA: true,
clearCalibrationPointB: true,
);
await _saveCustomMapState();
notifyListeners();
}
Future<void> removeCustomMap() async {
final filePath = _customMapConfig?.filePath;
_customMapConfig = null;
_isUsingCustomMap = false;
clearNavigation();
await _saveCustomMapState();
if (filePath != null) {
await _deleteFileIfExists(filePath);
}
notifyListeners();
}
Future<void> enterCustomMapMode() async {
if (!hasCustomMap || _isUsingCustomMap) return;
_isUsingCustomMap = true;
await _saveCustomMapState();
notifyListeners();
}
Future<void> exitCustomMapMode() async {
if (!_isUsingCustomMap) return;
_isUsingCustomMap = false;
await _saveCustomMapState();
notifyListeners();
}
void updateZoom(double zoom) {
@@ -152,37 +361,7 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
/// Toggle path visibility for a contact
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
} else {
_visibleContactPaths.add(publicKeyHex);
}
notifyListeners();
}
/// Check if a contact's path is visible
bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex);
}
/// Hide all contact paths
void hideAllPaths() {
_visibleContactPaths.clear();
notifyListeners();
}
/// Show path for specific contact (hide all others)
void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex);
notifyListeners();
}
/// Start a new location trail
void startTrail() {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
}
@@ -195,22 +374,26 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
/// Add a point to the current trail
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
if (!_isTrailRecordingEnabled) {
return;
}
if (_currentTrail == null || !_currentTrail!.isActive) {
startTrail();
}
_currentTrail!.addPoint(TrailPoint(
position: position,
timestamp: DateTime.now(),
accuracy: accuracy,
speed: speed,
));
_currentTrail!.addPoint(
TrailPoint(
position: position,
timestamp: DateTime.now(),
accuracy: accuracy,
speed: speed,
),
);
notifyListeners();
}
/// End the current trail
void endTrail() {
if (_currentTrail != null) {
_currentTrail!.isActive = false;
@@ -223,13 +406,29 @@ class MapProvider with ChangeNotifier {
}
}
/// Toggle trail visibility
void toggleTrailVisibility() {
_isTrailVisible = !_isTrailVisible;
notifyListeners();
}
/// Clear the current trail
void setTrailRecordingEnabled(bool enabled) {
if (_isTrailRecordingEnabled == enabled) {
return;
}
_isTrailRecordingEnabled = enabled;
if (!enabled) {
if (_currentTrail != null) {
endTrail();
} else {
notifyListeners();
}
return;
}
notifyListeners();
}
void clearCurrentTrail() {
if (_currentTrail != null) {
_currentTrail = null;
@@ -237,197 +436,347 @@ class MapProvider with ChangeNotifier {
}
}
/// Clear all trail history
void clearAllTrails() {
_currentTrail = null;
_trailHistory.clear();
notifyListeners();
}
/// Get total trail distance in meters
double get totalTrailDistance {
if (_currentTrail == null) return 0;
return _currentTrail!.totalDistance;
}
/// Get trail duration
Duration get trailDuration {
if (_currentTrail == null) return Duration.zero;
return _currentTrail!.duration;
}
/// Toggle cadastral parcels overlay
Future<void> toggleCadastralOverlay() async {
_showCadastralOverlay = !_showCadastralOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle forest roads overlay
Future<void> toggleForestRoadsOverlay() async {
_showForestRoadsOverlay = !_showForestRoadsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle hiking trails overlay
Future<void> toggleHikingTrailsOverlay() async {
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle main roads overlay
Future<void> toggleMainRoadsOverlay() async {
_showMainRoadsOverlay = !_showMainRoadsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle house numbers overlay
Future<void> toggleHouseNumbersOverlay() async {
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle fire hazard zones overlay
Future<void> toggleFireHazardZonesOverlay() async {
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle historical fires overlay
Future<void> toggleHistoricalFiresOverlay() async {
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle firebreaks overlay
Future<void> toggleFirebreaksOverlay() async {
_showFirebreaksOverlay = !_showFirebreaksOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle Kras fire zones overlay
Future<void> toggleKrasFireZonesOverlay() async {
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle place names overlay
Future<void> togglePlaceNamesOverlay() async {
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle municipality borders overlay
Future<void> toggleMunicipalityBordersOverlay() async {
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Load overlay state from SharedPreferences
Future<void> loadOverlayState() async {
final prefs = await SharedPreferences.getInstance();
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false;
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false;
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false;
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false;
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false;
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false;
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false;
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false;
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_overlay') ?? false;
_showCadastralOverlay =
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
_showForestRoadsOverlay =
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
_showHikingTrailsOverlay =
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
_showMainRoadsOverlay =
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
_showHouseNumbersOverlay =
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
_showFireHazardZonesOverlay =
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
false;
_showHistoricalFiresOverlay =
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
_showFirebreaksOverlay =
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
_showKrasFireZonesOverlay =
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
_showPlaceNamesOverlay =
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
_showMunicipalityBordersOverlay =
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
false;
notifyListeners();
}
/// Save overlay state to SharedPreferences
Future<void> _loadInitialState() async {
await Future.wait([
loadOverlayState(),
loadRepeaterVisibilitySettings(),
_loadCustomMapState(),
_removeDeprecatedSettings(),
]);
}
Future<void> _removeDeprecatedSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
}
Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay);
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay);
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay);
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay);
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay);
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay);
await prefs.setBool(
_scopedKey('map_show_cadastral_overlay'),
_showCadastralOverlay,
);
await prefs.setBool(
_scopedKey('map_show_forest_roads_overlay'),
_showForestRoadsOverlay,
);
await prefs.setBool(
_scopedKey('map_show_hiking_trails_overlay'),
_showHikingTrailsOverlay,
);
await prefs.setBool(
_scopedKey('map_show_main_roads_overlay'),
_showMainRoadsOverlay,
);
await prefs.setBool(
_scopedKey('map_show_house_numbers_overlay'),
_showHouseNumbersOverlay,
);
await prefs.setBool(
_scopedKey('map_show_fire_hazard_zones_overlay'),
_showFireHazardZonesOverlay,
);
await prefs.setBool(
_scopedKey('map_show_historical_fires_overlay'),
_showHistoricalFiresOverlay,
);
await prefs.setBool(
_scopedKey('map_show_firebreaks_overlay'),
_showFirebreaksOverlay,
);
await prefs.setBool(
_scopedKey('map_show_kras_fire_zones_overlay'),
_showKrasFireZonesOverlay,
);
await prefs.setBool(
_scopedKey('map_show_place_names_overlay'),
_showPlaceNamesOverlay,
);
await prefs.setBool(
_scopedKey('map_show_municipality_borders_overlay'),
_showMunicipalityBordersOverlay,
);
}
/// Toggle all contact trails on/off
Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails;
Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide;
notifyListeners();
await _saveTrailSettings();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
}
/// Load trail settings from SharedPreferences
Future<void> loadTrailSettings() async {
Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all)
_hideRepeatersOnMap =
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
notifyListeners();
}
/// Save trail settings to SharedPreferences
Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
}
/// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) {
_importedTrail = trail;
notifyListeners();
}
/// Clear imported trail
void clearImportedTrail() {
_importedTrail = null;
notifyListeners();
}
/// Replace current trail with imported trail
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
}
// Set imported trail as current trail
_currentTrail = importedTrail;
_isTrailVisible = true;
notifyListeners();
}
/// Enter download area selection mode with initial bounds
void enterDownloadAreaMode(LatLngBounds initialBounds) {
_isSelectingDownloadArea = true;
_downloadAreaBounds = initialBounds;
notifyListeners();
}
/// Exit download area selection mode
void exitDownloadAreaMode() {
_isSelectingDownloadArea = false;
_downloadAreaBounds = null;
notifyListeners();
}
/// Update the download area bounds (while dragging/resizing)
void updateDownloadAreaBounds(LatLngBounds bounds) {
_downloadAreaBounds = bounds;
notifyListeners();
}
Future<void> _loadCustomMapState() async {
final prefs = await SharedPreferences.getInstance();
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
if (configJson != null && configJson.isNotEmpty) {
final decoded = jsonDecode(configJson);
if (decoded is Map<String, dynamic>) {
final config = CustomMapConfig.fromJson(decoded);
if (config != null && await File(config.filePath).exists()) {
_customMapConfig = config;
} else {
_customMapConfig = null;
}
}
} else {
_customMapConfig = null;
}
_isUsingCustomMap =
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
_customMapConfig != null;
notifyListeners();
}
Future<void> _saveCustomMapState() async {
final prefs = await SharedPreferences.getInstance();
if (_customMapConfig == null) {
await prefs.remove(_scopedKey(_customMapConfigKey));
} else {
await prefs.setString(
_scopedKey(_customMapConfigKey),
jsonEncode(_customMapConfig!.toJson()),
);
}
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
}
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return (frame.image.width, frame.image.height);
}
String _normalizedExtension(String displayName) {
final dotIndex = displayName.lastIndexOf('.');
if (dotIndex == -1 || dotIndex == displayName.length - 1) {
return 'png';
}
return displayName.substring(dotIndex + 1).toLowerCase();
}
Future<void> _deleteFileIfExists(String path) async {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
Map<String, dynamic> exportWorkspaceJson() {
return {
'currentTrail': _currentTrail?.toJson(),
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
'importedTrail': _importedTrail?.toJson(),
'isTrailVisible': _isTrailVisible,
'isTrailRecordingEnabled': _isTrailRecordingEnabled,
'showCadastralOverlay': _showCadastralOverlay,
'showForestRoadsOverlay': _showForestRoadsOverlay,
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
'showMainRoadsOverlay': _showMainRoadsOverlay,
'showHouseNumbersOverlay': _showHouseNumbersOverlay,
'showFireHazardZonesOverlay': _showFireHazardZonesOverlay,
'showHistoricalFiresOverlay': _showHistoricalFiresOverlay,
'showFirebreaksOverlay': _showFirebreaksOverlay,
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
'hideRepeatersOnMap': _hideRepeatersOnMap,
};
}
void applyWorkspaceJson(Map<String, dynamic> json) {
_currentTrail = json['currentTrail'] is Map<String, dynamic>
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
: null;
_trailHistory
..clear()
..addAll(
(json['trailHistory'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(LocationTrail.fromJson),
);
_importedTrail = json['importedTrail'] is Map<String, dynamic>
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
: null;
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
_isTrailRecordingEnabled =
json['isTrailRecordingEnabled'] as bool? ?? true;
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
_showHikingTrailsOverlay =
json['showHikingTrailsOverlay'] as bool? ?? false;
_showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false;
_showHouseNumbersOverlay =
json['showHouseNumbersOverlay'] as bool? ?? false;
_showFireHazardZonesOverlay =
json['showFireHazardZonesOverlay'] as bool? ?? false;
_showHistoricalFiresOverlay =
json['showHistoricalFiresOverlay'] as bool? ?? false;
_showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false;
_showKrasFireZonesOverlay =
json['showKrasFireZonesOverlay'] as bool? ?? false;
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
_showMunicipalityBordersOverlay =
json['showMunicipalityBordersOverlay'] as bool? ?? false;
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
notifyListeners();
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
}

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