Compare commits
272 Commits
v2026.0308
...
f7b6143a57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b6143a57 | ||
|
|
3c08e150ef | ||
|
|
3283d4ca20 | ||
|
|
bdbceece75 | ||
|
|
365bd76cbc | ||
|
|
ba27843166 | ||
|
|
3e1113035d | ||
|
|
89359c5e34 | ||
|
|
0406b59d3b | ||
|
|
c176ed0775 | ||
|
|
6062f91c2d | ||
|
|
e1400cf1ec | ||
|
|
b040a8bd3a | ||
|
|
c847bd6465 | ||
|
|
132a576785 | ||
|
|
9b8b1bf473 | ||
|
|
0008eff1b8 | ||
|
|
2fe125d6ba | ||
|
|
5834454087 | ||
|
|
73e78691ed | ||
|
|
d067095345 | ||
|
|
0b463f6454 | ||
|
|
a472b0c05c | ||
|
|
4181b33da1 | ||
|
|
f9dfdc2340 | ||
|
|
b333b3737e | ||
|
|
7245d76e17 | ||
|
|
7a76ba97f8 | ||
|
|
d00198c829 | ||
|
|
e6cd59e96f | ||
|
|
b863cda139 | ||
|
|
4bd7f36b98 | ||
|
|
44a668df93 | ||
|
|
e5514b4ef9 | ||
|
|
d92e52c5c2 | ||
|
|
daa88fb471 | ||
|
|
c182813c35 | ||
|
|
81944bfd3c | ||
|
|
43eb857663 | ||
|
|
faa396769e | ||
|
|
1a35b790a7 | ||
|
|
573921e002 | ||
|
|
14a41b210e | ||
|
|
56a7fa2fc6 | ||
|
|
7f4b4c4560 | ||
|
|
7f7f4aa975 | ||
|
|
1baab06627 | ||
|
|
5320fa1a2b | ||
|
|
512b64db9f | ||
|
|
4690ceead1 | ||
|
|
d431a79aa8 | ||
|
|
d66bbca82c | ||
|
|
d2fee94678 | ||
|
|
61a856e84c | ||
|
|
5feb07aad7 | ||
|
|
59931f402a | ||
|
|
0cd563063a | ||
|
|
9244368b49 | ||
|
|
c089db5c62 | ||
|
|
7c1bc3b84f | ||
|
|
724f4b38e8 | ||
|
|
caefbdbcef | ||
|
|
79f8f8dd04 | ||
|
|
e4eb40cd34 | ||
|
|
7c9dfd6f63 | ||
|
|
3e8d4c2d11 | ||
|
|
3a28acac2d | ||
|
|
5ee03d4b2f | ||
|
|
3dfdbd7700 | ||
|
|
043faea06a | ||
|
|
05ddb6841a | ||
|
|
3392e5f9c1 | ||
|
|
7df750c8d5 | ||
|
|
e09d418084 | ||
|
|
96aeec1301 | ||
|
|
6978f24440 | ||
|
|
ba0f1fc141 | ||
|
|
28a9235168 | ||
|
|
3e3c9a34d5 | ||
|
|
e85dccea35 | ||
|
|
019224d955 | ||
|
|
4c048a5a50 | ||
|
|
2b42e7dd15 | ||
|
|
7ea6334f02 | ||
|
|
6f0a66dc68 | ||
|
|
a26ae96acd | ||
|
|
a6dc137e51 | ||
|
|
c2501442e3 | ||
|
|
71eac21df9 | ||
|
|
c299349b6a | ||
|
|
ab05f13de9 | ||
|
|
0e61dc5bf1 | ||
|
|
d45924d7b4 | ||
|
|
a24e699e22 | ||
|
|
7486837ad0 | ||
|
|
059125c714 | ||
|
|
9279ff2158 | ||
|
|
dc297b0b9f | ||
|
|
2d24481aba | ||
|
|
20a1df94eb | ||
|
|
5cc81bef65 | ||
|
|
6145462555 | ||
|
|
721fb7e7a2 | ||
|
|
5a58e45e13 | ||
|
|
de4258c0a4 | ||
|
|
d8cb35c595 | ||
|
|
8da69705ee | ||
|
|
e474168ba8 | ||
|
|
03dba8b3ce | ||
|
|
bc9e37bd22 | ||
|
|
a32d5d2c85 | ||
|
|
e683ab933f | ||
|
|
f286b0afd1 | ||
|
|
68ec72534d | ||
|
|
6af8b62d9b | ||
|
|
01fbb7de6c | ||
|
|
9303d24b69 | ||
|
|
140088fe2f | ||
|
|
67d8e1c906 | ||
|
|
29b75a3155 | ||
|
|
87cb890877 | ||
|
|
095458ece4 | ||
|
|
5659242672 | ||
|
|
3da4ba8fb2 | ||
|
|
89dc1f2862 | ||
|
|
251932bb47 | ||
|
|
b48139964a | ||
|
|
d29b85feb8 | ||
|
|
c01ef7a059 | ||
|
|
fa2b586ab7 | ||
|
|
bbd5d108cb | ||
|
|
e17f24a6bc | ||
|
|
372224a62c | ||
|
|
e4c8709ee2 | ||
|
|
3c469b62d0 | ||
|
|
1e97ba9831 | ||
|
|
6187067017 | ||
|
|
cd165d2372 | ||
|
|
59f6df4260 | ||
|
|
464fc5e715 | ||
|
|
a76ee4f8f6 | ||
|
|
c884f81ca5 | ||
|
|
993563f059 | ||
|
|
cfd233d38e | ||
|
|
9b14ff4e1a | ||
|
|
84cd8eec00 | ||
|
|
434d3df53e | ||
|
|
1fe260b95e | ||
|
|
d3d53995a6 | ||
|
|
efbddc8831 | ||
|
|
0e3acfa4ca | ||
|
|
450b8382e8 | ||
|
|
420690d683 | ||
|
|
fedd7b9a2b | ||
|
|
c82e0c3452 | ||
|
|
a55cfffa59 | ||
|
|
2bb5b353bd | ||
|
|
991421d097 | ||
|
|
1dcd85f178 | ||
|
|
5d2f61749e | ||
|
|
69377d90a4 | ||
|
|
3e559a4f4e | ||
|
|
a124e3d9f2 | ||
|
|
3bf4971279 | ||
|
|
e355f28ca9 | ||
|
|
7a9be3eb76 | ||
|
|
650f3c66aa | ||
|
|
df993cd176 | ||
|
|
feb3145854 | ||
|
|
da40acf950 | ||
|
|
66eb32bf44 | ||
|
|
c2e0fa952e | ||
|
|
4557fb4b9e | ||
|
|
c05de2cf0a | ||
|
|
c5145c2fca | ||
|
|
e502bc23b7 | ||
|
|
62bf33c9d1 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d | ||
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 | ||
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac | ||
|
|
99bb446ee9 | ||
|
|
088f3d37ef | ||
|
|
7f200e5334 | ||
|
|
ae16417886 | ||
|
|
467371946d | ||
|
|
2d23328ad0 | ||
|
|
0dfb84bf4e | ||
|
|
193a193e8a | ||
|
|
c1d1ec7d0c | ||
|
|
b415b63b4b | ||
|
|
818b9dbfb2 | ||
|
|
8085b89c07 | ||
|
|
86771a3c55 | ||
|
|
4b668ac2e9 | ||
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb | ||
|
|
d113f7a881 | ||
|
|
2ab8681ac4 | ||
|
|
de5fb1e164 | ||
|
|
dae7e67a8a | ||
|
|
d51924021f | ||
|
|
0f91292fd7 | ||
|
|
b471ab0990 | ||
|
|
771cddb80d | ||
|
|
bfd15065f0 | ||
|
|
f72db76538 | ||
|
|
a96f96ca4c | ||
|
|
9f62055eef | ||
|
|
6b2f18130c | ||
|
|
922b40b755 | ||
|
|
17d7c43745 | ||
|
|
e026c1dbbd | ||
|
|
9ff24c1fcb | ||
|
|
948a22c4d2 | ||
|
|
aa0777d4a3 |
95
.github/workflows/build-artifacts.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
@@ -40,6 +40,17 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
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
|
- name: Cache Android NDK
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -52,22 +63,76 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
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
|
- name: Build APK
|
||||||
run: flutter build apk --release
|
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: |
|
run: |
|
||||||
RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}"
|
||||||
TAG="${RAW_TAG//\//-}"
|
TAG="${RAW_TAG//\//-}"
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk"
|
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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: release-android
|
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
|
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:
|
build-linux:
|
||||||
name: Build Linux App
|
name: Build Linux App
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -95,6 +160,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
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
|
- name: Enable Linux desktop
|
||||||
run: flutter config --enable-linux-desktop
|
run: flutter config --enable-linux-desktop
|
||||||
@@ -132,6 +200,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
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
|
- name: Enable Windows desktop
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -174,6 +245,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
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
|
- name: Enable macOS desktop
|
||||||
run: flutter config --enable-macos-desktop
|
run: flutter config --enable-macos-desktop
|
||||||
@@ -181,6 +255,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Install macOS native build tools
|
||||||
|
run: brew install automake libtool
|
||||||
|
|
||||||
- name: Build macOS release
|
- name: Build macOS release
|
||||||
run: flutter build macos --release
|
run: flutter build macos --release
|
||||||
|
|
||||||
@@ -216,6 +293,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
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
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
@@ -272,6 +352,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
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
|
- name: Enable web
|
||||||
run: flutter config --enable-web
|
run: flutter config --enable-web
|
||||||
@@ -280,7 +363,7 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build web release
|
- 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
|
- name: Upload pages artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
@@ -327,6 +410,7 @@ jobs:
|
|||||||
body: |
|
body: |
|
||||||
Release assets are published for all supported build targets:
|
Release assets are published for all supported build targets:
|
||||||
- Android (`.apk`)
|
- Android (`.apk`)
|
||||||
|
- Google Play (`.aab`)
|
||||||
- Linux (`.tar.gz`)
|
- Linux (`.tar.gz`)
|
||||||
- macOS (`.dmg`)
|
- macOS (`.dmg`)
|
||||||
- Windows (`.zip`)
|
- Windows (`.zip`)
|
||||||
@@ -341,6 +425,7 @@ jobs:
|
|||||||
4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators).
|
4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators).
|
||||||
files: |
|
files: |
|
||||||
dist/*.apk
|
dist/*.apk
|
||||||
|
dist/*.aab
|
||||||
dist/*.tar.gz
|
dist/*.tar.gz
|
||||||
dist/*.zip
|
dist/*.zip
|
||||||
dist/*.dmg
|
dist/*.dmg
|
||||||
|
|||||||
3
.github/workflows/build-multiplatform.yml
vendored
@@ -27,6 +27,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
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
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|||||||
10
.gitignore
vendored
@@ -28,6 +28,7 @@ migrate_working_dir/
|
|||||||
**/ios/Flutter/.last_build_id
|
**/ios/Flutter/.last_build_id
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
|
pubspec_overrides.yaml
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
@@ -72,14 +73,23 @@ GoogleService-Info.plist
|
|||||||
**/fastlane/.env
|
**/fastlane/.env
|
||||||
**/fastlane/.env.*
|
**/fastlane/.env.*
|
||||||
fastlane/README.md
|
fastlane/README.md
|
||||||
|
ios/.bundle/
|
||||||
|
ios/vendor/
|
||||||
|
|
||||||
# Play Store release assets & scripts
|
# Play Store release assets & scripts
|
||||||
play-store/
|
play-store/
|
||||||
create_feature_graphic.py
|
create_feature_graphic.py
|
||||||
|
|
||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
|
.devenv/
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
worker/node_modules/
|
||||||
|
worker/.wrangler/
|
||||||
|
worker/.bun/
|
||||||
|
worker/.astro/
|
||||||
|
worker/dist/
|
||||||
|
|
||||||
# Claude Code local settings (permissions, personal config)
|
# Claude Code local settings (permissions, personal config)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
25
CHANGELOG.md
Normal 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`
|
||||||
3
Makefile
@@ -135,6 +135,9 @@ clean: ## Clean build artifacts
|
|||||||
flutter clean
|
flutter clean
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
|
|
||||||
|
build-web: ## Build web with WebAssembly
|
||||||
|
flutter build web --wasm --release
|
||||||
|
|
||||||
# Build for all platforms
|
# Build for all platforms
|
||||||
build-all: bump ## Build for Android and iOS
|
build-all: bump ## Build for Android and iOS
|
||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
|
|||||||
20
README.md
@@ -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.
|
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.
|
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
|
## Highlights
|
||||||
|
|
||||||
@@ -75,6 +75,24 @@ It uses the MeshCore protocol over LoRa for long-range, infrastructure-free comm
|
|||||||
- Incident command and coordination roles
|
- Incident command and coordination roles
|
||||||
- Operators working in weak/no cellular coverage
|
- 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)
|
## Permissions (App Use)
|
||||||
|
|
||||||
- Bluetooth: mesh device communication
|
- Bluetooth: mesh device communication
|
||||||
|
|||||||
4
android/Gemfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
||||||
|
gem "ostruct"
|
||||||
@@ -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 -->
|
<!-- Bluetooth permissions -->
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
@@ -62,6 +63,12 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ subprojects {
|
|||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
}
|
}
|
||||||
subprojects {
|
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")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,44 +16,72 @@
|
|||||||
default_platform(:android)
|
default_platform(:android)
|
||||||
|
|
||||||
platform :android do
|
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"
|
desc "Runs all the tests"
|
||||||
lane :test do
|
lane :test do
|
||||||
gradle(task: "test")
|
gradle(task: "test")
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Build release APK"
|
desc "Build release APK"
|
||||||
lane :beta do
|
lane :direct_apk do
|
||||||
# Get the project root directory (two levels up from android/fastlane/)
|
sh(direct_apk_build_command)
|
||||||
project_root = File.expand_path("../..", __dir__)
|
|
||||||
|
|
||||||
# Build APK with Flutter
|
UI.success("APK built successfully at: #{direct_apk_path}")
|
||||||
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}")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Deploy a new version to the Google Play"
|
desc "Build Play release AAB and upload to internal testing"
|
||||||
lane :deploy do
|
lane :internal do
|
||||||
# Get the project root directory (two levels up from android/fastlane/)
|
sh(play_build_command) unless skip_android_build?
|
||||||
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")
|
|
||||||
|
|
||||||
upload_to_play_store(
|
upload_to_play_store(
|
||||||
aab: aab_path,
|
aab: play_aab_path,
|
||||||
track: "internal"
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=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
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.9.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
128
docs/google-play-release.md
Normal 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.
|
||||||
116
integration_test/app_screenshots_test.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,49 +1,47 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.7)
|
CFPropertyList (3.0.9)
|
||||||
base64
|
abbrev (0.1.2)
|
||||||
nkf
|
addressable (2.9.0)
|
||||||
rexml
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
addressable (2.8.7)
|
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.3.2)
|
||||||
aws-partitions (1.1172.0)
|
aws-partitions (1.1109.0)
|
||||||
aws-sdk-core (3.233.0)
|
aws-sdk-core (3.224.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.113.0)
|
aws-sdk-kms (1.101.0)
|
||||||
aws-sdk-core (~> 3, >= 3.231.0)
|
aws-sdk-core (~> 3, >= 3.216.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.199.1)
|
aws-sdk-s3 (1.188.0)
|
||||||
aws-sdk-core (~> 3, >= 3.231.0)
|
aws-sdk-core (~> 3, >= 3.224.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.11.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bigdecimal (3.3.1)
|
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.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)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.112.0)
|
excon (0.109.0)
|
||||||
faraday (1.10.4)
|
faraday (1.10.5)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
@@ -55,25 +53,26 @@ GEM
|
|||||||
faraday-rack (~> 1.0)
|
faraday-rack (~> 1.0)
|
||||||
faraday-retry (~> 1.0)
|
faraday-retry (~> 1.0)
|
||||||
ruby2_keywords (>= 0.0.4)
|
ruby2_keywords (>= 0.0.4)
|
||||||
faraday-cookie_jar (0.0.7)
|
faraday-cookie_jar (0.0.8)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (~> 1.0.0)
|
http-cookie (>= 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.1)
|
faraday-em_synchrony (1.0.1)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.1.1)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (1.0.2)
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
faraday-patron (1.0.0)
|
faraday-patron (1.0.0)
|
||||||
faraday-rack (1.0.0)
|
faraday-rack (1.0.0)
|
||||||
faraday-retry (1.0.3)
|
faraday-retry (1.0.4)
|
||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.1)
|
||||||
fastlane (2.228.0)
|
fastlane (2.229.0)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.0)
|
||||||
@@ -81,6 +80,7 @@ GEM
|
|||||||
bundler (>= 1.12.0, < 3.0.0)
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
colored (~> 1.2)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
@@ -100,6 +100,7 @@ GEM
|
|||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3.0)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
@@ -114,8 +115,7 @@ GEM
|
|||||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
xcpretty (~> 0.4.1)
|
xcpretty (~> 0.4.1)
|
||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.1.0)
|
||||||
sysrandom (~> 1.0)
|
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.54.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
@@ -131,19 +131,19 @@ GEM
|
|||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.11.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-playcustomapp_v1 (0.13.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
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-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-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (1.6.0)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
google-cloud-errors (1.5.0)
|
google-cloud-errors (1.3.1)
|
||||||
google-cloud-storage (1.47.0)
|
google-cloud-storage (1.37.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
google-apis-storage_v1 (~> 0.31.0)
|
google-apis-storage_v1 (~> 0.1)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
@@ -159,42 +159,40 @@ GEM
|
|||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.15.1)
|
json (2.7.6)
|
||||||
jwt (2.10.2)
|
jwt (2.10.3)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.17.0)
|
multi_json (1.15.0)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
optparse (0.8.1)
|
||||||
optparse (0.6.0)
|
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (6.0.2)
|
public_suffix (5.1.1)
|
||||||
rake (13.3.0)
|
rake (13.4.2)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.8.0)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.21.0)
|
signet (0.18.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 4.0)
|
jwt (>= 1.5, < 3.0)
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
sysrandom (1.0.5)
|
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
@@ -204,6 +202,7 @@ GEM
|
|||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
|
unf (0.2.0)
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.27.0)
|
||||||
@@ -226,4 +225,4 @@ DEPENDENCIES
|
|||||||
fastlane
|
fastlane
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.2
|
2.4.22
|
||||||
|
|||||||
136
ios/Podfile.lock
@@ -1,187 +1,51 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- audioplayers_darwin (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- codec2_flutter (0.1.0):
|
- codec2_flutter (0.1.0):
|
||||||
- Flutter
|
- 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 (1.0.0)
|
||||||
- flutter_avif_ios (0.0.1):
|
- flutter_avif_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_background_service_ios (0.0.3):
|
- flutter_background_service_ios (0.0.3):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_blue_plus_darwin (0.0.2):
|
|
||||||
- Flutter
|
|
||||||
- FlutterMacOS
|
|
||||||
- flutter_compass (0.0.1):
|
- flutter_compass (0.0.1):
|
||||||
- Flutter
|
- 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):
|
- nsd_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- package_info_plus (0.4.5):
|
|
||||||
- Flutter
|
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- 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:
|
DEPENDENCIES:
|
||||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
|
||||||
- codec2_flutter (from `.symlinks/plugins/codec2_flutter/ios`)
|
- 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 (from `Flutter`)
|
||||||
- flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`)
|
- flutter_avif_ios (from `.symlinks/plugins/flutter_avif_ios/ios`)
|
||||||
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_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_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`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- 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
|
|
||||||
- SDWebImage
|
|
||||||
- SwiftyGif
|
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audioplayers_darwin:
|
|
||||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
:path: ".symlinks/plugins/codec2_flutter/ios"
|
: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:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_avif_ios:
|
flutter_avif_ios:
|
||||||
:path: ".symlinks/plugins/flutter_avif_ios/ios"
|
:path: ".symlinks/plugins/flutter_avif_ios/ios"
|
||||||
flutter_background_service_ios:
|
flutter_background_service_ios:
|
||||||
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
|
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
|
||||||
flutter_blue_plus_darwin:
|
|
||||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
|
||||||
flutter_compass:
|
flutter_compass:
|
||||||
:path: ".symlinks/plugins/flutter_compass/ios"
|
: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:
|
nsd_ios:
|
||||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||||
package_info_plus:
|
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
: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:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
|
||||||
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd
|
flutter_avif_ios: 2553d2aafda56339cf924e943da89ad3f40af2dd
|
||||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
|
||||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||||
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
|
||||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
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
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -80,6 +82,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||||
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */,
|
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||||
@@ -187,6 +191,9 @@
|
|||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
};
|
};
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||||
|
packageProductDependencies = (
|
||||||
|
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||||
|
);
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
@@ -213,6 +220,9 @@
|
|||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
97C146E61CF9000F007C117D /* Project object */ = {
|
||||||
|
packageReferences = (
|
||||||
|
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||||
|
);
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
@@ -489,7 +499,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 105;
|
CURRENT_PROJECT_VERSION = 142;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +521,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 105;
|
CURRENT_PROJECT_VERSION = 142;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +540,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 105;
|
CURRENT_PROJECT_VERSION = 142;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +557,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 105;
|
CURRENT_PROJECT_VERSION = 142;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +689,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 105;
|
CURRENT_PROJECT_VERSION = 142;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +712,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 105;
|
CURRENT_PROJECT_VERSION = 142;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -752,6 +762,18 @@
|
|||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,24 @@
|
|||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|||||||
59
ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
Normal 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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>105</string>
|
<string>142</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>location</string>
|
<string>location</string>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
<string>bluetooth-peripheral</string>
|
<string>bluetooth-peripheral</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
|
|||||||
@@ -16,17 +16,66 @@
|
|||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
platform :ios do
|
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"
|
desc "Push a new release build to the App Store"
|
||||||
lane :release do
|
lane :release do
|
||||||
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
||||||
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
|
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
|
end
|
||||||
|
|
||||||
desc "Push a new beta build to TestFlight"
|
desc "Push a new beta build to TestFlight"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
||||||
build_app(workspace: "Runner.xcworkspace", scheme: "Runner")
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ Push a new release build to the App Store
|
|||||||
|
|
||||||
Push a new beta build to TestFlight
|
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.
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|||||||
1
ios/fastlane/metadata/copyright.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Copyright 2026 Janez Troha
|
||||||
14
ios/fastlane/metadata/en-US/description.txt
Normal 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.
|
||||||
1
ios/fastlane/metadata/en-US/keywords.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
meshcore,sar,search,rescue,mesh,lora,offline,maps,radio,gps
|
||||||
1
ios/fastlane/metadata/en-US/marketing_url.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
https://github.com/dz0ny/meshcore-sar
|
||||||
1
ios/fastlane/metadata/en-US/name.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
MeshCore SAR
|
||||||
1
ios/fastlane/metadata/en-US/privacy_url.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
https://github.com/dz0ny/meshcore-sar
|
||||||
1
ios/fastlane/metadata/en-US/promotional_text.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Field coordination for search and rescue teams using MeshCore, BLE, offline maps, messaging, voice, and SAR markers.
|
||||||
1
ios/fastlane/metadata/en-US/release_notes.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Adds App Store screenshots and TestFlight metadata for MeshCore SAR publishing.
|
||||||
1
ios/fastlane/metadata/en-US/subtitle.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
SAR mesh field coordination
|
||||||
1
ios/fastlane/metadata/en-US/support_url.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
https://github.com/dz0ny/meshcore-sar/issues
|
||||||
@@ -5,17 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001342">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000247">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.40706">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.741448">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="70.83361">
|
<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>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
BIN
ios/fastlane/screenshots/en-US/01-messages.png
Normal file
|
After Width: | Height: | Size: 584 KiB |
BIN
ios/fastlane/screenshots/en-US/02-contacts.png
Normal file
|
After Width: | Height: | Size: 595 KiB |
BIN
ios/fastlane/screenshots/en-US/03-map.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
ios/fastlane/screenshots/en-US/04-settings.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
ios/fastlane/screenshots/en-US/ipad-01-messages.png
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
ios/fastlane/screenshots/en-US/ipad-02-contacts.png
Normal file
|
After Width: | Height: | Size: 526 KiB |
BIN
ios/fastlane/screenshots/en-US/ipad-03-map.png
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
BIN
ios/fastlane/screenshots/en-US/ipad-04-settings.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
2129
lib/l10n/app_de.arb
3316
lib/l10n/app_el.arb
3292
lib/l10n/app_en.arb
2122
lib/l10n/app_es.arb
2127
lib/l10n/app_fr.arb
1275
lib/l10n/app_hr.arb
2127
lib/l10n/app_it.arb
3450
lib/l10n/app_localizations_pl.dart
Normal file
3459
lib/l10n/app_localizations_pt.dart
Normal file
3424
lib/l10n/app_localizations_tr.dart
Normal file
3456
lib/l10n/app_localizations_uk.dart
Normal file
2617
lib/l10n/app_pl.arb
Normal file
2617
lib/l10n/app_pt.arb
Normal file
1281
lib/l10n/app_sl.arb
2617
lib/l10n/app_tr.arb
Normal file
2617
lib/l10n/app_uk.arb
Normal file
2995
lib/l10n/app_zh.arb
112
lib/main.dart
@@ -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/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'providers/connection_provider.dart';
|
import 'providers/connection_provider.dart';
|
||||||
import 'providers/contacts_provider.dart';
|
import 'providers/contacts_provider.dart';
|
||||||
import 'providers/messages_provider.dart';
|
import 'providers/messages_provider.dart';
|
||||||
@@ -14,19 +18,28 @@ import 'providers/channels_provider.dart';
|
|||||||
import 'providers/voice_provider.dart';
|
import 'providers/voice_provider.dart';
|
||||||
import 'providers/image_provider.dart' as ip;
|
import 'providers/image_provider.dart' as ip;
|
||||||
import 'providers/app_provider.dart';
|
import 'providers/app_provider.dart';
|
||||||
|
import 'providers/offline_tiles_provider.dart';
|
||||||
import 'providers/sensors_provider.dart';
|
import 'providers/sensors_provider.dart';
|
||||||
import 'services/voice_codec_service.dart';
|
import 'services/voice_codec_service.dart';
|
||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.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/update_checker_service.dart';
|
||||||
import 'services/wizard_preferences.dart';
|
import 'services/wizard_preferences.dart';
|
||||||
|
import 'screens/discovery_screen.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/welcome_wizard_screen.dart';
|
import 'screens/welcome_wizard_screen.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'utils/toast_logger.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await WakelockPlus.enable();
|
||||||
runApp(const MeshCoreSarApp());
|
runApp(const MeshCoreSarApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +51,16 @@ class MeshCoreSarApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||||
|
static const bool _isScreenshotRun = bool.fromEnvironment(
|
||||||
|
'MESHCORE_SCREENSHOTS',
|
||||||
|
);
|
||||||
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||||
AppThemeMode _themeMode = AppThemeMode.system;
|
AppThemeMode _themeMode = AppThemeMode.system;
|
||||||
Locale? _locale;
|
Locale? _locale;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _shouldShowPermissionDialog = false;
|
bool _shouldShowPermissionDialog = false;
|
||||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||||
|
String? _pendingNotificationPayload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -51,6 +69,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeApp() async {
|
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 _loadThemePreference();
|
||||||
await _loadLocalePreference();
|
await _loadLocalePreference();
|
||||||
|
|
||||||
@@ -62,18 +88,32 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Set up notification tap handler for update notifications
|
// Set up notification tap handler for update notifications
|
||||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||||
|
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||||
|
|
||||||
// Check if we need to request location permissions
|
// Check if we need to request location permissions
|
||||||
await _checkLocationPermissions();
|
if (!_isScreenshotRun) {
|
||||||
|
await _checkLocationPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
// Check for app updates (Android only) - runs in background
|
// Check for app updates (Android only) - runs in background
|
||||||
// Shows notification if update is available
|
// Shows notification if update is available
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
|
|
||||||
|
// Refresh remote mesh node cache in background when the app starts.
|
||||||
|
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_wizardCompleted = wizardCompleted;
|
_wizardCompleted = wizardCompleted;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_pendingNotificationPayload != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final payload = _pendingNotificationPayload;
|
||||||
|
_pendingNotificationPayload = null;
|
||||||
|
_handleNotificationTap(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle notification tap
|
/// Handle notification tap
|
||||||
@@ -82,10 +122,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
debugPrint('[Main] Notification tapped: $payload');
|
debugPrint('[Main] Notification tapped: $payload');
|
||||||
|
|
||||||
|
if (!_wizardCompleted) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle update notification tap
|
// Handle update notification tap
|
||||||
if (payload.startsWith('update:')) {
|
if (payload.startsWith('update:')) {
|
||||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||||
_launchUpdateDownload(downloadUrl);
|
_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
|
// SAR and message notifications handled by their respective providers
|
||||||
}
|
}
|
||||||
@@ -158,7 +215,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
/// Shows notification if update is available
|
/// Shows notification if update is available
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only check for updates on Android
|
// Only check for updates on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,6 +292,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) {
|
||||||
|
final manager = ProfileManager();
|
||||||
|
manager.initialize();
|
||||||
|
return manager;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Voice provider (packet reassembly + playback)
|
// Voice provider (packet reassembly + playback)
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
@@ -287,6 +352,39 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(),
|
child: _buildMaterialApp(),
|
||||||
);
|
);
|
||||||
@@ -297,10 +395,12 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||||
final materialApp = MaterialApp(
|
final materialApp = MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
|
scaffoldMessengerKey: rootScaffoldMessengerKey,
|
||||||
key: ValueKey<String?>(
|
key: ValueKey<String?>(
|
||||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||||
),
|
),
|
||||||
title: 'MeshCore SAR',
|
onGenerateTitle: (context) => AppLocalizations.of(context)?.appTitle ?? 'MeshCore SAR',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.getTheme(_themeMode, systemBrightness),
|
theme: AppTheme.getTheme(_themeMode, systemBrightness),
|
||||||
locale: _locale,
|
locale: _locale,
|
||||||
@@ -324,7 +424,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
// 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)
|
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||||
if (Platform.isAndroid) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
left: false,
|
left: false,
|
||||||
right: false,
|
right: false,
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ class Channel {
|
|||||||
} else {
|
} else {
|
||||||
// Normal channel: require explicit secret
|
// Normal channel: require explicit secret
|
||||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||||
throw ArgumentError(
|
throw ArgumentError('Normal channels require a 16-byte secret');
|
||||||
'Normal channels require a 16-byte secret',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index,
|
index: index,
|
||||||
@@ -78,6 +76,18 @@ class Channel {
|
|||||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
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)
|
/// Create the default public channel (channel 0)
|
||||||
/// Uses the well-known pre-shared key from MeshCore
|
/// Uses the well-known pre-shared key from MeshCore
|
||||||
factory Channel.publicChannel() {
|
factory Channel.publicChannel() {
|
||||||
@@ -85,8 +95,22 @@ class Channel {
|
|||||||
index: 0,
|
index: 0,
|
||||||
name: 'Public Channel',
|
name: 'Public Channel',
|
||||||
secret: Uint8List.fromList([
|
secret: Uint8List.fromList([
|
||||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
0x8b,
|
||||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
0x33,
|
||||||
|
0x87,
|
||||||
|
0xe9,
|
||||||
|
0xc5,
|
||||||
|
0xcd,
|
||||||
|
0xea,
|
||||||
|
0x6a,
|
||||||
|
0xc9,
|
||||||
|
0xe5,
|
||||||
|
0xed,
|
||||||
|
0xba,
|
||||||
|
0xa1,
|
||||||
|
0x15,
|
||||||
|
0xcd,
|
||||||
|
0x72,
|
||||||
]),
|
]),
|
||||||
flags: null,
|
flags: null,
|
||||||
);
|
);
|
||||||
@@ -95,6 +119,18 @@ class Channel {
|
|||||||
/// Check if this is a hash-based channel (name starts with '#')
|
/// Check if this is a hash-based channel (name starts with '#')
|
||||||
bool get isHashChannel => name.startsWith('#');
|
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
|
/// Display name for the channel
|
||||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||||
String get displayName {
|
String get displayName {
|
||||||
@@ -131,12 +167,7 @@ class Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with modified fields
|
/// Create a copy with modified fields
|
||||||
Channel copyWith({
|
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
|
||||||
int? index,
|
|
||||||
String? name,
|
|
||||||
Uint8List? secret,
|
|
||||||
int? flags,
|
|
||||||
}) {
|
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index ?? this.index,
|
index: index ?? this.index,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
|
|||||||
518
lib/models/config_profile.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
static ParsedContactRoute? fromContact(Contact contact) {
|
||||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -179,6 +196,54 @@ class ContactRouteCodec {
|
|||||||
final hopCount = raw & 0x3F;
|
final hopCount = raw & 0x3F;
|
||||||
return hopCount * hashSize <= maxPathBytes;
|
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 {
|
extension ContactLocalization on Contact {
|
||||||
@@ -232,7 +297,7 @@ extension ContactLocalization on Contact {
|
|||||||
|
|
||||||
String get routeSummary {
|
String get routeSummary {
|
||||||
if (routeIsUnknown) {
|
if (routeIsUnknown) {
|
||||||
return 'Unknown';
|
return 'Flood/Unknown';
|
||||||
}
|
}
|
||||||
if (!routeHasPath || routeHopCount == 0) {
|
if (!routeHasPath || routeHopCount == 0) {
|
||||||
return 'Direct';
|
return 'Direct';
|
||||||
@@ -240,6 +305,5 @@ extension ContactLocalization on Contact {
|
|||||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get routeSupportsLegacyRawTransport =>
|
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||||
routeHasPath && routeSignedPathLen >= 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
lib/models/contact_group.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
lib/models/custom_map_config.dart
Normal 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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,11 @@ enum ConnectionMode {
|
|||||||
/// Direct BLE connection to MeshCore device (default)
|
/// Direct BLE connection to MeshCore device (default)
|
||||||
ble,
|
ble,
|
||||||
|
|
||||||
/// Act as SSE server - share BLE device with multiple clients
|
|
||||||
sseServer,
|
|
||||||
|
|
||||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
tcp,
|
tcp,
|
||||||
|
|
||||||
|
/// USB serial connection (Android OTG or Web Serial API)
|
||||||
|
usb,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectionModeExtension on ConnectionMode {
|
extension ConnectionModeExtension on ConnectionMode {
|
||||||
@@ -26,10 +26,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share Device (Server)';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct (WiFi)';
|
return 'Direct (WiFi)';
|
||||||
|
case ConnectionMode.usb:
|
||||||
|
return 'Direct (Serial)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,10 +37,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share BLE device with multiple clients over network';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
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? advLat;
|
||||||
final int? advLon;
|
final int? advLon;
|
||||||
final bool? manualAddContacts;
|
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? radioFreq;
|
||||||
final int? radioBw;
|
final int? radioBw;
|
||||||
final int? radioSf;
|
final int? radioSf;
|
||||||
@@ -90,6 +96,7 @@ class DeviceInfo {
|
|||||||
|
|
||||||
// Repeat mode (firmware v9+)
|
// Repeat mode (firmware v9+)
|
||||||
final bool? clientRepeat;
|
final bool? clientRepeat;
|
||||||
|
final int? pathHashMode;
|
||||||
final bool? supportsSpectrumScan;
|
final bool? supportsSpectrumScan;
|
||||||
final int? spectrumScanMinKhz;
|
final int? spectrumScanMinKhz;
|
||||||
final int? spectrumScanMaxKhz;
|
final int? spectrumScanMaxKhz;
|
||||||
@@ -113,6 +120,12 @@ class DeviceInfo {
|
|||||||
this.advLat,
|
this.advLat,
|
||||||
this.advLon,
|
this.advLon,
|
||||||
this.manualAddContacts,
|
this.manualAddContacts,
|
||||||
|
this.autoAddUsers,
|
||||||
|
this.autoAddRepeaters,
|
||||||
|
this.autoAddRoomServers,
|
||||||
|
this.autoAddSensors,
|
||||||
|
this.autoAddOverwriteOldest,
|
||||||
|
this.autoAddMaxHops,
|
||||||
this.radioFreq,
|
this.radioFreq,
|
||||||
this.radioBw,
|
this.radioBw,
|
||||||
this.radioSf,
|
this.radioSf,
|
||||||
@@ -129,6 +142,7 @@ class DeviceInfo {
|
|||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
this.semanticVersion,
|
this.semanticVersion,
|
||||||
this.clientRepeat,
|
this.clientRepeat,
|
||||||
|
this.pathHashMode,
|
||||||
this.supportsSpectrumScan,
|
this.supportsSpectrumScan,
|
||||||
this.spectrumScanMinKhz,
|
this.spectrumScanMinKhz,
|
||||||
this.spectrumScanMaxKhz,
|
this.spectrumScanMaxKhz,
|
||||||
@@ -239,6 +253,12 @@ class DeviceInfo {
|
|||||||
int? advLat,
|
int? advLat,
|
||||||
int? advLon,
|
int? advLon,
|
||||||
bool? manualAddContacts,
|
bool? manualAddContacts,
|
||||||
|
bool? autoAddUsers,
|
||||||
|
bool? autoAddRepeaters,
|
||||||
|
bool? autoAddRoomServers,
|
||||||
|
bool? autoAddSensors,
|
||||||
|
bool? autoAddOverwriteOldest,
|
||||||
|
int? autoAddMaxHops,
|
||||||
int? radioFreq,
|
int? radioFreq,
|
||||||
int? radioBw,
|
int? radioBw,
|
||||||
int? radioSf,
|
int? radioSf,
|
||||||
@@ -255,6 +275,7 @@ class DeviceInfo {
|
|||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
String? semanticVersion,
|
String? semanticVersion,
|
||||||
bool? clientRepeat,
|
bool? clientRepeat,
|
||||||
|
int? pathHashMode,
|
||||||
bool? supportsSpectrumScan,
|
bool? supportsSpectrumScan,
|
||||||
int? spectrumScanMinKhz,
|
int? spectrumScanMinKhz,
|
||||||
int? spectrumScanMaxKhz,
|
int? spectrumScanMaxKhz,
|
||||||
@@ -278,6 +299,13 @@ class DeviceInfo {
|
|||||||
advLat: advLat ?? this.advLat,
|
advLat: advLat ?? this.advLat,
|
||||||
advLon: advLon ?? this.advLon,
|
advLon: advLon ?? this.advLon,
|
||||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
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,
|
radioFreq: radioFreq ?? this.radioFreq,
|
||||||
radioBw: radioBw ?? this.radioBw,
|
radioBw: radioBw ?? this.radioBw,
|
||||||
radioSf: radioSf ?? this.radioSf,
|
radioSf: radioSf ?? this.radioSf,
|
||||||
@@ -294,6 +322,7 @@ class DeviceInfo {
|
|||||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
clientRepeat: clientRepeat ?? this.clientRepeat,
|
||||||
|
pathHashMode: pathHashMode ?? this.pathHashMode,
|
||||||
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||||
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||||
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||||
|
|||||||
11
lib/models/map_coordinate_space.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import 'map_coordinate_space.dart';
|
||||||
|
import '../utils/custom_map_id.dart';
|
||||||
|
|
||||||
/// Drawing shape type
|
/// Drawing shape type
|
||||||
enum DrawingShapeType {
|
enum DrawingShapeType { line, rectangle }
|
||||||
line,
|
|
||||||
rectangle,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drawing color enum for compact network transmission
|
/// Drawing color enum for compact network transmission
|
||||||
enum DrawingColor {
|
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
|
||||||
red, // 0
|
|
||||||
blue, // 1
|
|
||||||
green, // 2
|
|
||||||
yellow, // 3
|
|
||||||
orange, // 4
|
|
||||||
purple, // 5
|
|
||||||
pink, // 6
|
|
||||||
cyan, // 7
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drawing colors available for user selection
|
/// Drawing colors available for user selection
|
||||||
class DrawingColors {
|
class DrawingColors {
|
||||||
static const List<Color> palette = [
|
static const List<Color> palette = [
|
||||||
Colors.red, // index 0
|
Colors.red,
|
||||||
Colors.blue, // index 1
|
Colors.blue,
|
||||||
Colors.green, // index 2
|
Colors.green,
|
||||||
Colors.yellow, // index 3
|
Colors.yellow,
|
||||||
Colors.orange, // index 4
|
Colors.orange,
|
||||||
Colors.purple, // index 5
|
Colors.purple,
|
||||||
Colors.pink, // index 6
|
Colors.pink,
|
||||||
Colors.cyan, // index 7
|
Colors.cyan,
|
||||||
];
|
];
|
||||||
|
|
||||||
static String colorToName(Color color) {
|
static String colorToName(Color color) {
|
||||||
@@ -45,37 +36,36 @@ class DrawingColors {
|
|||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Color to enum index for network transmission
|
|
||||||
static int colorToIndex(Color color) {
|
static int colorToIndex(Color color) {
|
||||||
for (int i = 0; i < palette.length; i++) {
|
for (int i = 0; i < palette.length; i++) {
|
||||||
if (palette[i].toARGB32() == color.toARGB32()) {
|
if (palette[i].toARGB32() == color.toARGB32()) {
|
||||||
return i;
|
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) {
|
static Color indexToColor(int index) {
|
||||||
if (index >= 0 && index < palette.length) {
|
if (index >= 0 && index < palette.length) {
|
||||||
return palette[index];
|
return palette[index];
|
||||||
}
|
}
|
||||||
return palette[0]; // Default to red if invalid index
|
return palette[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Base class for map drawings
|
|
||||||
abstract class MapDrawing {
|
abstract class MapDrawing {
|
||||||
final String id;
|
final String id;
|
||||||
final DrawingShapeType type;
|
final DrawingShapeType type;
|
||||||
final Color color;
|
final Color color;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? senderName; // Name of sender (null if local drawing)
|
final String? senderName;
|
||||||
final bool isReceived; // True if drawing was received from another node
|
final bool isReceived;
|
||||||
final String? messageId; // ID of the source message (for navigation)
|
final String? messageId;
|
||||||
final bool isShared; // Whether drawing has been broadcast over mesh
|
final bool isShared;
|
||||||
final bool isSent; // Whether this is a sent drawing (vs received)
|
final bool isSent;
|
||||||
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
|
final bool isHidden;
|
||||||
|
final MapCoordinateSpace coordinateSpace;
|
||||||
|
final String? mapId;
|
||||||
|
|
||||||
MapDrawing({
|
MapDrawing({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -88,79 +78,79 @@ abstract class MapDrawing {
|
|||||||
this.isShared = false,
|
this.isShared = false,
|
||||||
this.isSent = false,
|
this.isSent = false,
|
||||||
this.isHidden = 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();
|
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();
|
Map<String, dynamic> toNetworkJson();
|
||||||
|
|
||||||
/// Parse network JSON (compact format)
|
|
||||||
/// senderName and messageId will be populated from packet metadata
|
|
||||||
static MapDrawing? fromNetworkJson(
|
static MapDrawing? fromNetworkJson(
|
||||||
Map<String, dynamic> json, {
|
Map<String, dynamic> json, {
|
||||||
String? senderName,
|
String? senderName,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
final typeNum = json['t'] as int?;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final type = DrawingShapeType.values[typeNum];
|
final type = DrawingShapeType.values[typeNum];
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case DrawingShapeType.line:
|
case DrawingShapeType.line:
|
||||||
return LineDrawing.fromNetworkJson(
|
return LineDrawing.fromNetworkJson(
|
||||||
json,
|
json,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: mapId,
|
||||||
);
|
);
|
||||||
case DrawingShapeType.rectangle:
|
case DrawingShapeType.rectangle:
|
||||||
return RectangleDrawing.fromNetworkJson(
|
return RectangleDrawing.fromNetworkJson(
|
||||||
json,
|
json,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create from JSON
|
|
||||||
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
||||||
final typeStr = json['type'] as String?;
|
final typeStr = json['type'] as String?;
|
||||||
if (typeStr == null) return null;
|
if (typeStr == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final type = DrawingShapeType.values.firstWhere(
|
final type = DrawingShapeType.values.firstWhere(
|
||||||
(e) => e.toString() == 'DrawingShapeType.$typeStr',
|
(value) => value.name == typeStr,
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case DrawingShapeType.line:
|
case DrawingShapeType.line:
|
||||||
return LineDrawing.fromJson(json);
|
return LineDrawing.fromJson(json);
|
||||||
case DrawingShapeType.rectangle:
|
case DrawingShapeType.rectangle:
|
||||||
return RectangleDrawing.fromJson(json);
|
return RectangleDrawing.fromJson(json);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the center point of the drawing
|
|
||||||
LatLng getCenter();
|
LatLng getCenter();
|
||||||
|
|
||||||
/// Get the bounds of the drawing
|
|
||||||
LatLngBounds getBounds();
|
LatLngBounds getBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Line drawing on map
|
|
||||||
class LineDrawing extends MapDrawing {
|
class LineDrawing extends MapDrawing {
|
||||||
final List<LatLng> points;
|
final List<LatLng> points;
|
||||||
|
|
||||||
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
|
|||||||
super.isShared,
|
super.isShared,
|
||||||
super.isSent,
|
super.isSent,
|
||||||
super.isHidden,
|
super.isHidden,
|
||||||
|
super.coordinateSpace,
|
||||||
|
super.mapId,
|
||||||
}) : super(type: DrawingShapeType.line);
|
}) : super(type: DrawingShapeType.line);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
|
|||||||
'type': type.name,
|
'type': type.name,
|
||||||
'color': color.toARGB32(),
|
'color': color.toARGB32(),
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'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,
|
'isShared': isShared,
|
||||||
// Note: isHidden is not persisted - it's session-only
|
'coordinateSpace': coordinateSpace.name,
|
||||||
|
'mapId': normalizeCustomMapId(mapId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toNetworkJson() {
|
Map<String, dynamic> toNetworkJson() {
|
||||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
|
final payload = <String, dynamic>{
|
||||||
// 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 {
|
|
||||||
't': type.index,
|
't': type.index,
|
||||||
'c': DrawingColors.colorToIndex(color),
|
'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) {
|
static LineDrawing fromJson(Map<String, dynamic> json) {
|
||||||
final pointsJson = json['points'] as List<dynamic>;
|
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?;
|
final senderName = json['sender'] as String?;
|
||||||
|
|
||||||
return LineDrawing(
|
return LineDrawing(
|
||||||
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
|
|||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
points: points,
|
points: points,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: senderName != null, // Mark as received if sender is present
|
isReceived: senderName != null,
|
||||||
isShared: json['isShared'] as bool? ?? false,
|
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, {
|
Map<String, dynamic> json, {
|
||||||
String? senderName,
|
String? senderName,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
// Parse ultra-compact format
|
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
|
||||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
for (int i = 0; i < pointsFlat.length; i += 2) {
|
for (int i = 0; i < flatPoints.length; i += 2) {
|
||||||
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
|
points.add(
|
||||||
|
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return LineDrawing(
|
return LineDrawing(
|
||||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
color: DrawingColors.indexToColor(json['c'] as int),
|
color: DrawingColors.indexToColor(json['c'] as int),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
points: points,
|
points: points,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: true,
|
isReceived: true,
|
||||||
messageId: messageId, // Link to source message
|
messageId: messageId,
|
||||||
isShared: false, // Received drawings are not marked as shared
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: normalizeCustomMapId(mapId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with updated points
|
LineDrawing copyWith({
|
||||||
LineDrawing copyWith({List<LatLng>? points}) {
|
List<LatLng>? points,
|
||||||
|
bool? isHidden,
|
||||||
|
bool? isShared,
|
||||||
|
bool? isReceived,
|
||||||
|
String? messageId,
|
||||||
|
String? senderName,
|
||||||
|
MapCoordinateSpace? coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
|
}) {
|
||||||
return LineDrawing(
|
return LineDrawing(
|
||||||
id: id,
|
id: id,
|
||||||
color: color,
|
color: color,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
points: points ?? this.points,
|
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
|
@override
|
||||||
LatLng getCenter() {
|
LatLng getCenter() {
|
||||||
if (points.isEmpty) return LatLng(0, 0);
|
if (points.isEmpty) return const LatLng(0, 0);
|
||||||
if (points.length == 1) return points[0];
|
if (points.length == 1) return points[0];
|
||||||
|
|
||||||
// Calculate center as average of all points
|
|
||||||
double sumLat = 0;
|
double sumLat = 0;
|
||||||
double sumLon = 0;
|
double sumLon = 0;
|
||||||
for (final point in points) {
|
for (final point in points) {
|
||||||
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
LatLngBounds getBounds() {
|
LatLngBounds getBounds() {
|
||||||
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
|
if (points.isEmpty) {
|
||||||
if (points.length == 1) return LatLngBounds(points[0], points[0]);
|
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 minLat = points[0].latitude;
|
||||||
double maxLat = points[0].latitude;
|
double maxLat = points[0].latitude;
|
||||||
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rectangle drawing on map
|
|
||||||
class RectangleDrawing extends MapDrawing {
|
class RectangleDrawing extends MapDrawing {
|
||||||
final LatLng topLeft;
|
final LatLng topLeft;
|
||||||
final LatLng bottomRight;
|
final LatLng bottomRight;
|
||||||
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
super.isShared,
|
super.isShared,
|
||||||
super.isSent,
|
super.isSent,
|
||||||
super.isHidden,
|
super.isHidden,
|
||||||
|
super.coordinateSpace,
|
||||||
|
super.mapId,
|
||||||
}) : super(type: DrawingShapeType.rectangle);
|
}) : super(type: DrawingShapeType.rectangle);
|
||||||
|
|
||||||
/// Get all corner points for rendering
|
|
||||||
List<LatLng> get corners => [
|
List<LatLng> get corners => [
|
||||||
topLeft,
|
topLeft,
|
||||||
LatLng(topLeft.latitude, bottomRight.longitude), // top right
|
LatLng(topLeft.latitude, bottomRight.longitude),
|
||||||
bottomRight,
|
bottomRight,
|
||||||
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
|
LatLng(bottomRight.latitude, topLeft.longitude),
|
||||||
topLeft, // close the rectangle
|
topLeft,
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
'color': color.toARGB32(),
|
'color': color.toARGB32(),
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
||||||
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
|
'bottomRight': {
|
||||||
|
'lat': bottomRight.latitude,
|
||||||
|
'lon': bottomRight.longitude,
|
||||||
|
},
|
||||||
'isShared': isShared,
|
'isShared': isShared,
|
||||||
// Note: isHidden is not persisted - it's session-only
|
'coordinateSpace': coordinateSpace.name,
|
||||||
|
'mapId': normalizeCustomMapId(mapId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toNetworkJson() {
|
Map<String, dynamic> toNetworkJson() {
|
||||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
|
final payload = <String, dynamic>{
|
||||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
|
||||||
// Sender is fetched from packet metadata, not included in JSON
|
|
||||||
return {
|
|
||||||
't': type.index,
|
't': type.index,
|
||||||
'c': DrawingColors.colorToIndex(color),
|
'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) {
|
static RectangleDrawing fromJson(Map<String, dynamic> json) {
|
||||||
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
color: Color(json['color'] as int),
|
color: Color(json['color'] as int),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
|
topLeft: LatLng(
|
||||||
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
|
(topLeftJson['lat'] as num).toDouble(),
|
||||||
|
(topLeftJson['lon'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
bottomRight: LatLng(
|
||||||
|
(bottomRightJson['lat'] as num).toDouble(),
|
||||||
|
(bottomRightJson['lon'] as num).toDouble(),
|
||||||
|
),
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: senderName != null, // Mark as received if sender is present
|
isReceived: senderName != null,
|
||||||
isShared: json['isShared'] as bool? ?? false,
|
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, {
|
Map<String, dynamic> json, {
|
||||||
String? senderName,
|
String? senderName,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
// Parse ultra-compact format
|
final bounds = (json['b'] as List<dynamic>).cast<num>();
|
||||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
|
||||||
|
|
||||||
return RectangleDrawing(
|
return RectangleDrawing(
|
||||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
color: DrawingColors.indexToColor(json['c'] as int),
|
color: DrawingColors.indexToColor(json['c'] as int),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
topLeft: LatLng(bounds[0], bounds[1]),
|
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
|
||||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: true,
|
isReceived: true,
|
||||||
messageId: messageId, // Link to source message
|
messageId: messageId,
|
||||||
isShared: false, // Received drawings are not marked as shared
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: normalizeCustomMapId(mapId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with updated corners
|
|
||||||
RectangleDrawing copyWith({
|
RectangleDrawing copyWith({
|
||||||
LatLng? topLeft,
|
LatLng? topLeft,
|
||||||
LatLng? bottomRight,
|
LatLng? bottomRight,
|
||||||
|
bool? isHidden,
|
||||||
|
bool? isShared,
|
||||||
|
bool? isReceived,
|
||||||
|
String? messageId,
|
||||||
|
String? senderName,
|
||||||
|
MapCoordinateSpace? coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
return RectangleDrawing(
|
return RectangleDrawing(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
topLeft: topLeft ?? this.topLeft,
|
topLeft: topLeft ?? this.topLeft,
|
||||||
bottomRight: bottomRight ?? this.bottomRight,
|
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
|
@override
|
||||||
LatLng getCenter() {
|
LatLng getCenter() {
|
||||||
// Center is the midpoint between top-left and bottom-right
|
|
||||||
return LatLng(
|
return LatLng(
|
||||||
(topLeft.latitude + bottomRight.latitude) / 2,
|
(topLeft.latitude + bottomRight.latitude) / 2,
|
||||||
(topLeft.longitude + bottomRight.longitude) / 2,
|
(topLeft.longitude + bottomRight.longitude) / 2,
|
||||||
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
LatLngBounds getBounds() {
|
LatLngBounds getBounds() {
|
||||||
// Bounds are simply the two corners
|
|
||||||
return LatLngBounds(topLeft, bottomRight);
|
return LatLngBounds(topLeft, bottomRight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ enum MapLayerType {
|
|||||||
// Kept for stored preference compatibility after MBTiles removal.
|
// Kept for stored preference compatibility after MBTiles removal.
|
||||||
vectorMbtiles,
|
vectorMbtiles,
|
||||||
wmsBase,
|
wmsBase,
|
||||||
|
// Appended last: stored layer preference is the enum index.
|
||||||
|
mapyOutdoor,
|
||||||
}
|
}
|
||||||
|
|
||||||
class MapLayer {
|
class MapLayer {
|
||||||
@@ -21,6 +23,9 @@ class MapLayer {
|
|||||||
final String attribution;
|
final String attribution;
|
||||||
final double maxZoom;
|
final double maxZoom;
|
||||||
|
|
||||||
|
/// Extra HTTP headers required by the tile server (e.g. Referer).
|
||||||
|
final Map<String, String>? headers;
|
||||||
|
|
||||||
// WMS specific properties
|
// WMS specific properties
|
||||||
final bool isWms;
|
final bool isWms;
|
||||||
final String? wmsBaseUrl;
|
final String? wmsBaseUrl;
|
||||||
@@ -36,6 +41,7 @@ class MapLayer {
|
|||||||
required this.urlTemplate,
|
required this.urlTemplate,
|
||||||
required this.attribution,
|
required this.attribution,
|
||||||
required this.maxZoom,
|
required this.maxZoom,
|
||||||
|
this.headers,
|
||||||
this.isWms = false,
|
this.isWms = false,
|
||||||
this.wmsBaseUrl,
|
this.wmsBaseUrl,
|
||||||
this.wmsLayers,
|
this.wmsLayers,
|
||||||
@@ -64,6 +70,9 @@ class MapLayer {
|
|||||||
case MapLayerType.vectorMbtiles:
|
case MapLayerType.vectorMbtiles:
|
||||||
// Legacy value kept only for preference migration compatibility.
|
// Legacy value kept only for preference migration compatibility.
|
||||||
return name;
|
return name;
|
||||||
|
case MapLayerType.mapyOutdoor:
|
||||||
|
// Brand name, not translated.
|
||||||
|
return name;
|
||||||
case MapLayerType.wmsBase:
|
case MapLayerType.wmsBase:
|
||||||
// For WMS layers, use the name (will be localized separately)
|
// For WMS layers, use the name (will be localized separately)
|
||||||
return name;
|
return name;
|
||||||
@@ -86,6 +95,17 @@ class MapLayer {
|
|||||||
maxZoom: 17.49, // OpenTopoMap maximum (just below level 18)
|
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(
|
static const esriWorldImagery = MapLayer(
|
||||||
type: MapLayerType.esriWorldImagery,
|
type: MapLayerType.esriWorldImagery,
|
||||||
name: 'ESRI Satellite',
|
name: 'ESRI Satellite',
|
||||||
@@ -160,6 +180,7 @@ class MapLayer {
|
|||||||
static const List<MapLayer> allLayers = [
|
static const List<MapLayer> allLayers = [
|
||||||
openStreetMap,
|
openStreetMap,
|
||||||
openTopoMap,
|
openTopoMap,
|
||||||
|
mapyOutdoor,
|
||||||
esriWorldImagery,
|
esriWorldImagery,
|
||||||
googleHybrid,
|
googleHybrid,
|
||||||
googleRoadmap,
|
googleRoadmap,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import 'sar_marker.dart';
|
import 'sar_marker.dart';
|
||||||
|
import 'map_coordinate_space.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
extension MessageVoiceExtension on Message {
|
extension MessageVoiceExtension on Message {
|
||||||
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
|
|||||||
|
|
||||||
/// Convert to a [SarMarker] if this message contains SAR data.
|
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||||
SarMarker? toSarMarker() {
|
SarMarker? toSarMarker() {
|
||||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
if (!isSarMarker || sarMarkerType == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
|
|||||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
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(
|
return SarMarker(
|
||||||
id: id,
|
id: id,
|
||||||
type: sarMarkerType!,
|
type: sarMarkerType!,
|
||||||
location: sarGpsCoordinates!,
|
location: location,
|
||||||
timestamp: sentAt,
|
timestamp: sentAt,
|
||||||
senderPublicKey: senderPublicKeyPrefix,
|
senderPublicKey: senderPublicKeyPrefix,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
notes: sarNotes,
|
notes: sarNotes,
|
||||||
customEmoji: sarCustomEmoji,
|
customEmoji: sarCustomEmoji,
|
||||||
colorIndex: sarColorIndex,
|
colorIndex: sarColorIndex,
|
||||||
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: sarCustomMapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ class MessageContactLocation {
|
|||||||
|
|
||||||
String get technicalSourceLabel {
|
String get technicalSourceLabel {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
|
case 'shared':
|
||||||
|
return 'shared location';
|
||||||
|
case 'gps':
|
||||||
|
return 'shared location';
|
||||||
case 'telemetry':
|
case 'telemetry':
|
||||||
return 'telemetry';
|
return 'live telemetry';
|
||||||
case 'advert':
|
case 'advert':
|
||||||
return 'advert';
|
return 'shared advert';
|
||||||
default:
|
default:
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MessageReceptionDetails {
|
|||||||
final int? senderToReceiptMs;
|
final int? senderToReceiptMs;
|
||||||
final int? estimatedTransmitMs;
|
final int? estimatedTransmitMs;
|
||||||
final int? postTransmitDelayMs;
|
final int? postTransmitDelayMs;
|
||||||
|
final int receivedCopies;
|
||||||
|
|
||||||
const MessageReceptionDetails({
|
const MessageReceptionDetails({
|
||||||
required this.capturedAt,
|
required this.capturedAt,
|
||||||
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
|
|||||||
this.senderToReceiptMs,
|
this.senderToReceiptMs,
|
||||||
this.estimatedTransmitMs,
|
this.estimatedTransmitMs,
|
||||||
this.postTransmitDelayMs,
|
this.postTransmitDelayMs,
|
||||||
|
this.receivedCopies = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||||
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
|
|||||||
'senderToReceiptMs': senderToReceiptMs,
|
'senderToReceiptMs': senderToReceiptMs,
|
||||||
'estimatedTransmitMs': estimatedTransmitMs,
|
'estimatedTransmitMs': estimatedTransmitMs,
|
||||||
'postTransmitDelayMs': postTransmitDelayMs,
|
'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) {
|
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||||
final capturedAtMillis = json['capturedAtMillis'];
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
if (capturedAtMillis is! int) {
|
if (capturedAtMillis is! int) {
|
||||||
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
|
|||||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||||
|
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
lib/models/message_route_metadata.dart
Normal 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/models/path_selection.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import 'map_coordinate_space.dart';
|
||||||
import '../services/sar_template_service.dart';
|
import '../services/sar_template_service.dart';
|
||||||
|
|
||||||
/// SAR (Search & Rescue) marker types
|
/// SAR (Search & Rescue) marker types
|
||||||
@@ -78,6 +79,8 @@ class SarMarker {
|
|||||||
final String? notes;
|
final String? notes;
|
||||||
final String? customEmoji; // For custom SAR markers not in predefined types
|
final String? customEmoji; // For custom SAR markers not in predefined types
|
||||||
final int? colorIndex; // Color index (0-7) from standard palette
|
final int? colorIndex; // Color index (0-7) from standard palette
|
||||||
|
final MapCoordinateSpace coordinateSpace;
|
||||||
|
final String? mapId;
|
||||||
|
|
||||||
SarMarker({
|
SarMarker({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -89,8 +92,13 @@ class SarMarker {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.customEmoji,
|
this.customEmoji,
|
||||||
this.colorIndex,
|
this.colorIndex,
|
||||||
|
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
this.mapId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get isCustomMapMarker =>
|
||||||
|
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
|
||||||
|
|
||||||
/// Get sender public key as hex string (short)
|
/// Get sender public key as hex string (short)
|
||||||
String? get senderKeyShort {
|
String? get senderKeyShort {
|
||||||
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
||||||
@@ -167,6 +175,8 @@ class SarMarker {
|
|||||||
String? notes,
|
String? notes,
|
||||||
String? customEmoji,
|
String? customEmoji,
|
||||||
int? colorIndex,
|
int? colorIndex,
|
||||||
|
MapCoordinateSpace? coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
return SarMarker(
|
return SarMarker(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -178,6 +188,8 @@ class SarMarker {
|
|||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
customEmoji: customEmoji ?? this.customEmoji,
|
customEmoji: customEmoji ?? this.customEmoji,
|
||||||
colorIndex: colorIndex ?? this.colorIndex,
|
colorIndex: colorIndex ?? this.colorIndex,
|
||||||
|
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||||
|
mapId: mapId ?? this.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
int _selectedChannelIndex = 0; // Default to public channel
|
int _selectedChannelIndex = 0; // Default to public channel
|
||||||
|
|
||||||
/// Get all channels
|
/// 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
|
/// Get a specific channel by index
|
||||||
Channel? getChannel(int index) => _channels[index];
|
Channel? getChannel(int index) => _channels[index];
|
||||||
@@ -28,6 +29,16 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
return index == 0 ? 'Public' : 'Channel $index';
|
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
|
/// Add or update a channel
|
||||||
void addOrUpdateChannel({
|
void addOrUpdateChannel({
|
||||||
required int index,
|
required int index,
|
||||||
@@ -96,6 +107,13 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear runtime channel state before a live device sync begins.
|
||||||
|
void prepareForDeviceSync() {
|
||||||
|
_channels.clear();
|
||||||
|
_selectedChannelIndex = 0;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if channels have been loaded
|
/// Check if channels have been loaded
|
||||||
bool get hasChannels => _channels.isNotEmpty;
|
bool get hasChannels => _channels.isNotEmpty;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
|
import '../models/map_coordinate_space.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
|
|
||||||
/// Drawing mode state
|
/// Drawing mode state
|
||||||
@@ -11,6 +14,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
|||||||
/// Provider for managing map drawings
|
/// Provider for managing map drawings
|
||||||
class DrawingProvider with ChangeNotifier {
|
class DrawingProvider with ChangeNotifier {
|
||||||
static const String _storageKey = 'map_drawings';
|
static const String _storageKey = 'map_drawings';
|
||||||
|
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||||
|
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
DrawingMode _drawingMode = DrawingMode.none;
|
DrawingMode _drawingMode = DrawingMode.none;
|
||||||
@@ -20,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Completed drawings
|
// Completed drawings
|
||||||
final List<MapDrawing> _drawings = [];
|
final List<MapDrawing> _drawings = [];
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
// In-progress drawing
|
// In-progress drawing
|
||||||
MapDrawing? _currentDrawing;
|
MapDrawing? _currentDrawing;
|
||||||
@@ -30,12 +36,18 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
LatLng? _measurementPoint1;
|
LatLng? _measurementPoint1;
|
||||||
LatLng? _measurementPoint2;
|
LatLng? _measurementPoint2;
|
||||||
double? _measuredDistance; // in meters
|
double? _measuredDistance; // in meters
|
||||||
|
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
String? _activeMapId;
|
||||||
|
double? _activeMetersPerPixel;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
DrawingMode get drawingMode => _drawingMode;
|
DrawingMode get drawingMode => _drawingMode;
|
||||||
Color get selectedColor => _selectedColor;
|
Color get selectedColor => _selectedColor;
|
||||||
bool get showReceivedDrawings => _showReceivedDrawings;
|
bool get showReceivedDrawings => _showReceivedDrawings;
|
||||||
bool get showSarMarkers => _showSarMarkers;
|
bool get showSarMarkers => _showSarMarkers;
|
||||||
|
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
|
||||||
|
String? get activeMapId => _activeMapId;
|
||||||
|
double? get activeMetersPerPixel => _activeMetersPerPixel;
|
||||||
List<MapDrawing> get drawings {
|
List<MapDrawing> get drawings {
|
||||||
// Filter out hidden drawings first
|
// Filter out hidden drawings first
|
||||||
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
||||||
@@ -45,19 +57,62 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
|
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());
|
return List.unmodifiable(visibleDrawings.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
MapDrawing? get currentDrawing => _currentDrawing;
|
MapDrawing? get currentDrawing => _currentDrawing;
|
||||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||||
double? get measuredDistance => _measuredDistance;
|
double? get measuredDistance => _measuredDistance;
|
||||||
|
|
||||||
/// Initialize and load saved drawings
|
/// Initialize and load saved drawings
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
await _loadPreferences();
|
||||||
await _loadDrawings();
|
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
|
/// Set drawing mode
|
||||||
@@ -77,15 +132,34 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of received drawings
|
/// Toggle visibility of received drawings
|
||||||
void toggleReceivedDrawings() {
|
Future<void> toggleReceivedDrawings() async {
|
||||||
_showReceivedDrawings = !_showReceivedDrawings;
|
_showReceivedDrawings = !_showReceivedDrawings;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of SAR markers
|
/// Toggle visibility of SAR markers
|
||||||
void toggleSarMarkers() {
|
Future<void> toggleSarMarkers() async {
|
||||||
_showSarMarkers = !_showSarMarkers;
|
_showSarMarkers = !_showSarMarkers;
|
||||||
notifyListeners();
|
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
|
/// Start drawing a line
|
||||||
@@ -116,6 +190,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
color: _selectedColor,
|
color: _selectedColor,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
points: List.from(_currentLinePoints),
|
points: List.from(_currentLinePoints),
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
_drawings.add(drawing);
|
_drawings.add(drawing);
|
||||||
@@ -159,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
? _rectangleStartPoint!.longitude
|
? _rectangleStartPoint!.longitude
|
||||||
: endPoint.longitude,
|
: endPoint.longitude,
|
||||||
),
|
),
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -195,6 +277,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
topLeft: topLeft,
|
topLeft: topLeft,
|
||||||
bottomRight: bottomRight,
|
bottomRight: bottomRight,
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
_drawings.add(drawing);
|
_drawings.add(drawing);
|
||||||
@@ -216,7 +302,9 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Set second measurement point and calculate distance
|
/// Set second measurement point and calculate distance
|
||||||
void setMeasurementPoint2(LatLng point) {
|
void setMeasurementPoint2(LatLng point) {
|
||||||
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
|
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_measurementPoint2 = point;
|
_measurementPoint2 = point;
|
||||||
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
|
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
|
||||||
@@ -225,6 +313,13 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Calculate distance between two points using Haversine formula
|
/// Calculate distance between two points using Haversine formula
|
||||||
double _calculateDistance(LatLng point1, LatLng point2) {
|
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();
|
const Distance distance = Distance();
|
||||||
return distance.as(LengthUnit.Meter, point1, point2);
|
return distance.as(LengthUnit.Meter, point1, point2);
|
||||||
}
|
}
|
||||||
@@ -281,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
||||||
final jsonString = jsonEncode(jsonList);
|
final jsonString = jsonEncode(jsonList);
|
||||||
await prefs.setString(_storageKey, jsonString);
|
await prefs.setString(_scopedKey(_storageKey), jsonString);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving drawings: $e');
|
debugPrint('Error saving drawings: $e');
|
||||||
}
|
}
|
||||||
@@ -291,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
Future<void> _loadDrawings() async {
|
Future<void> _loadDrawings() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_storageKey);
|
final jsonString = prefs.getString(_scopedKey(_storageKey));
|
||||||
if (jsonString == null) return;
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
_drawings.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
_drawings.clear();
|
_drawings.clear();
|
||||||
@@ -318,6 +416,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
color: _selectedColor,
|
color: _selectedColor,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
points: _currentLinePoints,
|
points: _currentLinePoints,
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
} else if (_drawingMode == DrawingMode.rectangle &&
|
} else if (_drawingMode == DrawingMode.rectangle &&
|
||||||
_currentDrawing != null) {
|
_currentDrawing != null) {
|
||||||
@@ -355,6 +457,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
} else if (drawing is RectangleDrawing) {
|
} else if (drawing is RectangleDrawing) {
|
||||||
return RectangleDrawing(
|
return RectangleDrawing(
|
||||||
@@ -369,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return drawing;
|
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)
|
/// Get all unshared drawings (local drawings not yet sent)
|
||||||
List<MapDrawing> getUnsharedDrawings() {
|
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
|
/// Mark a drawing as shared
|
||||||
@@ -407,6 +531,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: true,
|
isShared: true,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
} else if (drawing is RectangleDrawing) {
|
} else if (drawing is RectangleDrawing) {
|
||||||
_drawings[index] = RectangleDrawing(
|
_drawings[index] = RectangleDrawing(
|
||||||
@@ -421,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: true,
|
isShared: true,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +576,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: !drawing.isHidden,
|
isHidden: !drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
} else if (drawing is RectangleDrawing) {
|
} else if (drawing is RectangleDrawing) {
|
||||||
_drawings[index] = RectangleDrawing(
|
_drawings[index] = RectangleDrawing(
|
||||||
@@ -462,6 +592,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: !drawing.isHidden,
|
isHidden: !drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Map of ACK tag to timestamp for timeout cleanup
|
/// Map of ACK tag to timestamp for timeout cleanup
|
||||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
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
|
/// Track a pending message ID before sending
|
||||||
///
|
///
|
||||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||||
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
|
|||||||
_messageIdToAckTag.remove(messageId);
|
_messageIdToAckTag.remove(messageId);
|
||||||
}
|
}
|
||||||
_ackTagTimestamps.remove(ackCode);
|
_ackTagTimestamps.remove(ackCode);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove ACK tag mapping by message ID
|
/// Remove ACK tag mapping by message ID
|
||||||
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
|
|||||||
if (ackTag != null) {
|
if (ackTag != null) {
|
||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
_pendingMessageIds.remove(messageId);
|
_pendingMessageIds.remove(messageId);
|
||||||
final emptyKeys = <String>[];
|
final emptyKeys = <String>[];
|
||||||
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of pending ACK tags
|
/// Get count of pending ACK tags
|
||||||
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
|
|||||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||||
bool get shouldRateLimit => pendingCount >= 7;
|
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)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
/// Manages message retry state and logic
|
/// Manages message retry state and logic
|
||||||
///
|
///
|
||||||
/// This helper class centralizes retry logic for direct messages, implementing
|
/// This helper class centralizes retry logic for direct messages.
|
||||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
|
||||||
/// with learned routing paths.
|
|
||||||
///
|
///
|
||||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||||
/// - Firmware calculates timeout based on path length and airtime
|
/// - Firmware calculates timeout based on path length and airtime
|
||||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
/// - 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
|
/// - Firmware does NOT automatically retry - app must implement
|
||||||
class MessageRetryManager {
|
class MessageRetryManager {
|
||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
@@ -21,10 +20,19 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
/// Max retry attempts when the contact has a known path.
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
/// Sequence: 2 direct attempts, flood, then last successful route.
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
static const int maxRetryAttemptsWithPath = 3;
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
|
||||||
|
/// 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 _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -32,76 +40,65 @@ class MessageRetryManager {
|
|||||||
static const int _defaultLoRaCrcEnabled = 1;
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
static const int _defaultLoRaExplicitHeader = 1;
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// Get backoff delay for the next retry attempt.
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
int getDelayForAttempt(int attempt) {
|
||||||
int getTimeoutForAttempt(int attempt) {
|
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
return _retryDelays.last;
|
||||||
return _timeouts.last; // Default to last timeout if out of range
|
|
||||||
}
|
}
|
||||||
return _timeouts[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
static final math.Random _rng = math.Random();
|
||||||
/// provide one or returns an invalid value.
|
|
||||||
|
/// 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({
|
int calculateAckTimeoutMs({
|
||||||
required String text,
|
required String text,
|
||||||
required Contact? contact,
|
required Contact? contact,
|
||||||
int? suggestedTimeoutMs,
|
int? suggestedTimeoutMs,
|
||||||
}) {
|
}) {
|
||||||
|
int baseTimeout;
|
||||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
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;
|
// Add random jitter: 500-2000ms to avoid collision
|
||||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
final jitterMs = 500 + _rng.nextInt(1501);
|
||||||
final hopCount = contact?.routeHasPath == true
|
return baseTimeout + jitterMs;
|
||||||
? contact!.routeHopCount
|
}
|
||||||
: -1;
|
|
||||||
|
/// Max attempts for a given contact based on whether it has a known path.
|
||||||
if (hopCount < 0) {
|
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||||
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
final hasPath = contact?.routeHasPath ?? false;
|
||||||
}
|
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||||
|
|
||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
// Never retry if already tried flood mode
|
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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:
|
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
/// directly against maxAttempts (not +1).
|
||||||
/// - Contact HAS a learned path (so direct mode was used)
|
bool isLastAttempt(Message message, Contact contact) {
|
||||||
/// - Hasn't already used flood fallback
|
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||||
///
|
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||||
/// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ typedef RawPacketSender =
|
|||||||
required Uint8List payload,
|
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>({
|
Future<bool> serveCachedSessionFragments<T>({
|
||||||
required String providerLabel,
|
required String providerLabel,
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@@ -19,6 +24,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
required Uint8List Function(T fragment) encodeBinary,
|
required Uint8List Function(T fragment) encodeBinary,
|
||||||
required RawPacketSender? sendRawPacket,
|
required RawPacketSender? sendRawPacket,
|
||||||
Set<int>? requestedIndices,
|
Set<int>? requestedIndices,
|
||||||
|
Duration interFragmentDelay = _interFragmentDelay,
|
||||||
}) async {
|
}) async {
|
||||||
if (fragments.isEmpty) {
|
if (fragments.isEmpty) {
|
||||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||||
@@ -38,12 +44,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
);
|
);
|
||||||
return false;
|
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) {
|
if (requester.outPath.isEmpty) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
@@ -62,9 +62,12 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (servedCount > 0 && interFragmentDelay > Duration.zero) {
|
||||||
|
await Future<void>.delayed(interFragmentDelay);
|
||||||
|
}
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
import 'helpers/raw_session_retransmit.dart';
|
import 'helpers/raw_session_retransmit.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
|
||||||
@@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier {
|
|||||||
_restore();
|
_restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
|
||||||
|
|
||||||
// ── Accessors ────────────────────────────────────────────────────────────
|
// ── Accessors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ImageSession? session(String sessionId) => _sessions[sessionId];
|
ImageSession? session(String sessionId) => _sessions[sessionId];
|
||||||
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
|
|||||||
// ── Persistence ──────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
_sessions.clear();
|
_resetInMemoryState();
|
||||||
_outgoing.clear();
|
|
||||||
_ignoredIncomingSessions.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_storageKey);
|
await prefs.remove(_scopedStorageKey());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
_resetInMemoryState();
|
||||||
|
await _restore();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void _evictExpiredOutgoing() {
|
void _evictExpiredOutgoing() {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
||||||
@@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
};
|
};
|
||||||
await prefs.setString(_storageKey, jsonEncode(payload));
|
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
||||||
}
|
}
|
||||||
@@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
Future<void> _restore() async {
|
Future<void> _restore() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final raw = prefs.getString(_storageKey);
|
final raw = prefs.getString(_scopedStorageKey());
|
||||||
if (raw == null || raw.isEmpty) return;
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
@@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _resetInMemoryState() {
|
||||||
|
_sessions.clear();
|
||||||
|
_outgoing.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OutgoingSession {
|
class _OutgoingSession {
|
||||||
|
|||||||
@@ -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/foundation.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/custom_map_config.dart';
|
||||||
import '../models/location_trail.dart';
|
import '../models/location_trail.dart';
|
||||||
|
import '../models/map_coordinate_space.dart';
|
||||||
import '../models/map_drawing.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 {
|
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;
|
LatLng? _targetLocation;
|
||||||
|
LatLngBounds? _targetBounds;
|
||||||
double? _targetZoom;
|
double? _targetZoom;
|
||||||
bool _shouldAnimate = false;
|
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;
|
LocationTrail? _currentTrail;
|
||||||
bool _isTrailVisible = true;
|
bool _isTrailVisible = true;
|
||||||
|
bool _isTrailRecordingEnabled = true;
|
||||||
final List<LocationTrail> _trailHistory = [];
|
final List<LocationTrail> _trailHistory = [];
|
||||||
|
|
||||||
// WMS overlay toggles
|
|
||||||
bool _showCadastralOverlay = false;
|
bool _showCadastralOverlay = false;
|
||||||
bool _showForestRoadsOverlay = false;
|
bool _showForestRoadsOverlay = false;
|
||||||
bool _showHikingTrailsOverlay = false;
|
bool _showHikingTrailsOverlay = false;
|
||||||
@@ -31,28 +54,28 @@ class MapProvider with ChangeNotifier {
|
|||||||
bool _showPlaceNamesOverlay = false;
|
bool _showPlaceNamesOverlay = false;
|
||||||
bool _showMunicipalityBordersOverlay = false;
|
bool _showMunicipalityBordersOverlay = false;
|
||||||
|
|
||||||
// Contact trail toggles
|
bool _hideRepeatersOnMap = false;
|
||||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
|
||||||
|
|
||||||
// Imported trail (from GPX)
|
|
||||||
LocationTrail? _importedTrail;
|
LocationTrail? _importedTrail;
|
||||||
|
|
||||||
// Download area selection
|
|
||||||
bool _isSelectingDownloadArea = false;
|
bool _isSelectingDownloadArea = false;
|
||||||
LatLngBounds? _downloadAreaBounds;
|
LatLngBounds? _downloadAreaBounds;
|
||||||
|
|
||||||
|
CustomMapConfig? _customMapConfig;
|
||||||
|
bool _isUsingCustomMap = false;
|
||||||
|
|
||||||
LatLng? get targetLocation => _targetLocation;
|
LatLng? get targetLocation => _targetLocation;
|
||||||
|
LatLngBounds? get targetBounds => _targetBounds;
|
||||||
double? get targetZoom => _targetZoom;
|
double? get targetZoom => _targetZoom;
|
||||||
bool get shouldAnimate => _shouldAnimate;
|
bool get shouldAnimate => _shouldAnimate;
|
||||||
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
|
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
|
||||||
|
String? get targetMapId => _targetMapId;
|
||||||
// Trail getters
|
|
||||||
LocationTrail? get currentTrail => _currentTrail;
|
LocationTrail? get currentTrail => _currentTrail;
|
||||||
bool get isTrailVisible => _isTrailVisible;
|
bool get isTrailVisible => _isTrailVisible;
|
||||||
|
bool get isTrailRecordingEnabled => _isTrailRecordingEnabled;
|
||||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||||
|
|
||||||
// WMS overlay getters
|
|
||||||
bool get showCadastralOverlay => _showCadastralOverlay;
|
bool get showCadastralOverlay => _showCadastralOverlay;
|
||||||
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
|
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
|
||||||
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
|
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
|
||||||
@@ -65,68 +88,138 @@ class MapProvider with ChangeNotifier {
|
|||||||
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
|
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
|
||||||
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
|
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
|
||||||
|
|
||||||
// Contact trail getters
|
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||||
bool get showAllContactTrails => _showAllContactTrails;
|
|
||||||
|
|
||||||
// Imported trail getters
|
|
||||||
LocationTrail? get importedTrail => _importedTrail;
|
LocationTrail? get importedTrail => _importedTrail;
|
||||||
|
|
||||||
// Download area getters
|
|
||||||
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
||||||
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
|
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({
|
void navigateToLocation({
|
||||||
required LatLng location,
|
required LatLng location,
|
||||||
double zoom = 15.0,
|
double zoom = 15.0,
|
||||||
bool animate = true,
|
bool animate = true,
|
||||||
}) {
|
}) {
|
||||||
|
if (_isUsingCustomMap) {
|
||||||
|
_isUsingCustomMap = false;
|
||||||
|
unawaited(_saveCustomMapState());
|
||||||
|
}
|
||||||
_targetLocation = location;
|
_targetLocation = location;
|
||||||
|
_targetBounds = null;
|
||||||
_targetZoom = zoom;
|
_targetZoom = zoom;
|
||||||
_shouldAnimate = animate;
|
_shouldAnimate = animate;
|
||||||
|
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
_targetMapId = null;
|
||||||
notifyListeners();
|
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() {
|
void clearNavigation() {
|
||||||
_targetLocation = null;
|
_targetLocation = null;
|
||||||
|
_targetBounds = null;
|
||||||
_targetZoom = null;
|
_targetZoom = null;
|
||||||
_shouldAnimate = false;
|
_shouldAnimate = false;
|
||||||
// Don't notify listeners to avoid rebuilds
|
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
_targetMapId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to a drawing by its ID
|
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
||||||
void navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (drawing == null) {
|
if (drawing == null) {
|
||||||
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found');
|
return 'Drawing not found.';
|
||||||
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use MapDrawing's built-in getCenter and getBounds methods
|
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
final center = drawing.getCenter();
|
if (!matchesActiveCustomMap(drawing.mapId)) {
|
||||||
final bounds = drawing.getBounds();
|
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
|
final bounds = drawing.getBounds();
|
||||||
// For larger drawings, use lower zoom to fit the whole drawing
|
|
||||||
// For smaller drawings, use higher zoom for better detail
|
|
||||||
final latDiff = (bounds.north - bounds.south).abs();
|
final latDiff = (bounds.north - bounds.south).abs();
|
||||||
final lonDiff = (bounds.east - bounds.west).abs();
|
final lonDiff = (bounds.east - bounds.west).abs();
|
||||||
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
|
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;
|
double zoom = 15.0;
|
||||||
if (maxDiff < 0.001) {
|
if (maxDiff < 0.001) {
|
||||||
zoom = 17.0;
|
zoom = 17.0;
|
||||||
@@ -142,9 +235,125 @@ class MapProvider with ChangeNotifier {
|
|||||||
zoom = 10.0;
|
zoom = 10.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle';
|
navigateToLocation(
|
||||||
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom');
|
location: drawing.getCenter(),
|
||||||
navigateToLocation(location: center, zoom: zoom, animate: true);
|
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) {
|
void updateZoom(double zoom) {
|
||||||
@@ -152,37 +361,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
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() {
|
void startTrail() {
|
||||||
// End current trail if active
|
|
||||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||||
endTrail();
|
endTrail();
|
||||||
}
|
}
|
||||||
@@ -195,22 +374,26 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a point to the current trail
|
|
||||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||||
|
if (!_isTrailRecordingEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||||
startTrail();
|
startTrail();
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentTrail!.addPoint(TrailPoint(
|
_currentTrail!.addPoint(
|
||||||
position: position,
|
TrailPoint(
|
||||||
timestamp: DateTime.now(),
|
position: position,
|
||||||
accuracy: accuracy,
|
timestamp: DateTime.now(),
|
||||||
speed: speed,
|
accuracy: accuracy,
|
||||||
));
|
speed: speed,
|
||||||
|
),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End the current trail
|
|
||||||
void endTrail() {
|
void endTrail() {
|
||||||
if (_currentTrail != null) {
|
if (_currentTrail != null) {
|
||||||
_currentTrail!.isActive = false;
|
_currentTrail!.isActive = false;
|
||||||
@@ -223,13 +406,29 @@ class MapProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle trail visibility
|
|
||||||
void toggleTrailVisibility() {
|
void toggleTrailVisibility() {
|
||||||
_isTrailVisible = !_isTrailVisible;
|
_isTrailVisible = !_isTrailVisible;
|
||||||
notifyListeners();
|
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() {
|
void clearCurrentTrail() {
|
||||||
if (_currentTrail != null) {
|
if (_currentTrail != null) {
|
||||||
_currentTrail = null;
|
_currentTrail = null;
|
||||||
@@ -237,197 +436,347 @@ class MapProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all trail history
|
|
||||||
void clearAllTrails() {
|
void clearAllTrails() {
|
||||||
_currentTrail = null;
|
_currentTrail = null;
|
||||||
_trailHistory.clear();
|
_trailHistory.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total trail distance in meters
|
|
||||||
double get totalTrailDistance {
|
double get totalTrailDistance {
|
||||||
if (_currentTrail == null) return 0;
|
if (_currentTrail == null) return 0;
|
||||||
return _currentTrail!.totalDistance;
|
return _currentTrail!.totalDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get trail duration
|
|
||||||
Duration get trailDuration {
|
Duration get trailDuration {
|
||||||
if (_currentTrail == null) return Duration.zero;
|
if (_currentTrail == null) return Duration.zero;
|
||||||
return _currentTrail!.duration;
|
return _currentTrail!.duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle cadastral parcels overlay
|
|
||||||
Future<void> toggleCadastralOverlay() async {
|
Future<void> toggleCadastralOverlay() async {
|
||||||
_showCadastralOverlay = !_showCadastralOverlay;
|
_showCadastralOverlay = !_showCadastralOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle forest roads overlay
|
|
||||||
Future<void> toggleForestRoadsOverlay() async {
|
Future<void> toggleForestRoadsOverlay() async {
|
||||||
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle hiking trails overlay
|
|
||||||
Future<void> toggleHikingTrailsOverlay() async {
|
Future<void> toggleHikingTrailsOverlay() async {
|
||||||
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle main roads overlay
|
|
||||||
Future<void> toggleMainRoadsOverlay() async {
|
Future<void> toggleMainRoadsOverlay() async {
|
||||||
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle house numbers overlay
|
|
||||||
Future<void> toggleHouseNumbersOverlay() async {
|
Future<void> toggleHouseNumbersOverlay() async {
|
||||||
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle fire hazard zones overlay
|
|
||||||
Future<void> toggleFireHazardZonesOverlay() async {
|
Future<void> toggleFireHazardZonesOverlay() async {
|
||||||
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle historical fires overlay
|
|
||||||
Future<void> toggleHistoricalFiresOverlay() async {
|
Future<void> toggleHistoricalFiresOverlay() async {
|
||||||
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle firebreaks overlay
|
|
||||||
Future<void> toggleFirebreaksOverlay() async {
|
Future<void> toggleFirebreaksOverlay() async {
|
||||||
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle Kras fire zones overlay
|
|
||||||
Future<void> toggleKrasFireZonesOverlay() async {
|
Future<void> toggleKrasFireZonesOverlay() async {
|
||||||
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle place names overlay
|
|
||||||
Future<void> togglePlaceNamesOverlay() async {
|
Future<void> togglePlaceNamesOverlay() async {
|
||||||
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle municipality borders overlay
|
|
||||||
Future<void> toggleMunicipalityBordersOverlay() async {
|
Future<void> toggleMunicipalityBordersOverlay() async {
|
||||||
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load overlay state from SharedPreferences
|
|
||||||
Future<void> loadOverlayState() async {
|
Future<void> loadOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false;
|
_showCadastralOverlay =
|
||||||
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
|
||||||
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false;
|
_showForestRoadsOverlay =
|
||||||
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
|
||||||
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false;
|
_showHikingTrailsOverlay =
|
||||||
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
|
||||||
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false;
|
_showMainRoadsOverlay =
|
||||||
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
|
||||||
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
|
_showHouseNumbersOverlay =
|
||||||
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
|
||||||
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_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();
|
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 {
|
Future<void> _saveOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
|
await prefs.setBool(
|
||||||
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
|
_scopedKey('map_show_cadastral_overlay'),
|
||||||
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay);
|
_showCadastralOverlay,
|
||||||
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
|
);
|
||||||
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay);
|
await prefs.setBool(
|
||||||
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay);
|
_scopedKey('map_show_forest_roads_overlay'),
|
||||||
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay);
|
_showForestRoadsOverlay,
|
||||||
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
|
);
|
||||||
await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay);
|
await prefs.setBool(
|
||||||
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
|
_scopedKey('map_show_hiking_trails_overlay'),
|
||||||
await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay);
|
_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> setHideRepeatersOnMap(bool hide) async {
|
||||||
Future<void> toggleAllContactTrails() async {
|
if (_hideRepeatersOnMap == hide) return;
|
||||||
_showAllContactTrails = !_showAllContactTrails;
|
_hideRepeatersOnMap = hide;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveTrailSettings();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load trail settings from SharedPreferences
|
Future<void> loadRepeaterVisibilitySettings() async {
|
||||||
Future<void> loadTrailSettings() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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();
|
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) {
|
void setImportedTrail(LocationTrail trail) {
|
||||||
_importedTrail = trail;
|
_importedTrail = trail;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear imported trail
|
|
||||||
void clearImportedTrail() {
|
void clearImportedTrail() {
|
||||||
_importedTrail = null;
|
_importedTrail = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace current trail with imported trail
|
|
||||||
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
||||||
// End current trail if active
|
|
||||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||||
endTrail();
|
endTrail();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set imported trail as current trail
|
|
||||||
_currentTrail = importedTrail;
|
_currentTrail = importedTrail;
|
||||||
_isTrailVisible = true;
|
_isTrailVisible = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enter download area selection mode with initial bounds
|
|
||||||
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
||||||
_isSelectingDownloadArea = true;
|
_isSelectingDownloadArea = true;
|
||||||
_downloadAreaBounds = initialBounds;
|
_downloadAreaBounds = initialBounds;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exit download area selection mode
|
|
||||||
void exitDownloadAreaMode() {
|
void exitDownloadAreaMode() {
|
||||||
_isSelectingDownloadArea = false;
|
_isSelectingDownloadArea = false;
|
||||||
_downloadAreaBounds = null;
|
_downloadAreaBounds = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the download area bounds (while dragging/resizing)
|
|
||||||
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
||||||
_downloadAreaBounds = bounds;
|
_downloadAreaBounds = bounds;
|
||||||
notifyListeners();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||