Initial commit: MeshCore Open Flutter client

Open-source Flutter client for MeshCore LoRa mesh networking devices.

Features:
- BLE device scanning and connection
- Nordic UART Service (NUS) integration
- Material 3 design with system theme support
- Provider-based state management
- Placeholder screens for chat, contacts, and settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
zach
2025-12-26 11:42:02 -07:00
commit e7a5b9e209
177 changed files with 20129 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Flutter plugin generated files
.flutter-plugins
.flutter-plugins-dependencies
# Environment and secrets
.env
*.env
secrets.dart
# macOS
.DS_Store
.AppleDouble
.LSOverride
# iOS
**/ios/Pods/
**/ios/.symlinks/
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
# Android
**/android/.gradle/
**/android/captures/
**/android/local.properties
**/android/.externalNativeBuild/
*.jks
keystore.properties
# Generated files
*.g.dart
*.freezed.dart
*.mocks.dart
# IDE
.vscode/launch.json
.vscode/settings.json
+45
View File
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: android
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: ios
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: linux
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: macos
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: web
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: windows
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+34
View File
@@ -0,0 +1,34 @@
# Repository Guidelines
## Project Structure & Module Organization
- Core Flutter code is in `lib/`, with BLE protocol definitions in `lib/connector/meshcore_protocol.dart` and BLE transport/state in `lib/connector/meshcore_connector.dart`.
- UI lives in `lib/screens/` and `lib/widgets/`, models in `lib/models/`, tests in `test/`, and platform runners in `android/`, `ios/`, `macos/`, `linux/`, `windows/`, `web/`.
## BLE Frames & Protocol Notes
- Nordic UART Service (NUS) UUIDs: Service `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, RX `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, TX `6e400003-b5a3-f393-e0a9-e50e24dcca9e`.
- Discovery: scans for device name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`.
- Frames are capped at `maxFrameSize = 172` bytes; byte 0 is the command/response/push code. I/O is `MeshCoreConnector.sendFrame` and `MeshCoreConnector.receivedFrames`.
- Command codes (to device): `cmdAppStart`=1, `cmdSendTxtMsg`=2, `cmdSendChannelTxtMsg`=3, `cmdGetContacts`=4, `cmdGetDeviceTime`=5, `cmdSetDeviceTime`=6, `cmdSendSelfAdvert`=7, `cmdSetAdvertName`=8, `cmdAddUpdateContact`=9, `cmdSyncNextMessage`=10, `cmdSetRadioParams`=11, `cmdSetRadioTxPower`=12, `cmdResetPath`=13, `cmdSetAdvertLatLon`=14, `cmdRemoveContact`=15, `cmdShareContact`=16, `cmdExportContact`=17, `cmdImportContact`=18, `cmdReboot`=19, `cmdSendLogin`=26, `cmdGetChannel`=31, `cmdSetChannel`=32, `cmdGetRadioSettings`=57.
- Response codes (from device): `respCodeOk`=0, `respCodeErr`=1, `respCodeContactsStart`=2, `respCodeContact`=3, `respCodeEndOfContacts`=4, `respCodeSelfInfo`=5, `respCodeSent`=6, `respCodeContactMsgRecv`=7, `respCodeChannelMsgRecv`=8, `respCodeCurrTime`=9, `respCodeNoMoreMessages`=10, `respCodeContactMsgRecvV3`=16, `respCodeChannelMsgRecvV3`=17, `respCodeChannelInfo`=18, `respCodeRadioSettings`=25.
- Push codes (async): `pushCodeAdvert`=0x80, `pushCodePathUpdated`=0x81, `pushCodeSendConfirmed`=0x82, `pushCodeMsgWaiting`=0x83, `pushCodeLoginSuccess`=0x85, `pushCodeLoginFail`=0x86, `pushCodeLogRxData`=0x88, `pushCodeNewAdvert`=0x8A.
- Device info: `cmdAppStart` triggers `respCodeSelfInfo` with tx power, pubkey, lat/lon, telemetry flags, radio params, and node name (see offsets in `lib/connector/meshcore_connector.dart`).
- Radio/time helpers: `cmdGetRadioSettings``respCodeRadioSettings`; `cmdGetDeviceTime``respCodeCurrTime`; `cmdSetDeviceTime` updates device time.
- Reboot: the UI sends `sendCliCommand('reboot')` (the raw `cmdReboot` code exists but no frame builder is wired in yet).
- Companion radio format: `cmdSendTxtMsg` expects `[cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]` (no flags/full pubkey). CLI commands use `txtTypeCliData` in the same format, and the app maps `forceFlood` to attempt `3` when sending.
- Group text packets (`PAYLOAD_TYPE_GRP_TXT`): payload is `[channel_hash (1)][MAC (2)][encrypted data...]`. Decrypted data layout is `[timestamp x4][txt_type][text...]` where text is `"sender: message"` (see MeshCore `BaseChatMesh::sendGroupMessage`). Sender identity is not in the payload; use `PUSH_CODE_LOG_RX_DATA` raw packet path bytes for origin hash when available.
- Identity hash: `PATH_HASH_SIZE` is 1 byte; it is the prefix of the public key (see `Identity::copyHashTo`). Flooded packets append this hash to the path as they traverse hops. Self-identification via log data should compare sender name and presence of self pubkey prefix within the path bytes.
## Build, Test, and Development Commands
- `~/flutter/bin/flutter pub get` installs dependencies (or `flutter pub get` if Flutter is on PATH).
- `~/flutter/bin/flutter run` launches the app; `~/flutter/bin/flutter build apk|ios` produces release builds.
- `~/flutter/bin/flutter analyze` and `~/flutter/bin/flutter test` run linting and tests.
## Coding Style & Naming Conventions
- Follow `flutter_lints`, use `lowerCamelCase`/`UpperCamelCase`/`snake_case`, prefer `StatelessWidget` + `Consumer`, and use `const` constructors.
- Material widgets only; keep screens simple, handle disconnects by returning to the scanner, and avoid premature abstractions.
## Testing Guidelines
- Tests use `flutter_test`; add `*_test.dart` under `test/` and run `flutter test` before UI/protocol changes.
## Commit & Pull Request Guidelines
- Keep commit subjects short and action-focused; PRs should describe behavior changes, link issues, include screenshots for UI changes, and call out BLE protocol changes explicitly.
+136
View File
@@ -0,0 +1,136 @@
# MeshCore Open - Flutter Client
Open-source Flutter client for MeshCore LoRa mesh networking devices.
## Build Commands
```bash
# Install dependencies
~/flutter/bin/flutter pub get
# Run in debug mode
~/flutter/bin/flutter run
# Build Android APK
~/flutter/bin/flutter build apk
# Build iOS
~/flutter/bin/flutter build ios
# Run static analysis
~/flutter/bin/flutter analyze
# Run tests
~/flutter/bin/flutter test
```
## Project Structure
```
lib/
├── main.dart # App entry point, MaterialApp setup with Provider
├── connector/
│ └── meshcore_connector.dart # BLE communication layer (MeshCoreConnector)
├── screens/
│ ├── scanner_screen.dart # BLE device scanning (home screen)
│ ├── device_screen.dart # Connected device hub with navigation
│ ├── chat_screen.dart # Chat interface (placeholder)
│ ├── contacts_screen.dart # Contacts list (placeholder)
│ └── settings_screen.dart # Device info and app settings
└── widgets/
└── device_tile.dart # Device list item with signal strength
```
## Architecture
### State Management
- **Provider** with `ChangeNotifier` pattern
- `MeshCoreConnector` is the central state holder for BLE connection
- Screens use `Consumer<MeshCoreConnector>` for reactive UI updates
### Theming
- Material 3 design (`useMaterial3: true`)
- System-based dark/light mode (`ThemeMode.system`)
- Blue color scheme seed
## BLE Protocol
### Nordic UART Service (NUS)
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
### Device Discovery
- Scans for devices with name prefix `MeshCore-`
- Filters by `platformName` or `advertisementData.advName`
### Connection States
```dart
enum MeshCoreConnectionState {
disconnected,
scanning,
connecting,
connected,
disconnecting,
}
```
### Frame I/O
- **Send**: `MeshCoreConnector.sendFrame(Uint8List data)`
- **Receive**: `MeshCoreConnector.receivedFrames` stream of `Uint8List`
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| flutter_blue_plus | ^2.1.0 | BLE communication |
| provider | ^6.1.5+1 | State management |
| cupertino_icons | ^1.0.8 | iOS-style icons |
## Platform Configuration
### Android (`android/app/src/main/AndroidManifest.xml`)
- `BLUETOOTH`, `BLUETOOTH_ADMIN` (API 30 and below)
- `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (API 31+)
- `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` (for BLE scanning)
### iOS (`ios/Runner/Info.plist`)
- `NSBluetoothAlwaysUsageDescription`
- `NSBluetoothPeripheralUsageDescription`
## Coding Conventions
### Code Philosophy
- **Minimal**: Only write code that is necessary. Avoid over-engineering.
- **Organized**: Keep related code together. One responsibility per file.
- **Maintainable**: Favor readability over cleverness. Simple is better.
### Style
- Use `StatelessWidget` with `Consumer` for state-dependent UI
- Use `const` constructors where possible
- Prefix private methods/fields with `_`
- Center app bar titles (`centerTitle: true`)
- **Material widgets only** - no Cupertino or custom widgets
- Handle disconnection gracefully (auto-navigate back to scanner)
### Avoid
- Premature abstractions - don't create helpers until needed in 3+ places
- Unnecessary comments - code should be self-explanatory
- Feature flags or backwards-compatibility shims
- Over-engineered error handling for impossible scenarios
## Key Files
| File | Purpose |
|------|---------|
| `lib/connector/meshcore_connector.dart` | All BLE logic - scanning, connecting, data transfer |
| `lib/screens/scanner_screen.dart` | Entry point UI, device list |
| `lib/main.dart` | App configuration, theme, Provider setup |
| `pubspec.yaml` | Dependencies and project metadata |
## Placeholder Screens
The following screens are implemented as placeholders and need full implementation:
- `chat_screen.dart` - Mesh chat functionality
- `contacts_screen.dart` - Contact management
- `settings_screen.dart` - Radio settings, node identity, location (partially implemented)
+16
View File
@@ -0,0 +1,16 @@
# meshcore_open
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.meshcore.meshcore_open"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.meshcore.meshcore_open"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+59
View File
@@ -0,0 +1,59 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet permission (required for map tiles) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Bluetooth permissions -->
<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_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<!-- Location permission (required for BLE scanning on Android 11 and below) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- Notification permission (required for Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="meshcore_open"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+616
View File
@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; 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>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+13
View File
@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Meshcore Open</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>meshcore_open</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
import 'dart:convert';
import 'dart:typed_data';
// Command codes (to device)
const int cmdAppStart = 1;
const int cmdSendTxtMsg = 2;
const int cmdSendChannelTxtMsg = 3;
const int cmdGetContacts = 4;
const int cmdGetDeviceTime = 5;
const int cmdSetDeviceTime = 6;
const int cmdSendSelfAdvert = 7;
const int cmdSetAdvertName = 8;
const int cmdAddUpdateContact = 9;
const int cmdSyncNextMessage = 10;
const int cmdSetRadioParams = 11;
const int cmdSetRadioTxPower = 12;
const int cmdResetPath = 13;
const int cmdSetAdvertLatLon = 14;
const int cmdRemoveContact = 15;
const int cmdShareContact = 16;
const int cmdExportContact = 17;
const int cmdImportContact = 18;
const int cmdReboot = 19;
const int cmdGetBattAndStorage = 20;
const int cmdDeviceQuery = 22;
const int cmdSendLogin = 26;
const int cmdSendStatusReq = 27;
const int cmdGetChannel = 31;
const int cmdSetChannel = 32;
const int cmdGetRadioSettings = 57;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
// Repeater request types (for server requests)
const int reqTypeGetStatus = 0x01;
const int reqTypeKeepAlive = 0x02;
const int reqTypeGetTelemetry = 0x03;
const int reqTypeGetAccessList = 0x05;
const int reqTypeGetNeighbours = 0x06;
// Repeater response codes
const int respServerLoginOk = 0;
// Response codes (from device)
const int respCodeOk = 0;
const int respCodeErr = 1;
const int respCodeContactsStart = 2;
const int respCodeContact = 3;
const int respCodeEndOfContacts = 4;
const int respCodeSelfInfo = 5;
const int respCodeSent = 6;
const int respCodeContactMsgRecv = 7;
const int respCodeChannelMsgRecv = 8;
const int respCodeCurrTime = 9;
const int respCodeNoMoreMessages = 10;
const int respCodeBattAndStorage = 12;
const int respCodeDeviceInfo = 13;
const int respCodeContactMsgRecvV3 = 16;
const int respCodeChannelMsgRecvV3 = 17;
const int respCodeChannelInfo = 18;
const int respCodeRadioSettings = 25;
// Push codes (async from device)
const int pushCodeAdvert = 0x80;
const int pushCodePathUpdated = 0x81;
const int pushCodeSendConfirmed = 0x82;
const int pushCodeMsgWaiting = 0x83;
const int pushCodeLoginSuccess = 0x85;
const int pushCodeLoginFail = 0x86;
const int pushCodeStatusResponse = 0x87;
const int pushCodeLogRxData = 0x88;
const int pushCodeNewAdvert = 0x8A;
// Contact/advertisement types
const int advTypeChat = 1;
const int advTypeRepeater = 2;
const int advTypeRoom = 3;
const int advTypeSensor = 4;
// Sizes
const int pubKeySize = 32;
const int maxPathSize = 64;
const int pathHashSize = 1;
const int maxNameSize = 32;
const int maxFrameSize = 172;
const int appProtocolVersion = 3;
// Contact frame offsets
const int contactPubKeyOffset = 1;
const int contactTypeOffset = 33;
const int contactFlagsOffset = 34;
const int contactPathLenOffset = 35;
const int contactPathOffset = 36;
const int contactNameOffset = 100;
const int contactTimestampOffset = 132;
const int contactLatOffset = 136;
const int contactLonOffset = 140;
const int contactLastmodOffset = 144;
const int contactFrameSize = 148;
// Message frame offsets
const int msgPubKeyOffset = 1;
const int msgTimestampOffset = 33;
const int msgFlagsOffset = 37;
const int msgTextOffset = 38;
class ParsedContactText {
final Uint8List senderPrefix;
final String text;
const ParsedContactText({
required this.senderPrefix,
required this.text,
});
}
ParsedContactText? parseContactMessageText(Uint8List frame) {
if (frame.isEmpty) return null;
final code = frame[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
// Companion radio layout:
// [code][snr?][res?][res?][prefix x6][path_len][txt_type][timestamp x4][extra?][text...]
final isV3 = code == respCodeContactMsgRecvV3;
final prefixOffset = isV3 ? 4 : 1;
const prefixLen = 6;
final txtTypeOffset = prefixOffset + prefixLen + 1;
final timestampOffset = txtTypeOffset + 1;
final baseTextOffset = timestampOffset + 4;
if (frame.length <= baseTextOffset) return null;
final flags = frame[txtTypeOffset];
final shiftedType = flags >> 2;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) {
return null;
}
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
if (text.isEmpty && frame.length > baseTextOffset + 4) {
text =
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
}
if (text.isEmpty) return null;
final senderPrefix = frame.sublist(prefixOffset, prefixOffset + prefixLen);
return ParsedContactText(senderPrefix: senderPrefix, text: text);
}
// Helper to read uint32 little-endian
int readUint32LE(Uint8List data, int offset) {
return data[offset] |
(data[offset + 1] << 8) |
(data[offset + 2] << 16) |
(data[offset + 3] << 24);
}
// Helper to read uint16 little-endian
int readUint16LE(Uint8List data, int offset) {
return data[offset] | (data[offset + 1] << 8);
}
// Helper to read int32 little-endian
int readInt32LE(Uint8List data, int offset) {
int val = readUint32LE(data, offset);
if (val >= 0x80000000) val -= 0x100000000;
return val;
}
// Helper to write uint32 little-endian
void writeUint32LE(Uint8List data, int offset, int value) {
data[offset] = value & 0xFF;
data[offset + 1] = (value >> 8) & 0xFF;
data[offset + 2] = (value >> 16) & 0xFF;
data[offset + 3] = (value >> 24) & 0xFF;
}
// Helper to read null-terminated UTF-8 string
String readCString(Uint8List data, int offset, int maxLen) {
int end = offset;
while (end < offset + maxLen && end < data.length && data[end] != 0) {
end++;
}
try {
return utf8.decode(data.sublist(offset, end), allowMalformed: true);
} catch (e) {
// Fallback to Latin-1 if UTF-8 decoding fails
return String.fromCharCodes(data.sublist(offset, end));
}
}
// Helper to convert public key to hex string
String pubKeyToHex(Uint8List pubKey) {
return pubKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
// Helper to convert hex string to public key
Uint8List hexToPubKey(String hex) {
final result = Uint8List(pubKeySize);
for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) {
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return result;
}
// Build CMD_GET_CONTACTS frame
Uint8List buildGetContactsFrame({int? since}) {
if (since != null) {
final frame = Uint8List(5);
frame[0] = cmdGetContacts;
writeUint32LE(frame, 1, since);
return frame;
}
return Uint8List.fromList([cmdGetContacts]);
}
// Build CMD_SEND_LOGIN frame
// Format: [cmd][pub_key x32][password...]\0
Uint8List buildSendLoginFrame(Uint8List recipientPubKey, String password) {
final passwordBytes = utf8.encode(password);
final frame = Uint8List(1 + pubKeySize + passwordBytes.length + 1);
frame[0] = cmdSendLogin;
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
frame.setRange(1 + pubKeySize, 1 + pubKeySize + passwordBytes.length, passwordBytes);
frame[frame.length - 1] = 0;
return frame;
}
// Build CMD_SEND_STATUS_REQ frame
// Format: [cmd][pub_key x32]
Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdSendStatusReq;
frame.setRange(1, 1 + pubKeySize, recipientPubKey);
return frame;
}
// Build CMD_SEND_TXT_MSG frame (companion_radio format)
// Format: [cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0
Uint8List buildSendTextMsgFrame(
Uint8List recipientPubKey,
String text, {
bool forceFlood = false,
int attempt = 0,
int? timestampSeconds,
}) {
final textBytes = utf8.encode(text);
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
const prefixSize = 6;
final safeAttempt = forceFlood ? 3 : (attempt & 0xFF);
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
int offset = 0;
frame[offset++] = cmdSendTxtMsg;
frame[offset++] = txtTypePlain;
frame[offset++] = safeAttempt;
writeUint32LE(frame, offset, timestamp);
offset += 4;
frame.setRange(offset, offset + prefixSize, recipientPubKey.sublist(0, prefixSize));
offset += prefixSize;
frame.setRange(offset, offset + textBytes.length, textBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
// Build CMD_SEND_CHANNEL_TXT_MSG frame
// Format: [cmd][txt_type][channel_idx][timestamp x4][text...]
Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) {
final textBytes = utf8.encode(text);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final frame = Uint8List(1 + 1 + 1 + 4 + textBytes.length + 1);
frame[0] = cmdSendChannelTxtMsg;
frame[1] = 0; // TXT_TYPE_PLAIN
frame[2] = channelIndex;
writeUint32LE(frame, 3, timestamp);
frame.setRange(7, 7 + textBytes.length, textBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
// Build CMD_REMOVE_CONTACT frame
Uint8List buildRemoveContactFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdRemoveContact;
frame.setRange(1, 1 + pubKeySize, pubKey);
return frame;
}
// Build CMD_APP_START frame
// Format: [cmd][reserved x7][app_name...]
Uint8List buildAppStartFrame({String appName = 'MeshCoreOpen'}) {
final nameBytes = utf8.encode(appName);
final frame = Uint8List(8 + nameBytes.length + 1);
frame[0] = cmdAppStart;
// bytes 1-7 are reserved (zeros)
frame.setRange(8, 8 + nameBytes.length, nameBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
// Build CMD_DEVICE_QUERY frame
Uint8List buildDeviceQueryFrame({int appVersion = appProtocolVersion}) {
return Uint8List.fromList([cmdDeviceQuery, appVersion]);
}
// Build CMD_GET_DEVICE_TIME frame
Uint8List buildGetDeviceTimeFrame() {
return Uint8List.fromList([cmdGetDeviceTime]);
}
// Build CMD_GET_BATT_AND_STORAGE frame
Uint8List buildGetBattAndStorageFrame() {
return Uint8List.fromList([cmdGetBattAndStorage]);
}
// Build CMD_SET_DEVICE_TIME frame
Uint8List buildSetDeviceTimeFrame(int timestamp) {
final frame = Uint8List(5);
frame[0] = cmdSetDeviceTime;
writeUint32LE(frame, 1, timestamp);
return frame;
}
// Build CMD_SYNC_NEXT_MESSAGE frame
Uint8List buildSyncNextMessageFrame() {
return Uint8List.fromList([cmdSyncNextMessage]);
}
// Build CMD_GET_CHANNEL frame
Uint8List buildGetChannelFrame(int channelIndex) {
return Uint8List.fromList([cmdGetChannel, channelIndex]);
}
// Build CMD_SET_CHANNEL frame
// Format: [cmd][idx][name x32][psk x16]
Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
final frame = Uint8List(2 + 32 + 16);
frame[0] = cmdSetChannel;
frame[1] = channelIndex;
// Write name (max 32 bytes UTF-8, null-padded)
final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < 32 ? nameBytes.length : 31; // Reserve 1 byte for null
for (int i = 0; i < nameLen; i++) {
frame[2 + i] = nameBytes[i];
}
// frame[2 + nameLen] is already 0 (null terminator)
// Write PSK (16 bytes)
for (int i = 0; i < 16 && i < psk.length; i++) {
frame[34 + i] = psk[i];
}
return frame;
}
// Build CMD_SET_RADIO_PARAMS frame
// Format: [cmd][freq x4][bw x4][sf][cr]
// freq: frequency in Hz (300000-2500000)
// bw: bandwidth in Hz (7000-500000)
// sf: spreading factor (5-12)
// cr: coding rate (5-8)
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
final frame = Uint8List(11);
frame[0] = cmdSetRadioParams;
writeUint32LE(frame, 1, freqHz);
writeUint32LE(frame, 5, bwHz);
frame[9] = sf;
frame[10] = cr;
return frame;
}
// Build CMD_SET_RADIO_TX_POWER frame
// Format: [cmd][power_dbm]
Uint8List buildSetRadioTxPowerFrame(int powerDbm) {
return Uint8List.fromList([cmdSetRadioTxPower, powerDbm]);
}
// Build CMD_RESET_PATH frame
// Format: [cmd][pub_key x32]
Uint8List buildResetPathFrame(Uint8List pubKey) {
final frame = Uint8List(1 + pubKeySize);
frame[0] = cmdResetPath;
frame.setRange(1, 1 + pubKeySize, pubKey);
return frame;
}
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
Uint8List buildUpdateContactPathFrame(
Uint8List pubKey,
Uint8List customPath,
int pathLen, {
int type = 1, // ADV_TYPE_CHAT
int flags = 0,
String name = '',
}) {
// Frame size: 1 + 32 + 1 + 1 + 1 + 64 + 32 + 4 = 136 bytes minimum
final frame = Uint8List(1 + pubKeySize + 1 + 1 + 1 + maxPathSize + maxNameSize + 4);
int offset = 0;
frame[offset++] = cmdAddUpdateContact;
// Public key (32 bytes)
frame.setRange(offset, offset + pubKeySize, pubKey);
offset += pubKeySize;
// Type and flags
frame[offset++] = type;
frame[offset++] = flags;
// Path length and path data
frame[offset++] = pathLen;
if (customPath.isNotEmpty && pathLen > 0) {
final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize;
frame.setRange(offset, offset + copyLen, customPath.sublist(0, copyLen));
}
offset += maxPathSize;
// Name (32 bytes, null-padded)
if (name.isNotEmpty) {
final nameBytes = utf8.encode(name);
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
frame.setRange(offset, offset + nameLen, nameBytes.sublist(0, nameLen));
}
offset += maxNameSize;
// Timestamp (current time)
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
writeUint32LE(frame, offset, timestamp);
return frame;
}
// Build CMD_GET_RADIO_SETTINGS frame
Uint8List buildGetRadioSettingsFrame() {
return Uint8List.fromList([cmdGetRadioSettings]);
}
// Calculate LoRa airtime for a packet
// Based on Semtech SX127x datasheet formula
// Returns airtime in milliseconds
int calculateLoRaAirtime({
required int payloadBytes,
required int spreadingFactor,
required int bandwidthHz,
required int codingRate,
int preambleSymbols = 8,
bool lowDataRateOptimize = false,
bool explicitHeader = true,
}) {
// Symbol duration (Ts) in milliseconds
final symbolDuration = (1 << spreadingFactor) / (bandwidthHz / 1000.0);
// Preamble time
final preambleTime = (preambleSymbols + 4.25) * symbolDuration;
// Payload symbol count
final headerBytes = explicitHeader ? 0 : 20;
final crc = 1; // CRC enabled
final de = lowDataRateOptimize ? 1 : 0;
final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
final denominator = 4 * (spreadingFactor - 2 * de);
var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
if (payloadSymbols < 0) {
payloadSymbols = 8;
}
final payloadTime = payloadSymbols * symbolDuration;
return (preambleTime + payloadTime).ceil();
}
// Calculate timeout for a message based on radio settings
// Returns timeout in milliseconds
int calculateMessageTimeout({
required int freqHz,
required int bwHz,
required int sf,
required int cr,
required int pathLength,
int messageBytes = 100, // Average message size
}) {
// Calculate airtime for one packet
final airtime = calculateLoRaAirtime(
payloadBytes: messageBytes,
spreadingFactor: sf,
bandwidthHz: bwHz,
codingRate: cr,
lowDataRateOptimize: sf >= 11,
);
if (pathLength < 0) {
// Flood mode: Base delay + 16× airtime
return 500 + (16 * airtime);
} else {
// Direct path: Base delay + ((airtime×6 + 250ms)×(hops+1))
return 500 + ((airtime * 6 + 250) * (pathLength + 1));
}
}
// Build CLI command text message frame (companion_radio format)
// Format: [cmd][txt_type][attempt][timestamp x4][pub_key_prefix x6][text...]\0
Uint8List buildSendCliCommandFrame(
Uint8List repeaterPubKey,
String command, {
int attempt = 0,
}) {
final textBytes = utf8.encode(command);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
const prefixSize = 6;
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
int offset = 0;
frame[offset++] = cmdSendTxtMsg;
frame[offset++] = txtTypeCliData;
frame[offset++] = attempt & 0xFF;
writeUint32LE(frame, offset, timestamp);
offset += 4;
frame.setRange(offset, offset + prefixSize, repeaterPubKey.sublist(0, prefixSize));
offset += prefixSize;
frame.setRange(offset, offset + textBytes.length, textBytes);
frame[frame.length - 1] = 0; // null terminator
return frame;
}
+411
View File
@@ -0,0 +1,411 @@
import 'dart:convert';
import 'dart:typed_data';
class Smaz {
static const int _verbatimSingle = 254;
static const int _verbatimRun = 255;
static const List<String> _rcb = [
" ",
"the",
"e",
"t",
"a",
"of",
"o",
"and",
"i",
"n",
"s",
"e ",
"r",
" th",
" t",
"in",
"he",
"th",
"h",
"he ",
"to",
"\r\n",
"l",
"s ",
"d",
" a",
"an",
"er",
"c",
" o",
"d ",
"on",
" of",
"re",
"of ",
"t ",
", ",
"is",
"u",
"at",
" ",
"n ",
"or",
"which",
"f",
"m",
"as",
"it",
"that",
"\n",
"was",
"en",
" ",
" w",
"es",
" an",
" i",
"\r",
"f ",
"g",
"p",
"nd",
" s",
"nd ",
"ed ",
"w",
"ed",
"http://",
"for",
"te",
"ing",
"y ",
"The",
" c",
"ti",
"r ",
"his",
"st",
" in",
"ar",
"nt",
",",
" to",
"y",
"ng",
" h",
"with",
"le",
"al",
"to ",
"b",
"ou",
"be",
"were",
" b",
"se",
"o ",
"ent",
"ha",
"ng ",
"their",
"\"",
"hi",
"from",
" f",
"in ",
"de",
"ion",
"me",
"v",
".",
"ve",
"all",
"re ",
"ri",
"ro",
"is ",
"co",
"f t",
"are",
"ea",
". ",
"her",
" m",
"er ",
" p",
"es ",
"by",
"they",
"di",
"ra",
"ic",
"not",
"s, ",
"d t",
"at ",
"ce",
"la",
"h ",
"ne",
"as ",
"tio",
"on ",
"n t",
"io",
"we",
" a ",
"om",
", a",
"s o",
"ur",
"li",
"ll",
"ch",
"had",
"this",
"e t",
"g ",
"e\r\n",
" wh",
"ere",
" co",
"e o",
"a ",
"us",
" d",
"ss",
"\n\r\n",
"\r\n\r",
"=\"",
" be",
" e",
"s a",
"ma",
"one",
"t t",
"or ",
"but",
"el",
"so",
"l ",
"e s",
"s,",
"no",
"ter",
" wa",
"iv",
"ho",
"e a",
" r",
"hat",
"s t",
"ns",
"ch ",
"wh",
"tr",
"ut",
"/",
"have",
"ly ",
"ta",
" ha",
" on",
"tha",
"-",
" l",
"ati",
"en ",
"pe",
" re",
"there",
"ass",
"si",
" fo",
"wa",
"ec",
"our",
"who",
"its",
"z",
"fo",
"rs",
">",
"ot",
"un",
"<",
"im",
"th ",
"nc",
"ate",
"><",
"ver",
"ad",
" we",
"ly",
"ee",
" n",
"id",
" cl",
"ac",
"il",
"</",
"rt",
" wi",
"div",
"e, ",
" it",
"whi",
" ma",
"ge",
"x",
"e c",
"men",
".com",
];
static final List<Uint8List> _rcbBytes =
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
return entry.length > maxLen ? entry.length : maxLen;
});
static String encodeIfSmaller(String text) {
if (text.isEmpty || text.startsWith('s:')) return text;
final originalBytes = Uint8List.fromList(utf8.encode(text));
final compressed = compressBytes(originalBytes);
final encoded = base64Encode(compressed);
final candidate = 's:$encoded';
if (utf8.encode(candidate).length < originalBytes.length) {
return candidate;
}
return text;
}
static String? tryDecodePrefixed(String text) {
final trimmedLeft = text.trimLeft();
if (!trimmedLeft.startsWith('s:') || trimmedLeft.length <= 2) return null;
final encoded = trimmedLeft.substring(2);
try {
final compressed = _decodeBase64Flexible(encoded);
final decompressed = decompressBytes(compressed);
return utf8.decode(decompressed, allowMalformed: true);
} catch (_) {
return null;
}
}
static Uint8List compressBytes(Uint8List input) {
final out = BytesBuilder(copy: false);
final verbatim = <int>[];
int index = 0;
void flushVerbatim() {
if (verbatim.isEmpty) return;
if (verbatim.length == 1) {
out.addByte(_verbatimSingle);
out.addByte(verbatim[0]);
} else {
out.addByte(_verbatimRun);
out.addByte(verbatim.length - 1);
out.add(verbatim);
}
verbatim.clear();
}
while (index < input.length) {
int bestLen = 0;
int bestCode = -1;
final remaining = input.length - index;
final maxLen = remaining < _maxEntryLen ? remaining : _maxEntryLen;
for (int code = 0; code < _rcbBytes.length; code++) {
final entry = _rcbBytes[code];
final entryLen = entry.length;
if (entryLen == 0 || entryLen > maxLen || entryLen <= bestLen) {
continue;
}
if (_matches(input, index, entry)) {
bestLen = entryLen;
bestCode = code;
if (bestLen == maxLen) {
break;
}
}
}
if (bestCode >= 0) {
flushVerbatim();
out.addByte(bestCode);
index += bestLen;
continue;
}
verbatim.add(input[index]);
index++;
if (verbatim.length == 256) {
flushVerbatim();
}
}
flushVerbatim();
return out.toBytes();
}
static Uint8List decompressBytes(Uint8List input) {
final out = BytesBuilder(copy: false);
int index = 0;
while (index < input.length) {
final code = input[index];
if (code == _verbatimSingle) {
if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
}
out.addByte(input[index + 1]);
index += 2;
} else if (code == _verbatimRun) {
if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
}
final len = input[index + 1] + 1;
final end = index + 2 + len;
if (end > input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
}
out.add(input.sublist(index + 2, end));
index = end;
} else {
if (code >= _rcbBytes.length) {
throw const FormatException('Invalid SMAZ stream: code out of range.');
}
out.add(_rcbBytes[code]);
index += 1;
}
}
return out.toBytes();
}
static bool _matches(Uint8List input, int offset, Uint8List entry) {
final len = entry.length;
if (offset + len > input.length) return false;
for (int i = 0; i < len; i++) {
if (input[offset + i] != entry[i]) return false;
}
return true;
}
static Uint8List _decodeBase64Flexible(String encoded) {
final trimmed = encoded.trim();
try {
return base64Decode(trimmed);
} catch (_) {
// Try base64url with missing padding.
var normalized = trimmed.replaceAll('-', '+').replaceAll('_', '/');
final pad = normalized.length % 4;
if (pad != 0) {
normalized = normalized.padRight(normalized.length + (4 - pad), '=');
}
return base64Decode(normalized);
}
}
}
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'connector/meshcore_connector.dart';
import 'screens/scanner_screen.dart';
import 'services/storage_service.dart';
import 'services/message_retry_service.dart';
import 'services/path_history_service.dart';
import 'services/app_settings_service.dart';
import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize services
final storage = StorageService();
final connector = MeshCoreConnector();
final pathHistoryService = PathHistoryService(storage);
final retryService = MessageRetryService(storage);
final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService();
// Load settings
await appSettingsService.loadSettings();
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
// Wire up connector with services
connector.initialize(
retryService: retryService,
pathHistoryService: pathHistoryService,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
);
await connector.loadContactCache();
await connector.loadChannelSettings();
// Load persisted channel messages
await connector.loadAllChannelMessages();
runApp(MeshCoreApp(
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
));
}
class MeshCoreApp extends StatelessWidget {
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
final StorageService storage;
final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService;
const MeshCoreApp({
super.key,
required this.connector,
required this.retryService,
required this.pathHistoryService,
required this.storage,
required this.appSettingsService,
required this.bleDebugLogService,
});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connector),
ChangeNotifierProvider.value(value: retryService),
ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService),
Provider.value(value: storage),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
return MaterialApp(
title: 'MeshCore Open',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
home: const ScannerScreen(),
);
},
),
);
}
ThemeMode _themeModeFromSetting(String value) {
switch (value) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
}
+108
View File
@@ -0,0 +1,108 @@
class AppSettings {
final bool clearPathOnMaxRetry;
final bool mapShowRepeaters;
final bool mapShowChatNodes;
final bool mapShowOtherNodes;
final double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled;
final String mapKeyPrefix;
final bool mapShowMarkers;
final bool notificationsEnabled;
final bool notifyOnNewMessage;
final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled;
final String themeMode;
final Map<String, String> batteryChemistryByDeviceId;
AppSettings({
this.clearPathOnMaxRetry = false,
this.mapShowRepeaters = true,
this.mapShowChatNodes = true,
this.mapShowOtherNodes = true,
this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '',
this.mapShowMarkers = true,
this.notificationsEnabled = true,
this.notifyOnNewMessage = true,
this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false,
this.themeMode = 'system',
Map<String, String>? batteryChemistryByDeviceId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
Map<String, dynamic> toJson() {
return {
'clear_path_on_max_retry': clearPathOnMaxRetry,
'map_show_repeaters': mapShowRepeaters,
'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes,
'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix,
'map_show_markers': mapShowMarkers,
'notifications_enabled': notificationsEnabled,
'notify_on_new_message': notifyOnNewMessage,
'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled,
'theme_mode': themeMode,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
};
}
factory AppSettings.fromJson(Map<String, dynamic> json) {
return AppSettings(
clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false,
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system',
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
) ??
{},
);
}
AppSettings copyWith({
bool? clearPathOnMaxRetry,
bool? mapShowRepeaters,
bool? mapShowChatNodes,
bool? mapShowOtherNodes,
double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
bool? mapShowMarkers,
bool? notificationsEnabled,
bool? notifyOnNewMessage,
bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled,
String? themeMode,
Map<String, String>? batteryChemistryByDeviceId,
}) {
return AppSettings(
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
mapShowRepeaters: mapShowRepeaters ?? this.mapShowRepeaters,
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'dart:convert';
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class Channel {
final int index;
final String name;
final Uint8List psk; // 16 bytes
Channel({
required this.index,
required this.name,
required this.psk,
});
String get pskBase64 => base64Encode(psk);
bool get isEmpty => name.isEmpty && psk.every((b) => b == 0);
bool get isPublicChannel => pskBase64 == publicChannelPsk;
static Channel? fromFrame(Uint8List data) {
// CHANNEL_INFO format:
// [0] = RESP_CODE_CHANNEL_INFO (18)
// [1] = channel_idx
// [2-33] = name (32 bytes, null-terminated)
// [34-49] = psk (16 bytes)
if (data.length < 50) return null;
if (data[0] != respCodeChannelInfo) return null;
final index = data[1];
final name = readCString(data, 2, 32);
final psk = Uint8List.fromList(data.sublist(34, 50));
return Channel(index: index, name: name, psk: psk);
}
static Channel empty(int index) {
return Channel(
index: index,
name: '',
psk: Uint8List(16),
);
}
static Channel fromPsk(int index, String name, String pskBase64) {
final pskBytes = base64Decode(pskBase64);
final psk = Uint8List(16);
for (int i = 0; i < pskBytes.length && i < 16; i++) {
psk[i] = pskBytes[i];
}
return Channel(index: index, name: name, psk: psk);
}
static const String publicChannelPsk = 'izOH6cXN6mrJ5e26oRXNcg==';
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
import '../helpers/smaz.dart';
enum ChannelMessageStatus { pending, sent, failed }
class Repeat {
final Uint8List? repeaterKey;
final String repeaterName;
final int tripTimeMs;
final List<Uint8List>? path;
Repeat({
this.repeaterKey,
required this.repeaterName,
required this.tripTimeMs,
this.path,
});
String? get repeaterKeyHex =>
repeaterKey != null ? pubKeyToHex(repeaterKey!) : null;
}
class ChannelMessage {
final Uint8List? senderKey;
final String senderName;
final String text;
final DateTime timestamp;
final bool isOutgoing;
final ChannelMessageStatus status;
final List<Repeat> repeats;
final int repeatCount;
final int? pathLength;
final Uint8List pathBytes;
final int? channelIndex;
ChannelMessage({
this.senderKey,
required this.senderName,
required this.text,
required this.timestamp,
required this.isOutgoing,
this.status = ChannelMessageStatus.pending,
this.repeats = const [],
this.repeatCount = 0,
this.pathLength,
Uint8List? pathBytes,
this.channelIndex,
}) : pathBytes = pathBytes ?? Uint8List(0);
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
ChannelMessage copyWith({
ChannelMessageStatus? status,
List<Repeat>? repeats,
int? repeatCount,
int? pathLength,
Uint8List? pathBytes,
}) {
return ChannelMessage(
senderKey: senderKey,
senderName: senderName,
text: text,
timestamp: timestamp,
isOutgoing: isOutgoing,
status: status ?? this.status,
repeats: repeats ?? this.repeats,
repeatCount: repeatCount ?? this.repeatCount,
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
channelIndex: channelIndex,
);
}
static ChannelMessage? fromFrame(Uint8List data) {
// CHANNEL_MSG_RECV format varies by version:
// V3: [0]=code [1]=SNR [2]=rsv1 [3]=rsv2 [4]=channel_idx [5]=path_len [path... optional] [txt_type] [timestamp x4] [text...]
// Non-V3: [0]=code [1]=channel_idx [2]=path_len [3]=txt_type [4-7]=timestamp [8+]=text
if (data.length < 8) return null;
final code = data[0];
if (code != respCodeChannelMsgRecv && code != respCodeChannelMsgRecvV3) {
return null;
}
int timestampOffset, textOffset, pathLenOffset, txtTypeOffset;
Uint8List pathBytes = Uint8List(0);
int channelIdx;
if (code == respCodeChannelMsgRecvV3) {
channelIdx = data[4];
pathLenOffset = 5;
final pathLen = data[pathLenOffset].toSigned(8);
var cursor = 6;
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
txtTypeOffset = cursor;
cursor += 1; // txt_type
timestampOffset = cursor;
textOffset = cursor + 4;
} else {
channelIdx = data[1];
pathLenOffset = 2;
txtTypeOffset = 3;
timestampOffset = 4;
textOffset = 8;
}
if (data.length < textOffset + 1) return null;
final txtType = data[txtTypeOffset];
if (txtType != txtTypePlain) {
return null;
}
final pathLen = data[pathLenOffset].toSigned(8);
final timestampRaw = readUint32LE(data, timestampOffset);
final text = readCString(data, textOffset, data.length - textOffset);
// Extract sender name and actual message from "name: msg" format
String senderName = 'Unknown';
String actualText = text;
final colonIndex = text.indexOf(':');
if (colonIndex > 0 && colonIndex < text.length - 1 && colonIndex < 50) {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
actualText = text.substring(offset);
}
}
final decodedText = Smaz.tryDecodePrefixed(actualText) ?? actualText;
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
status: ChannelMessageStatus.sent,
pathLength: pathLen,
pathBytes: pathBytes,
channelIndex: channelIdx,
);
}
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
return ChannelMessage(
senderKey: null,
senderName: senderName,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
status: ChannelMessageStatus.pending,
pathLength: null,
pathBytes: Uint8List(0),
channelIndex: channelIndex,
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
class Contact {
final Uint8List publicKey;
final String name;
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops
final Uint8List path;
final double? latitude;
final double? longitude;
final DateTime lastSeen;
Contact({
required this.publicKey,
required this.name,
required this.type,
required this.pathLength,
required this.path,
this.latitude,
this.longitude,
required this.lastSeen,
});
String get publicKeyHex => pubKeyToHex(publicKey);
String get typeLabel {
switch (type) {
case advTypeChat:
return 'Chat';
case advTypeRepeater:
return 'Repeater';
case advTypeRoom:
return 'Room';
case advTypeSensor:
return 'Sensor';
default:
return 'Unknown';
}
}
String get pathLabel {
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
bool get hasLocation => latitude != null && longitude != null;
String get pathIdList {
if (path.isEmpty) return '';
final parts = <String>[];
final groupSize = pathHashSize;
for (int i = 0; i < path.length; i += groupSize) {
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length;
final chunk = path.sublist(i, end);
parts.add(
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
);
}
return parts.join(',');
}
static Contact? fromFrame(Uint8List data) {
if (data.length < contactFrameSize) return null;
if (data[0] != respCodeContact) return null;
final pubKey = Uint8List.fromList(
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
);
final type = data[contactTypeOffset];
final pathLen = data[contactPathLenOffset].toSigned(8);
final safePathLen = pathLen > 0
? (pathLen > maxPathSize ? maxPathSize : pathLen)
: 0;
final pathBytes = safePathLen > 0
? Uint8List.fromList(
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
)
: Uint8List(0);
final name = readCString(data, contactNameOffset, maxNameSize);
final lastmod = readUint32LE(data, contactLastmodOffset);
double? lat, lon;
final latRaw = readInt32LE(data, contactLatOffset);
final lonRaw = readInt32LE(data, contactLonOffset);
if (latRaw != 0 || lonRaw != 0) {
lat = latRaw / 1e6;
lon = lonRaw / 1e6;
}
return Contact(
publicKey: pubKey,
name: name.isEmpty ? 'Unknown' : name,
type: type,
pathLength: pathLen,
path: pathBytes,
latitude: lat,
longitude: lon,
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
);
}
@override
bool operator ==(Object other) =>
other is Contact && publicKeyHex == other.publicKeyHex;
@override
int get hashCode => publicKeyHex.hashCode;
}
+37
View File
@@ -0,0 +1,37 @@
class ContactGroup {
final String name;
final List<String> memberKeys;
const ContactGroup({
required this.name,
required this.memberKeys,
});
ContactGroup copyWith({
String? name,
List<String>? memberKeys,
}) {
return ContactGroup(
name: name ?? this.name,
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'members': memberKeys,
};
}
factory ContactGroup.fromJson(Map<String, dynamic> json) {
final members = (json['members'] as List?)
?.map((value) => value.toString())
.toList() ??
<String>[];
return ContactGroup(
name: json['name'] as String? ?? '',
memberKeys: members,
);
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'dart:typed_data';
import '../connector/meshcore_protocol.dart';
enum MessageStatus { pending, sent, delivered, failed }
class Message {
final Uint8List senderKey;
final String text;
final DateTime timestamp;
final bool isOutgoing;
final bool isCli;
final MessageStatus status;
// NEW: Retry logic fields
final String? messageId;
final int retryCount;
final int? estimatedTimeoutMs;
final Uint8List? expectedAckHash;
final DateTime? sentAt;
final DateTime? deliveredAt;
final int? tripTimeMs;
final bool forceFlood;
final int? pathLength;
final Uint8List pathBytes;
Message({
required this.senderKey,
required this.text,
required this.timestamp,
required this.isOutgoing,
this.isCli = false,
this.status = MessageStatus.pending,
this.messageId,
this.retryCount = 0,
this.estimatedTimeoutMs,
this.expectedAckHash,
this.sentAt,
this.deliveredAt,
this.tripTimeMs,
this.forceFlood = false,
this.pathLength,
Uint8List? pathBytes,
}) : pathBytes = pathBytes ?? Uint8List(0);
String get senderKeyHex => pubKeyToHex(senderKey);
Message copyWith({
MessageStatus? status,
int? retryCount,
int? estimatedTimeoutMs,
Uint8List? expectedAckHash,
DateTime? sentAt,
DateTime? deliveredAt,
int? tripTimeMs,
int? pathLength,
Uint8List? pathBytes,
bool? isCli,
}) {
return Message(
senderKey: senderKey,
text: text,
timestamp: timestamp,
isOutgoing: isOutgoing,
isCli: isCli ?? this.isCli,
status: status ?? this.status,
messageId: messageId,
retryCount: retryCount ?? this.retryCount,
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
expectedAckHash: expectedAckHash ?? this.expectedAckHash,
sentAt: sentAt ?? this.sentAt,
deliveredAt: deliveredAt ?? this.deliveredAt,
tripTimeMs: tripTimeMs ?? this.tripTimeMs,
forceFlood: forceFlood,
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
);
}
static Message? fromFrame(Uint8List data, Uint8List selfPubKey) {
if (data.length < msgTextOffset + 1) return null;
final code = data[0];
if (code != respCodeContactMsgRecv && code != respCodeContactMsgRecvV3) {
return null;
}
final senderKey = Uint8List.fromList(
data.sublist(msgPubKeyOffset, msgPubKeyOffset + pubKeySize),
);
final timestampRaw = readUint32LE(data, msgTimestampOffset);
final flags = data[msgFlagsOffset];
if ((flags >> 2) != txtTypePlain) {
return null;
}
final text = readCString(data, msgTextOffset, data.length - msgTextOffset);
return Message(
senderKey: senderKey,
text: text,
timestamp: DateTime.fromMillisecondsSinceEpoch(timestampRaw * 1000),
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
pathBytes: Uint8List(0),
);
}
static Message outgoing(
Uint8List recipientKey,
String text, {
int? pathLength,
Uint8List? pathBytes,
}) {
return Message(
senderKey: recipientKey,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
isCli: false,
status: MessageStatus.pending,
pathLength: pathLength,
pathBytes: pathBytes,
);
}
}
+85
View File
@@ -0,0 +1,85 @@
class PathRecord {
final int hopCount;
final int tripTimeMs;
final DateTime timestamp;
final bool wasFloodDiscovery;
final List<int> pathBytes;
final int successCount;
final int failureCount;
PathRecord({
required this.hopCount,
required this.tripTimeMs,
required this.timestamp,
required this.wasFloodDiscovery,
required this.pathBytes,
required this.successCount,
required this.failureCount,
});
String get displayText =>
'$hopCount ${hopCount == 1 ? 'hop' : 'hops'} - ${(tripTimeMs / 1000).toStringAsFixed(2)}s';
Map<String, dynamic> toJson() {
return {
'hop_count': hopCount,
'trip_time_ms': tripTimeMs,
'timestamp': timestamp.toIso8601String(),
'was_flood': wasFloodDiscovery,
'path_bytes': pathBytes,
'success_count': successCount,
'failure_count': failureCount,
};
}
factory PathRecord.fromJson(Map<String, dynamic> json) {
return PathRecord(
hopCount: json['hop_count'] as int,
tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
wasFloodDiscovery: json['was_flood'] as bool,
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
);
}
}
class ContactPathHistory {
final String contactPubKeyHex;
final List<PathRecord> recentPaths;
ContactPathHistory({
required this.contactPubKeyHex,
required this.recentPaths,
});
PathRecord? get fastest {
if (recentPaths.isEmpty) return null;
return recentPaths.reduce((a, b) => a.tripTimeMs < b.tripTimeMs ? a : b);
}
PathRecord? get mostRecent {
if (recentPaths.isEmpty) return null;
return recentPaths.first;
}
Map<String, dynamic> toJson() {
return {
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
};
}
factory ContactPathHistory.fromJson(
String contactPubKeyHex, Map<String, dynamic> json) {
final pathsList = (json['recent_paths'] as List?)
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
.toList() ??
[];
return ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: pathsList,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
class PathSelection {
final List<int> pathBytes;
final int hopCount;
final bool useFlood;
const PathSelection({
required this.pathBytes,
required this.hopCount,
required this.useFlood,
});
}
+107
View File
@@ -0,0 +1,107 @@
enum LoRaBandwidth {
bw7_8(7800, '7.8 kHz'),
bw10_4(10400, '10.4 kHz'),
bw15_6(15600, '15.6 kHz'),
bw20_8(20800, '20.8 kHz'),
bw31_25(31250, '31.25 kHz'),
bw41_7(41700, '41.7 kHz'),
bw62_5(62500, '62.5 kHz'),
bw125(125000, '125 kHz'),
bw250(250000, '250 kHz'),
bw500(500000, '500 kHz');
final int hz;
final String label;
const LoRaBandwidth(this.hz, this.label);
}
enum LoRaSpreadingFactor {
sf5(5, 'SF5'),
sf6(6, 'SF6'),
sf7(7, 'SF7'),
sf8(8, 'SF8'),
sf9(9, 'SF9'),
sf10(10, 'SF10'),
sf11(11, 'SF11'),
sf12(12, 'SF12');
final int value;
final String label;
const LoRaSpreadingFactor(this.value, this.label);
}
enum LoRaCodingRate {
cr4_5(5, '4/5'),
cr4_6(6, '4/6'),
cr4_7(7, '4/7'),
cr4_8(8, '4/8');
final int value;
final String label;
const LoRaCodingRate(this.value, this.label);
}
class RadioSettings {
final double frequencyMHz;
final LoRaBandwidth bandwidth;
final LoRaSpreadingFactor spreadingFactor;
final LoRaCodingRate codingRate;
final int txPowerDbm;
RadioSettings({
required this.frequencyMHz,
required this.bandwidth,
required this.spreadingFactor,
required this.codingRate,
required this.txPowerDbm,
});
// Preset configurations
static RadioSettings get preset915MHz => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get preset868MHz => RadioSettings(
frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
);
static RadioSettings get preset433MHz => RadioSettings(
frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get presetLongRange => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
);
static RadioSettings get presetFastSpeed => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
int get frequencyHz => (frequencyMHz * 1000).round();
int get bandwidthHz => bandwidth.hz;
int get sf => spreadingFactor.value;
int get cr => codingRate.value;
}
+484
View File
@@ -0,0 +1,484 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../services/app_settings_service.dart';
import '../services/notification_service.dart';
class AppSettingsScreen extends StatelessWidget {
const AppSettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('App Settings'),
centerTitle: true,
),
body: Consumer2<AppSettingsService, MeshCoreConnector>(
builder: (context, settingsService, connector, child) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildAppearanceCard(context, settingsService),
const SizedBox(height: 16),
_buildNotificationsCard(context, settingsService),
const SizedBox(height: 16),
_buildMessagingCard(context, settingsService),
const SizedBox(height: 16),
_buildBatteryCard(context, settingsService, connector),
const SizedBox(height: 16),
_buildMapSettingsCard(context, settingsService),
],
);
},
),
);
}
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Appearance',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
title: const Text('Theme'),
subtitle: Text(_themeModeLabel(settingsService.settings.themeMode)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService),
),
],
),
);
}
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Notifications',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: const Text('Enable Notifications'),
subtitle: const Text('Receive notifications for messages and adverts'),
value: settingsService.settings.notificationsEnabled,
onChanged: (value) async {
if (value) {
// Request permission when enabling
final granted = await NotificationService().requestPermissions();
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Notification permission denied'),
duration: Duration(seconds: 2),
),
);
}
return;
}
}
await settingsService.setNotificationsEnabled(value);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Notifications enabled'
: 'Notifications disabled'),
duration: const Duration(seconds: 2),
),
);
}
},
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.message_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Message Notifications',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when receiving new messages',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
value: settingsService.settings.notifyOnNewMessage,
onChanged: settingsService.settings.notificationsEnabled
? (value) {
settingsService.setNotifyOnNewMessage(value);
}
: null,
),
const Divider(height: 1),
SwitchListTile(
secondary: Icon(
Icons.cell_tower,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
title: Text(
'Advertisement Notifications',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
subtitle: Text(
'Show notification when new nodes are discovered',
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
),
),
value: settingsService.settings.notifyOnNewAdvert,
onChanged: settingsService.settings.notificationsEnabled
? (value) {
settingsService.setNotifyOnNewAdvert(value);
}
: null,
),
],
),
);
}
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Messaging',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.refresh_outlined),
title: const Text('Clear Path on Max Retry'),
subtitle: const Text('Reset contact path after 5 failed send attempts'),
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Paths will be cleared after 5 failed retries'
: 'Paths will not be auto-cleared'),
duration: const Duration(seconds: 2),
),
);
},
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.alt_route),
title: const Text('Auto Route Rotation'),
subtitle: const Text('Cycle between best paths and flood mode'),
value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'Auto route rotation enabled'
: 'Auto route rotation disabled'),
duration: const Duration(seconds: 2),
),
);
},
),
],
),
);
}
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Map Display',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Show Repeaters'),
subtitle: const Text('Display repeater nodes on the map'),
value: settingsService.settings.mapShowRepeaters,
onChanged: (value) {
settingsService.setMapShowRepeaters(value);
},
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.chat_outlined),
title: const Text('Show Chat Nodes'),
subtitle: const Text('Display chat nodes on the map'),
value: settingsService.settings.mapShowChatNodes,
onChanged: (value) {
settingsService.setMapShowChatNodes(value);
},
),
const Divider(height: 1),
SwitchListTile(
secondary: const Icon(Icons.people_outline),
title: const Text('Show Other Nodes'),
subtitle: const Text('Display other node types on the map'),
value: settingsService.settings.mapShowOtherNodes,
onChanged: (value) {
settingsService.setMapShowOtherNodes(value);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.timer_outlined),
title: const Text('Time Filter'),
subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0
? 'Show all nodes'
: 'Show nodes from last ${settingsService.settings.mapTimeFilterHours.toInt()} hours',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService),
),
],
),
);
}
Widget _buildBatteryCard(
BuildContext context,
AppSettingsService settingsService,
MeshCoreConnector connector,
) {
final deviceId = connector.device?.remoteId.toString();
final isConnected = connector.isConnected && deviceId != null;
final selection =
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Battery',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.battery_full),
title: const Text('Battery Chemistry'),
subtitle: Text(
isConnected
? 'Set per device (${connector.device!.platformName})'
: 'Connect to a device to choose',
),
trailing: DropdownButton<String>(
value: selection,
onChanged: isConnected
? (value) {
if (value != null) {
settingsService.setBatteryChemistryForDevice(deviceId, value);
}
}
: null,
items: const [
DropdownMenuItem(
value: 'nmc',
child: Text('18650 NMC (3.0-4.2V)'),
),
DropdownMenuItem(
value: 'lifepo4',
child: Text('LiFePO4 (2.6-3.65V)'),
),
DropdownMenuItem(
value: 'lipo',
child: Text('LiPo (3.0-4.2V)'),
),
],
),
),
],
),
);
}
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<String>(
title: const Text('System default'),
value: 'system',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('Light'),
value: 'light',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
RadioListTile<String>(
title: const Text('Dark'),
value: 'dark',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
String _themeModeLabel(String value) {
switch (value) {
case 'light':
return 'Light';
case 'dark':
return 'Dark';
default:
return 'System default';
}
}
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Map Time Filter'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Show nodes discovered within:'),
const SizedBox(height: 16),
ListTile(
title: const Text('All time'),
leading: Radio<double>(
value: 0,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last hour'),
leading: Radio<double>(
value: 1,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last 6 hours'),
leading: Radio<double>(
value: 6,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last 24 hours'),
leading: Radio<double>(
value: 24,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
ListTile(
title: const Text('Last week'),
leading: Radio<double>(
value: 168,
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
}
+384
View File
@@ -0,0 +1,384 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter/services.dart';
import '../services/ble_debug_log_service.dart';
import '../connector/meshcore_protocol.dart';
enum _BleLogView { frames, rawLogRx }
class BleDebugLogScreen extends StatefulWidget {
const BleDebugLogScreen({super.key});
@override
State<BleDebugLogScreen> createState() => _BleDebugLogScreenState();
}
class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
_BleLogView _view = _BleLogView.frames;
@override
Widget build(BuildContext context) {
return Consumer<BleDebugLogService>(
builder: (context, logService, _) {
final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('BLE Debug Log'),
actions: [
IconButton(
tooltip: 'Copy log',
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
final text = showingFrames
? entries
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
.join('\n')
: rawEntries
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('BLE log copied')),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
logService.clear();
}
: null,
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: const [
ButtonSegment(value: _BleLogView.frames, label: Text('Frames')),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text('Raw Log-RX')),
],
selected: {_view},
onSelectionChanged: (selection) {
setState(() => _view = selection.first);
},
),
),
const SizedBox(height: 8),
Expanded(
child: hasEntries
? ListView.separated(
itemCount: showingFrames ? entries.length : rawEntries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (showingFrames) {
final entry = entries[index];
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(entry.description),
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing ? Icons.upload : Icons.download,
size: 18,
),
);
}
final entry = rawEntries[index];
final info = _decodeRawPacket(entry.payload);
final time =
'${entry.timestamp.hour.toString().padLeft(2, '0')}:${entry.timestamp.minute.toString().padLeft(2, '0')}:${entry.timestamp.second.toString().padLeft(2, '0')}';
return ListTile(
dense: true,
title: Text(info.title),
subtitle: Text('${info.summary}\n$time'),
isThreeLine: true,
leading: const Icon(Icons.download, size: 18),
onTap: () => _showRawDialog(context, info),
);
},
)
: const Center(
child: Text('No BLE activity yet'),
),
),
],
),
);
},
);
}
void _showRawDialog(BuildContext context, _RawPacketInfo info) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(info.title),
content: SingleChildScrollView(
child: SelectableText(info.rawHex),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
_RawPacketInfo _decodeRawPacket(Uint8List raw) {
if (raw.length < 2) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • invalid',
summary: 'Packet too short',
rawHex: _bytesToHex(raw),
);
}
var index = 0;
final header = raw[index++];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
final payloadVer = (header >> 6) & 0x03;
final hasTransport = routeType == 0 || routeType == 3;
if (hasTransport) {
if (raw.length < index + 4) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing transport codes',
rawHex: _bytesToHex(raw),
);
}
index += 4;
}
if (raw.length <= index) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing path length',
rawHex: _bytesToHex(raw),
);
}
final pathLen = raw[index++];
if (raw.length < index + pathLen) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Truncated path',
rawHex: _bytesToHex(raw),
);
}
final pathBytes = raw.sublist(index, index + pathLen);
index += pathLen;
if (raw.length <= index) {
return _RawPacketInfo(
title: 'RX RAW_LOG_RX_DATA • ${_payloadTypeLabel(payloadType)}',
summary: 'Missing payload',
rawHex: _bytesToHex(raw),
);
}
final payload = raw.sublist(index);
final title = 'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final summary = _decodePayloadSummary(payloadType, payload);
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
final detail = '$summary$pathSummary • len=${raw.length}';
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
}
String _decodePayloadSummary(int payloadType, Uint8List payload) {
switch (payloadType) {
case 0x00: // REQ
return 'REQ payload=${payload.length} bytes';
case 0x01: // RESP
return 'RESP payload=${payload.length} bytes';
case 0x02: // TXT
return 'TXT payload=${payload.length} bytes';
case 0x03: // ACK
if (payload.length < 4) return 'ACK (short)';
return 'ACK crc=${_bytesToHex(payload.sublist(0, 4))}';
case 0x04: // ADVERT
return _decodeAdvertSummary(payload);
case 0x05: // GROUP_TXT
if (payload.length < 3) return 'GRP_TXT (short)';
final channelHash = payload[0].toRadixString(16).padLeft(2, '0');
final mac = _bytesToHex(payload.sublist(1, 3));
final cipherLen = payload.length - 3;
return 'GRP_TXT hash=$channelHash mac=$mac cipher=$cipherLen';
case 0x06: // GROUP_DATA
return 'GRP_DATA payload=${payload.length} bytes';
case 0x07: // ANON_REQ
return 'ANON_REQ payload=${payload.length} bytes';
case 0x08: // PATH
return 'PATH payload=${payload.length} bytes';
case 0x09: // TRACE
return 'TRACE payload=${payload.length} bytes';
case 0x0A: // MULTIPART
return 'MULTIPART payload=${payload.length} bytes';
case 0x0B: // CONTROL
return _decodeControlSummary(payload);
case 0x0F: // RAW
return 'RAW payload=${payload.length} bytes';
default:
return 'TYPE_$payloadType payload=${payload.length} bytes';
}
}
String _decodeAdvertSummary(Uint8List payload) {
if (payload.length < 101) {
return 'ADVERT (short)';
}
var offset = 0;
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
offset += 32;
final timestamp = readUint32LE(payload, offset);
offset += 4;
offset += 64; // signature
final flags = payload[offset++];
final role = _deviceRoleLabel(flags & 0x0F);
final hasLocation = (flags & 0x10) != 0;
final hasFeature1 = (flags & 0x20) != 0;
final hasFeature2 = (flags & 0x40) != 0;
final hasName = (flags & 0x80) != 0;
String? name;
double? lat;
double? lon;
if (hasLocation && payload.length >= offset + 8) {
lat = readInt32LE(payload, offset) / 1000000.0;
lon = readInt32LE(payload, offset + 4) / 1000000.0;
offset += 8;
}
if (hasFeature1) offset += 2;
if (hasFeature2) offset += 2;
if (hasName && payload.length > offset) {
final rawName = String.fromCharCodes(payload.sublist(offset));
final nul = rawName.indexOf('\u0000');
name = nul >= 0 ? rawName.substring(0, nul) : rawName;
name = name.trim();
}
final namePart = (name != null && name.isNotEmpty) ? ' name="$name"' : '';
final locPart = (lat != null && lon != null)
? ' loc=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}'
: '';
return 'ADVERT role=$role ts=$timestamp$namePart$locPart key=${pubKey.substring(0, 12)}';
}
String _decodeControlSummary(Uint8List payload) {
if (payload.isEmpty) return 'CONTROL (empty)';
final flags = payload[0];
final subType = flags & 0xF0;
if (subType == 0x80) {
if (payload.length < 6) return 'CONTROL DISCOVER_REQ (short)';
final typeFilter = payload[1];
final tag = readUint32LE(payload, 2);
final since = payload.length >= 10 ? readUint32LE(payload, 6) : 0;
return 'CONTROL DISCOVER_REQ filter=0x${typeFilter.toRadixString(16).padLeft(2, '0')} tag=$tag since=$since';
}
if (subType == 0x90) {
if (payload.length < 14) return 'CONTROL DISCOVER_RESP (short)';
final nodeType = flags & 0x0F;
final snrRaw = payload[1];
final snrSigned = snrRaw > 127 ? snrRaw - 256 : snrRaw;
final snr = snrSigned / 4.0;
final tag = readUint32LE(payload, 2);
final keyLen = payload.length - 6;
return 'CONTROL DISCOVER_RESP node=${_deviceRoleLabel(nodeType)} snr=${snr.toStringAsFixed(2)} tag=$tag key=$keyLen';
}
return 'CONTROL subtype=0x${subType.toRadixString(16).padLeft(2, '0')}';
}
String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
case 0x0F:
return 'RAW';
default:
return 'TYPE_$payloadType';
}
}
String _routeLabel(int routeType) {
switch (routeType) {
case 0:
return 'TRANS_FLOOD';
case 1:
return 'FLOOD';
case 2:
return 'DIRECT';
case 3:
return 'TRANS_DIRECT';
default:
return 'ROUTE_$routeType';
}
}
String _deviceRoleLabel(int role) {
switch (role) {
case 0x01:
return 'Chat';
case 0x02:
return 'Repeater';
case 0x03:
return 'Room';
case 0x04:
return 'Sensor';
default:
return 'Unknown';
}
}
String _bytesToHex(Uint8List bytes, {bool spaced = true}) {
if (bytes.isEmpty) return '';
if (!spaced) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
}
class _RawPacketInfo {
final String title;
final String summary;
final String rawHex;
_RawPacketInfo({
required this.title,
required this.summary,
required this.rawHex,
});
}
+555
View File
@@ -0,0 +1,555 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
const ChannelChatScreen({
super.key,
required this.channel,
});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
}
class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Icon(
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channel.name.isEmpty
? 'Channel ${widget.channel.index}'
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
Text(
widget.channel.isPublicChannel ? 'Public' : 'Private',
style: const TextStyle(fontSize: 12),
),
],
),
),
],
),
centerTitle: false,
),
body: Column(
children: [
Expanded(
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.channel.isPublicChannel
? Icons.public
: Icons.tag,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'No messages yet',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Send a message to get started',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
cacheExtent: 0,
addAutomaticKeepAlives: false,
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return _buildMessageBubble(message);
},
);
},
),
),
_buildMessageComposer(),
],
),
);
}
Widget _buildMessageBubble(ChannelMessage message) {
final isOutgoing = message.isOutgoing;
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
],
Flexible(
child: GestureDetector(
onLongPress: () => _showMessagePathInfo(message),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
)
else
Text(
message.text,
style: const TextStyle(fontSize: 14),
),
if (message.pathBytes.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'via ${_formatPathPrefixes(message.pathBytes)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(Icons.repeat, size: 12, color: Colors.grey[600]),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status == ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color: message.status == ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
],
),
),
),
),
],
),
);
}
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
return match?.group(1);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
if (lat == null || lon == null) return null;
final label = match.group(3) ?? '';
return _PoiInfo(lat: lat, lon: lon, label: label);
}
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme;
final textColor =
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon),
highlightLabel: poi.label,
),
),
);
},
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'POI Shared',
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(
color: metaColor,
fontSize: 12,
),
),
],
),
),
],
);
}
void _showGifPicker(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => GifPicker(
onGifSelected: (gifId) {
_textController.text = 'g:$gifId';
},
),
);
}
Widget _buildAvatar(String senderName) {
final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
String _getFirstCharacterOrEmoji(String name) {
if (name.isEmpty) return '?';
final emoji = firstEmoji(name);
if (emoji != null) return emoji;
final runes = name.runes.toList();
if (runes.isEmpty) return '?';
return String.fromCharCode(runes[0]).toUpperCase();
}
Color _getColorForName(String name) {
// Generate a consistent color based on the name hash
final hash = name.hashCode;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.teal,
Colors.indigo,
Colors.cyan,
Colors.amber,
Colors.deepOrange,
];
return colors[hash.abs() % colors.length];
}
Widget _buildMessageComposer() {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
width: 160,
height: 110,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
);
}
return TextField(
controller: _textController,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
),
);
}
void _sendMessage() {
final text = _textController.text.trim();
if (text.isEmpty) return;
context.read<MeshCoreConnector>().sendChannelMessage(widget.channel, text);
_textController.clear();
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
} else {
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
}
}
void _showMessagePathInfo(ChannelMessage message) {
final pathPrefixes =
message.pathBytes.isNotEmpty ? _formatPathPrefixes(message.pathBytes) : null;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Packet Path'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Sender', message.senderName),
_buildDetailRow('Time', _formatTime(message.timestamp)),
_buildDetailRow('Repeats', message.repeatCount.toString()),
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
if (pathPrefixes != null) _buildDetailRow('Prefixes', pathPrefixes),
if (pathPrefixes == null) ...[
const SizedBox(height: 8),
const Text(
'Hop details are not provided for this packet.',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
String _formatPathLabel(int? pathLength) {
if (pathLength == null) return 'Unknown';
if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct';
return '$pathLength hops';
}
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 70,
child: Text(label, style: TextStyle(color: Colors.grey[600])),
),
Expanded(child: Text(value)),
],
),
);
}
}
class _PoiInfo {
final double lat;
final double lon;
final String label;
const _PoiInfo({
required this.lat,
required this.lon,
required this.label,
});
}
+439
View File
@@ -0,0 +1,439 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
import 'channel_chat_screen.dart';
class ChannelsScreen extends StatefulWidget {
const ChannelsScreen({super.key});
@override
State<ChannelsScreen> createState() => _ChannelsScreenState();
}
class _ChannelsScreenState extends State<ChannelsScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MeshCoreConnector>().getChannels();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Channels'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
if (connector.isLoadingChannels) {
return const Center(child: CircularProgressIndicator());
}
final channels = connector.channels;
if (channels.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tag, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No channels configured',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => _addPublicChannel(context, connector),
icon: const Icon(Icons.public),
label: const Text('Add Public Channel'),
),
],
),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.all(8),
itemCount: channels.length,
onReorder: (oldIndex, newIndex) async {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
await connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
);
},
itemBuilder: (context, index) {
final channel = channels[index];
return _buildChannelTile(context, connector, channel);
},
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add),
),
);
}
Widget _buildChannelTile(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
return Card(
key: ValueKey('channel_${channel.index}'),
child: ListTile(
leading: CircleAvatar(
backgroundColor: channel.isPublicChannel
? Colors.green.withValues(alpha: 0.2)
: Colors.blue.withValues(alpha: 0.2),
child: Icon(
channel.isPublicChannel
? Icons.public
: channel.name.startsWith('#')
? Icons.tag
: Icons.lock,
color: channel.isPublicChannel ? Colors.green : Colors.blue,
),
),
title: Text(
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
channel.name.startsWith('#')
? 'Hashtag channel'
: channel.isPublicChannel
? 'Public channel'
: 'Private channel',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showEditChannelDialog(context, connector, channel),
),
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'delete') {
_confirmDeleteChannel(context, connector, channel);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'delete',
child: Text('Delete'),
),
],
),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelChatScreen(channel: channel),
),
),
),
);
}
void _showAddChannelDialog(BuildContext context) {
final connector = context.read<MeshCoreConnector>();
final nameController = TextEditingController();
final pskController = TextEditingController();
final maxChannels = connector.maxChannels;
int selectedIndex = _findNextAvailableIndex(connector.channels, maxChannels);
bool usePublicPsk = false;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Add Channel'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: const InputDecoration(
labelText: 'Channel Index',
border: OutlineInputBorder(),
),
items: List.generate(maxChannels, (i) => i)
.map((i) => DropdownMenuItem(
value: i,
child: Text('Channel $i'),
))
.toList(),
onChanged: (value) {
if (value != null) {
setDialogState(() => selectedIndex = value);
}
},
),
const SizedBox(height: 16),
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Use Public Channel'),
subtitle: const Text('Standard public PSK'),
value: usePublicPsk,
onChanged: (value) {
setDialogState(() {
usePublicPsk = value ?? false;
if (usePublicPsk) {
nameController.text = 'Public';
pskController.text = Channel.publicChannelPsk;
} else {
pskController.clear();
}
});
},
),
if (!usePublicPsk) ...[
const SizedBox(height: 8),
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Base64)',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = base64Encode(bytes);
},
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskBase64 = usePublicPsk
? Channel.publicChannelPsk
: pskController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a channel name')),
);
return;
}
Uint8List psk;
try {
final decoded = base64Decode(pskBase64);
psk = Uint8List(16);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid PSK format')),
);
return;
}
Navigator.pop(context);
connector.setChannel(selectedIndex, name, psk);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" added')),
);
},
child: const Text('Add'),
),
],
),
),
);
}
void _showEditChannelDialog(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
final nameController = TextEditingController(text: channel.name);
final pskController = TextEditingController(text: channel.pskBase64);
bool smazEnabled = connector.isChannelSmazEnabled(channel.index);
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('Edit Channel ${channel.index}'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Channel Name',
border: OutlineInputBorder(),
),
maxLength: 31,
),
const SizedBox(height: 16),
TextField(
controller: pskController,
decoration: InputDecoration(
labelText: 'PSK (Base64)',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.casino),
tooltip: 'Generate random PSK',
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
pskController.text = base64Encode(bytes);
},
),
),
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('SMAZ compression'),
value: smazEnabled,
onChanged: (value) => setState(() => smazEnabled = value),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final name = nameController.text.trim();
final pskBase64 = pskController.text.trim();
Uint8List psk;
try {
final decoded = base64Decode(pskBase64);
psk = Uint8List(16);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid PSK format')),
);
return;
}
Navigator.pop(context);
connector.setChannel(channel.index, name, psk);
connector.setChannelSmazEnabled(channel.index, smazEnabled);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "$name" updated')),
);
},
child: const Text('Save'),
),
],
),
),
);
}
void _confirmDeleteChannel(
BuildContext context,
MeshCoreConnector connector,
Channel channel,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Channel'),
content: Text('Delete "${channel.name}"? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.deleteChannel(channel.index);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Channel "${channel.name}" deleted')),
);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _addPublicChannel(BuildContext context, MeshCoreConnector connector) {
final psk = Uint8List(16);
final decoded = base64Decode(Channel.publicChannelPsk);
for (int i = 0; i < decoded.length && i < 16; i++) {
psk[i] = decoded[i];
}
connector.setChannel(0, 'Public', psk);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Public channel added')),
);
}
int _findNextAvailableIndex(List<Channel> channels, int maxChannels) {
final usedIndices = channels.map((c) => c.index).toSet();
for (int i = 0; i < maxChannels; i++) {
if (!usedIndices.contains(i)) return i;
}
return 0;
}
}
File diff suppressed because it is too large Load Diff
+791
View File
@@ -0,0 +1,791 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
import '../widgets/repeater_login_dialog.dart';
import '../utils/emoji_utils.dart';
import 'chat_screen.dart';
import 'repeater_hub_screen.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
type,
}
class ContactsScreen extends StatefulWidget {
const ContactsScreen({super.key});
@override
State<ContactsScreen> createState() => _ContactsScreenState();
}
class _ContactsScreenState extends State<ContactsScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
final ContactGroupStore _groupStore = ContactGroupStore();
List<ContactGroup> _groups = [];
@override
void initState() {
super.initState();
_loadGroups();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadGroups() async {
final groups = await _groupStore.loadGroups();
if (!mounted) return;
setState(() {
_groups = groups;
});
}
Future<void> _saveGroups() async {
await _groupStore.saveGroups(_groups);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Contacts'),
centerTitle: true,
actions: [
PopupMenuButton<ContactSortOption>(
icon: const Icon(Icons.sort),
tooltip: 'Sort by',
onSelected: (option) {
setState(() {
_sortOption = option;
});
},
itemBuilder: (context) => [
PopupMenuItem(
value: ContactSortOption.lastSeen,
child: Row(
children: [
Icon(
Icons.access_time,
size: 20,
color: _sortOption == ContactSortOption.lastSeen
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Last Seen',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.lastSeen
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.recentMessages,
child: Row(
children: [
Icon(
Icons.chat_bubble,
size: 20,
color: _sortOption == ContactSortOption.recentMessages
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Recent Messages',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.recentMessages
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.name,
child: Row(
children: [
Icon(
Icons.sort_by_alpha,
size: 20,
color: _sortOption == ContactSortOption.name
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Name',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.name
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: ContactSortOption.type,
child: Row(
children: [
Icon(
Icons.category,
size: 20,
color: _sortOption == ContactSortOption.type
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 12),
Text(
'Type',
style: TextStyle(
fontWeight: _sortOption == ContactSortOption.type
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.group_add),
tooltip: 'New group',
onPressed: () {
final contacts = context.read<MeshCoreConnector>().contacts;
_showGroupEditor(context, contacts);
},
),
Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return IconButton(
icon: connector.isLoadingContacts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: connector.isLoadingContacts
? null
: () => connector.getContacts(),
);
},
),
],
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final contacts = connector.contacts;
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (contacts.isEmpty && _groups.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Contacts will appear when devices advertise',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
final filteredGroups = _filterAndSortGroups(_groups, contacts);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search contacts...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
),
),
Expanded(
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No contacts or groups found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
)
: RefreshIndicator(
onRefresh: () => connector.getContacts(),
child: ListView.builder(
itemCount: filteredGroups.length + filteredAndSorted.length,
itemBuilder: (context, index) {
if (index < filteredGroups.length) {
final group = filteredGroups[index];
return _buildGroupTile(context, group, contacts);
}
final contact = filteredAndSorted[index - filteredGroups.length];
return _ContactTile(
contact: contact,
onTap: () => _openChat(context, contact),
onLongPress: () => _showContactOptions(context, connector, contact),
);
},
),
),
),
],
);
},
),
);
}
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
final query = _searchQuery.trim().toLowerCase();
final contactNames = <String, String>{};
for (final contact in contacts) {
contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
}
final filtered = groups.where((group) {
if (query.isEmpty) return true;
if (group.name.toLowerCase().contains(query)) return true;
for (final key in group.memberKeys) {
final name = contactNames[key];
if (name != null && name.contains(query)) return true;
}
return false;
}).toList();
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return filtered;
}
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
var filtered = contacts.where((contact) {
if (_searchQuery.isEmpty) return true;
return contact.name.toLowerCase().contains(_searchQuery);
}).toList();
switch (_sortOption) {
case ContactSortOption.lastSeen:
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
break;
case ContactSortOption.recentMessages:
filtered.sort((a, b) {
final aMessages = connector.getMessages(a);
final bMessages = connector.getMessages(b);
final aLastMsg = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
final bLastMsg = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
return bLastMsg.compareTo(aLastMsg);
});
break;
case ContactSortOption.name:
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break;
case ContactSortOption.type:
filtered.sort((a, b) {
final typeCompare = a.type.compareTo(b.type);
if (typeCompare != 0) return typeCompare;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
break;
}
return filtered;
}
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
final memberContacts = _resolveGroupContacts(group, contacts);
final subtitle = _formatGroupMembers(memberContacts);
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.group, color: Colors.white, size: 20),
),
title: Text(group.name),
subtitle: Text(subtitle),
trailing: Text(
memberContacts.length.toString(),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
onTap: () => _showGroupOptions(context, group, contacts),
onLongPress: () => _showGroupOptions(context, group, contacts),
);
}
List<Contact> _resolveGroupContacts(ContactGroup group, List<Contact> contacts) {
final byKey = <String, Contact>{};
for (final contact in contacts) {
byKey[contact.publicKeyHex] = contact;
}
final resolved = <Contact>[];
for (final key in group.memberKeys) {
final contact = byKey[key];
if (contact != null) {
resolved.add(contact);
}
}
resolved.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return resolved;
}
String _formatGroupMembers(List<Contact> members) {
if (members.isEmpty) return 'No members';
final names = members.map((c) => c.name).toList();
if (names.length <= 2) return names.join(', ');
return '${names.take(2).join(', ')} +${names.length - 2}';
}
void _openChat(BuildContext context, Contact contact) {
// Check if this is a repeater
if (contact.type == advTypeRepeater) {
_showRepeaterLogin(context, contact);
} else {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => ChatScreen(contact: contact)),
);
}
}
void _showRepeaterLogin(BuildContext context, Contact repeater) {
showDialog(
context: context,
builder: (context) => RepeaterLoginDialog(
repeater: repeater,
onLogin: (password) {
// Navigate to repeater hub screen after successful login
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterHubScreen(
repeater: repeater,
password: password,
),
),
);
},
),
);
}
void _showGroupOptions(BuildContext context, ContactGroup group, List<Contact> contacts) {
final members = _resolveGroupContacts(group, contacts);
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit Group'),
onTap: () {
Navigator.pop(sheetContext);
_showGroupEditor(context, contacts, group: group);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Group', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(sheetContext);
_confirmDeleteGroup(context, group);
},
),
if (members.isNotEmpty) const Divider(),
...members.map((member) {
return ListTile(
leading: const Icon(Icons.person),
title: Text(member.name),
subtitle: Text(member.typeLabel),
onTap: () {
Navigator.pop(sheetContext);
_openChat(context, member);
},
);
}),
],
),
),
),
);
}
void _confirmDeleteGroup(BuildContext context, ContactGroup group) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Delete Group'),
content: Text('Remove "${group.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
setState(() {
_groups.removeWhere((g) => g.name == group.name);
});
await _saveGroups();
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
void _showGroupEditor(
BuildContext context,
List<Contact> contacts, {
ContactGroup? group,
}) {
final isEditing = group != null;
final nameController = TextEditingController(text: group?.name ?? '');
final selectedKeys = <String>{...group?.memberKeys ?? []};
String filterQuery = '';
final sortedContacts = List<Contact>.from(contacts)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
showDialog(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (builderContext, setDialogState) {
final filteredContacts = filterQuery.isEmpty
? sortedContacts
: sortedContacts
.where((contact) => contact.name.toLowerCase().contains(filterQuery))
.toList();
return AlertDialog(
title: Text(isEditing ? 'Edit Group' : 'New Group'),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Group name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
hintText: 'Filter contacts...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
isDense: true,
),
onChanged: (value) {
setDialogState(() {
filterQuery = value.toLowerCase();
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 240,
child: filteredContacts.isEmpty
? const Center(child: Text('No contacts match your filter'))
: ListView.builder(
itemCount: filteredContacts.length,
itemBuilder: (context, index) {
final contact = filteredContacts[index];
final isSelected = selectedKeys.contains(contact.publicKeyHex);
return CheckboxListTile(
value: isSelected,
title: Text(contact.name),
subtitle: Text(contact.typeLabel),
onChanged: (value) {
setDialogState(() {
if (value == true) {
selectedKeys.add(contact.publicKeyHex);
} else {
selectedKeys.remove(contact.publicKeyHex);
}
});
},
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final name = nameController.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Group name is required')),
);
return;
}
final exists = _groups.any((g) {
if (isEditing && g.name == group!.name) return false;
return g.name.toLowerCase() == name.toLowerCase();
});
if (exists) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Group "$name" already exists')),
);
return;
}
setState(() {
if (isEditing) {
final index = _groups.indexWhere((g) => g.name == group!.name);
if (index != -1) {
_groups[index] = ContactGroup(
name: name,
memberKeys: selectedKeys.toList(),
);
}
} else {
_groups.add(ContactGroup(name: name, memberKeys: selectedKeys.toList()));
}
});
await _saveGroups();
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: Text(isEditing ? 'Save' : 'Create'),
),
],
);
},
),
);
}
void _showContactOptions(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
) {
final isRepeater = contact.type == advTypeRepeater;
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isRepeater)
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
title: const Text('Manage Repeater'),
onTap: () {
Navigator.pop(context);
_showRepeaterLogin(context, contact);
},
)
else
ListTile(
leading: const Icon(Icons.chat),
title: const Text('Open Chat'),
onTap: () {
Navigator.pop(context);
_openChat(context, contact);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Delete Contact', style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_confirmDelete(context, connector, contact);
},
),
],
),
),
);
}
void _confirmDelete(
BuildContext context,
MeshCoreConnector connector,
Contact contact,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Contact'),
content: Text('Remove ${contact.name} from contacts?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.removeContact(contact);
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
}
class _ContactTile extends StatelessWidget {
final Contact contact;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _ContactTile({
required this.contact,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: _buildContactAvatar(contact),
),
title: Text(contact.name),
subtitle: Text('${contact.typeLabel}${contact.pathLabel}'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatLastSeen(contact.lastSeen),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
onTap: onTap,
onLongPress: onLongPress,
);
}
Widget _buildContactAvatar(Contact contact) {
final emoji = firstEmoji(contact.name);
if (emoji != null) {
return Text(
emoji,
style: const TextStyle(fontSize: 18),
);
}
return Icon(_getTypeIcon(contact.type), color: Colors.white, size: 20);
}
IconData _getTypeIcon(int type) {
switch (type) {
case advTypeChat:
return Icons.chat;
case advTypeRepeater:
return Icons.cell_tower;
case advTypeRoom:
return Icons.group;
case advTypeSensor:
return Icons.sensors;
default:
return Icons.device_unknown;
}
}
Color _getTypeColor(int type) {
switch (type) {
case advTypeChat:
return Colors.blue;
case advTypeRepeater:
return Colors.orange;
case advTypeRoom:
return Colors.purple;
case advTypeSensor:
return Colors.green;
default:
return Colors.grey;
}
}
String _formatLastSeen(DateTime lastSeen) {
final now = DateTime.now();
final diff = now.difference(lastSeen);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${lastSeen.month}/${lastSeen.day}';
}
}
+292
View File
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import 'channels_screen.dart';
import 'contacts_screen.dart';
import 'map_screen.dart';
import 'settings_screen.dart';
/// Main hub screen after connecting to a MeshCore device
class DeviceScreen extends StatefulWidget {
const DeviceScreen({super.key});
@override
State<DeviceScreen> createState() => _DeviceScreenState();
}
class _DeviceScreenState extends State<DeviceScreen> {
bool _showBatteryVoltage = false;
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
// If disconnected, pop back to scanner
if (!connector.isConnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
Navigator.popUntil(context, (route) => route.isFirst);
}
});
}
return PopScope(
canPop: false,
child: Scaffold(
appBar: AppBar(
title: Text(connector.device?.platformName ?? 'MeshCore Device'),
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Connection status card
_buildStatusCard(connector, context),
const SizedBox(height: 24),
// Navigation grid
Expanded(
child: _buildNavigationGrid(context),
),
],
),
),
),
);
},
);
}
Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connector.device?.platformName ?? 'Unknown Device',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
connector.device?.remoteId.toString() ?? '',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Text(
'Connected',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
_buildBatteryIndicator(connector, context),
],
),
],
),
),
);
}
Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
final percentLabel = percent != null ? '$percent%' : '--%';
final voltageLabel = millivolts == null
? '-- V'
: '${(millivolts / 1000.0).toStringAsFixed(2)} V';
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
final icon = _batteryIcon(percent);
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Colors.grey[700]),
const SizedBox(width: 4),
Text(
displayLabel,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
IconData _batteryIcon(int? percent) {
if (percent == null) return Icons.battery_unknown;
if (percent <= 15) return Icons.battery_alert;
return Icons.battery_full;
}
Widget _buildNavigationGrid(BuildContext context) {
final items = [
_NavItem(
icon: Icons.people_outline,
label: 'Contacts',
color: Colors.blue,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ContactsScreen()),
),
),
_NavItem(
icon: Icons.tag,
label: 'Channels',
color: Colors.green,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
),
),
_NavItem(
icon: Icons.map_outlined,
label: 'Map',
color: Colors.orange,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const MapScreen()),
),
),
_NavItem(
icon: Icons.settings_outlined,
label: 'Settings',
color: Colors.grey,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
),
];
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _buildNavCard(item);
},
);
}
Widget _buildNavCard(_NavItem item) {
return Card(
child: InkWell(
onTap: item.onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
size: 48,
color: item.color,
),
const SizedBox(height: 12),
Text(
item.label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Future<void> _disconnect(BuildContext context, MeshCoreConnector connector) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Disconnect'),
content: const Text('Are you sure you want to disconnect from this device?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Disconnect'),
),
],
),
);
if (confirmed == true) {
await connector.disconnect();
}
}
}
class _NavItem {
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
_NavItem({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
}
File diff suppressed because it is too large Load Diff
+528
View File
@@ -0,0 +1,528 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart';
class RepeaterCliScreen extends StatefulWidget {
final Contact repeater;
final String password;
const RepeaterCliScreen({
super.key,
required this.repeater,
required this.password,
});
@override
State<RepeaterCliScreen> createState() => _RepeaterCliScreenState();
}
class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
final TextEditingController _commandController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<Map<String, String>> _commandHistory = [];
int _historyIndex = -1;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
// Common commands for quick access
final List<Map<String, String>> _quickCommands = [
{'label': 'Get Name', 'command': 'get name'},
{'label': 'Get Radio', 'command': 'get radio'},
{'label': 'Get TX', 'command': 'get tx'},
{'label': 'Neighbors', 'command': 'neighbors'},
{'label': 'Version', 'command': 'ver'},
{'label': 'Advertise', 'command': 'advert'},
{'label': 'Clock', 'command': 'clock'},
];
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
}
@override
void dispose() {
_frameSubscription?.cancel();
_commandService?.dispose();
_commandController.dispose();
_scrollController.dispose();
super.dispose();
}
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
}
});
}
void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Note: The command service will handle the response via the Future
// We don't need to add it to history here anymore as _sendCommand will do it
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false;
for (int i = 0; i < 6; i++) {
if (prefix[i] != target[i]) return false;
}
return true;
}
void _sendCommand({bool showDebug = false}) async {
final command = _commandController.text.trim();
if (command.isEmpty) return;
setState(() {
_commandHistory.add({
'type': 'command',
'text': command,
'timestamp': DateTime.now().toString(),
});
});
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
DebugFrameViewer.showFrameDebug(context, frame, 'CLI Command Frame');
}
// Send CLI command to repeater with retry
try {
if (_commandService != null) {
final response = await _commandService!.sendCommand(
widget.repeater,
command,
);
if (mounted) {
setState(() {
_commandHistory.add({
'type': 'response',
'text': response,
'timestamp': DateTime.now().toString(),
});
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_commandHistory.add({
'type': 'response',
'text': 'Error: $e',
'timestamp': DateTime.now().toString(),
});
});
}
}
_commandController.clear();
_historyIndex = -1;
// Auto-scroll to bottom
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
void _useQuickCommand(String command) {
_commandController.text = command;
_sendCommand();
}
void _navigateHistory(bool up) {
final commands = _commandHistory
.where((entry) => entry['type'] == 'command')
.toList()
.reversed
.toList();
if (commands.isEmpty) return;
if (up) {
if (_historyIndex < commands.length - 1) {
_historyIndex++;
}
} else {
if (_historyIndex > 0) {
_historyIndex--;
} else {
_historyIndex = -1;
_commandController.clear();
return;
}
}
if (_historyIndex >= 0 && _historyIndex < commands.length) {
_commandController.text = commands[_historyIndex]['text'] ?? '';
_commandController.selection = TextSelection.fromPosition(
TextPosition(offset: _commandController.text.length),
);
}
}
void _clearHistory() {
setState(() {
_commandHistory.clear();
_historyIndex = -1;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater CLI'),
Text(
widget.repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.bug_report),
tooltip: 'Debug Next Command',
onPressed: () {
// Set a flag or just send next command with debug
if (_commandController.text.trim().isNotEmpty) {
_sendCommand(showDebug: true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enter a command first')),
);
}
},
),
IconButton(
icon: const Icon(Icons.help_outline),
tooltip: 'Command Help',
onPressed: () => _showCommandHelp(context),
),
IconButton(
icon: const Icon(Icons.clear_all),
tooltip: 'Clear History',
onPressed: _commandHistory.isEmpty ? null : _clearHistory,
),
],
),
body: Column(
children: [
_buildQuickCommandsBar(),
const Divider(height: 1),
Expanded(
child: _commandHistory.isEmpty
? _buildEmptyState()
: _buildCommandHistory(),
),
const Divider(height: 1),
_buildCommandInput(),
],
),
);
}
Widget _buildQuickCommandsBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _quickCommands.map((cmd) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(cmd['label']!),
onPressed: () => _useQuickCommand(cmd['command']!),
avatar: const Icon(Icons.play_arrow, size: 16),
),
);
}).toList(),
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.terminal, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No commands sent yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Type a command below or use quick commands',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
Widget _buildCommandHistory() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _commandHistory.length,
itemBuilder: (context, index) {
final entry = _commandHistory[index];
final isCommand = entry['type'] == 'command';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: isCommand
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
isCommand ? Icons.chevron_right : Icons.arrow_back,
size: 16,
color: isCommand
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
entry['text']!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: isCommand
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
],
),
);
},
);
}
Widget _buildCommandInput() {
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
tooltip: 'Previous command',
onPressed: () => _navigateHistory(true),
),
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
tooltip: 'Next command',
onPressed: () => _navigateHistory(false),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _commandController,
decoration: const InputDecoration(
hintText: 'Enter command...',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendCommand(),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _sendCommand,
),
],
),
),
);
}
void _showCommandHelp(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Commands List'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NOTE: for the various "set ..." commands, there is also a "get ..." command.',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection('General', [
'advert - Sends an advertisement packet',
"reboot - Reboots the device. (note, you'll prob get 'Timeout' which is normal)",
"clock - Displays current time per device's clock.",
'password {new-password} - Sets a new admin password for the device.',
'ver - Shows the device version and firmware build date.',
'clear stats - Resets various stats counters to zero.',
]),
const SizedBox(height: 16),
_buildHelpSection('Settings', [
'set af {air-time-factor} - Sets the air-time-factor.',
'set tx {tx-power-dbm} - Sets LoRa transmit power in dBm. (reboot to apply)',
'set repeat {on|off} - Enables or disables the repeater role for this node.',
"set allow.read.only {on|off} - (Room server) If 'on', then login in blank password will be allowed, but cannot Post to room. (just read only)",
'set flood.max {max-hops} - Sets the maximum number of hops of inbound flood packet (if >= max, packet is not forwarded)',
'set int.thresh {db} - Sets the Interference Threshold (in DB). Default is 14. Set to 0 to disable channel interference detection.',
'set agc.reset.interval {seconds} - Sets the interval to reset the Auto Gain Controller. Set to 0 to disable.',
"set multi.acks {0|1} - Enables or disables the 'double ACKs' feature.",
'set advert.interval {minutes} - Sets the timer interval in minutes to send a local (zero-hop) advertisement packet. Set to 0 to disable.',
'set flood.advert.interval {hours} - Sets the timer interval in hours to send a flood advertisement packet. Set to 0 to disable.',
'set guest.password {guess-password} - Sets/updates the guest password. (for repeaters, guest logins can send the "Get Stats" request)',
'set name {name} - Sets the advertisement name.',
'set lat {latitude} - Sets the advertisement map latitude. (decimal degrees)',
'set lon {longitude} - Sets the advertisement map longitude. (decimal degrees)',
'set radio {freq},{bw},{sf},{cr} - Sets completely new radio params, and saves to preferences. Requires a "reboot" command to apply.',
'set rxdelay {base} - Sets (experimental) base (must be > 1 for effect) for applying slight delay to received packets, based on signal strength/score. Set to 0 to disable.',
'set txdelay {factor} - Sets a factor multiplied with time-on-air for a flood-mode packet and with a randomized slot system, to delay its forwarding. (to decrease likelihood of collisions)',
'set direct.txdelay {factor} - Same as txdelay, but for applying a random delay to the forwarding of direct-mode packets.',
'set bridge.enabled {on|off} - Enable/Disable bridge.',
'set bridge.delay {0-10000} - Set delay before retransmitting packets.',
'set bridge.source {rx|tx} - Choose wether the bridge will retransmit received packets or transmitted packets.',
'set bridge.baud {speed} - Set serial link baudrate for rs232 bridges.',
'set bridge.secret {shared-secret} - Set bridge secret for espnow bridges.',
'set adc.multiplier {factor} - Sets custom factor to adjust reported battery voltage (only supported on select boards).',
'tempradio {freq},{bw},{sf},{cr},{minutes} - Sets temporary radio params for the given number of {minutes}, reverting to original radio params afterward. (does NOT save to preferences).',
'setperm {pubkey-hex} {permissions} - Modifies the ACL. Removes matching entry (by pubkey prefix) if "permissions" is zero. Adds new entry if pubkey-hex is full length and is not currently in ACL. Updates entry by matching pubkey prefix. Permission bits vary per firmware role, but low 2 bits are: 0 (Guest), 1 (Read only), 2 (Read write), 3 (Admin)',
]),
const SizedBox(height: 16),
_buildHelpSection('Bridge', [
'get bridge.type - Gets bridge type none, rs232, espnow',
]),
const SizedBox(height: 16),
_buildHelpSection('Logging', [
'log start - Starts packet logging to file system.',
'log stop - Stops packet logging to file system.',
'log erase - Erases the packet logs from file system.',
]),
const SizedBox(height: 16),
_buildHelpSection('Neighbors (Repeater only)', [
'neighbors - Shows a list of other repeater nodes heard via zero-hop adverts. Each line is {id-prefix-hex}:{timestamp}:{snr-times-4}',
'neighbor.remove {pubkey-prefix} - Removes first matching entry (by pubkey prefix (hex)), from neighbors list.',
]),
const SizedBox(height: 16),
_buildHelpSection('Region Management (Repeater only)', [
'region commands have been introduced to manage region definitions and permissions.',
'region - (serial only) Lists all defined regions and current flood permissions.',
'region load - NOTE: this is a special multi-command invocation. Each subsequent command is a region name (indented with spaces to indicate parent hierarchy, with one space at minimum). Terminated by sending a blank line/command.',
"region get {* | name-prefix} - Searches for region with given name prefix (or '*' for the global scope). Replies with \"-> {region-name} ({parent-name}) {'F'}\"",
'region put {name} {* | parent-name-prefix} - Adds or updates a region definition with given name.',
'region remove {name} - Removes a region definition with given name. (must match exactly, and have no child regions)',
"region allowf {* | name-prefix} - Sets the 'F'lood permission for the given region. ('*' for the global/legacy scope)",
"region denyf {* | name-prefix} - Removes the 'F'lood permission for the given region. (NOTE: at this stage NOT advised to use this on the global/legacy scope!!)",
"region home - Replies with the current 'home' region. (Note applied anywhere yet, reserved for future)",
"region home {* | name-prefix} - Sets the 'home' region.",
'region save - Persists the region list/map to storage.',
]),
const SizedBox(height: 16),
_buildHelpSection('GPS Management', [
'gps command has been introduced to manage location related topics.',
'gps - Gives status of gps. When gps is off, it replies only off, if on it replies with on, {status}, {fix}, {sat count}',
'gps {on|off} - Toggles gps power state.',
'gps sync - Syncs node time with gps clock.',
"gps setloc - Sets node's position to gps coordinates and save preferences.",
'gps advert - Gives location advert configuration of the node:',
"none: don't include location in adverts",
'share: share gps location (from SensorManager)',
'prefs: advert the location stored in preferences',
'gps advert {none|share|prefs} - Sets location advert configuration.',
]),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildHelpSection(String title, List<String> commands) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 8),
...commands.map((cmd) => Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text(
'$cmd',
style: const TextStyle(fontSize: 13, fontFamily: 'monospace'),
),
)),
],
);
}
}
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import '../models/contact.dart';
import 'repeater_status_screen.dart';
import 'repeater_cli_screen.dart';
import 'repeater_settings_screen.dart';
class RepeaterHubScreen extends StatelessWidget {
final Contact repeater;
final String password;
const RepeaterHubScreen({
super.key,
required this.repeater,
required this.password,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Management'),
Text(
repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Repeater info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: Colors.orange,
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white),
),
const SizedBox(height: 16),
Text(
repeater.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
repeater.pathLabel,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
if (repeater.hasLocation) ...[
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.location_on, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
],
],
),
),
),
const SizedBox(height: 24),
const Text(
'Management Tools',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Status button
_buildManagementCard(
context,
icon: Icons.analytics,
title: 'Status',
subtitle: 'View repeater status, stats, and neighbors',
color: Colors.blue,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterStatusScreen(
repeater: repeater,
password: password,
),
),
);
},
),
const SizedBox(height: 12),
// CLI button
_buildManagementCard(
context,
icon: Icons.terminal,
title: 'CLI',
subtitle: 'Send commands to the repeater',
color: Colors.green,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterCliScreen(
repeater: repeater,
password: password,
),
),
);
},
),
const SizedBox(height: 12),
// Settings button
_buildManagementCard(
context,
icon: Icons.settings,
title: 'Settings',
subtitle: 'Configure repeater parameters',
color: Colors.orange,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RepeaterSettingsScreen(
repeater: repeater,
password: password,
),
),
);
},
),
],
),
),
);
}
Widget _buildManagementCard(
BuildContext context, {
required IconData icon,
required String title,
required String subtitle,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey[400]),
],
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart';
class RepeaterStatusScreen extends StatefulWidget {
final Contact repeater;
final String password;
const RepeaterStatusScreen({
super.key,
required this.repeater,
required this.password,
});
@override
State<RepeaterStatusScreen> createState() => _RepeaterStatusScreenState();
}
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
Timer? _statusTimeout;
DateTime? _statusRequestedAt;
int? _batteryMv;
int? _uptimeSecs;
int? _queueLen;
int? _debugFlags;
int? _lastRssi;
double? _lastSnr;
int? _noiseFloor;
int? _txAirSecs;
int? _rxAirSecs;
int? _packetsSent;
int? _packetsRecv;
int? _floodTx;
int? _directTx;
int? _floodRx;
int? _directRx;
int? _dupFlood;
int? _dupDirect;
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
_loadStatus();
}
@override
void dispose() {
_frameSubscription?.cancel();
_commandService?.dispose();
_statusTimeout?.cancel();
super.dispose();
}
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == pushCodeStatusResponse) {
_handleStatusResponse(frame);
} else if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
}
});
}
void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
// Parse status responses
_parseStatusResponse(parsed.text);
}
void _handleStatusResponse(Uint8List frame) {
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return;
const payloadOffset = 8;
const statsSize = 52;
if (frame.length < payloadOffset + statsSize) return;
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize);
int offset = 0;
final batteryMv = data.getUint16(offset, Endian.little);
offset += 2;
final queueLen = data.getUint16(offset, Endian.little);
offset += 2;
final noiseFloor = data.getInt16(offset, Endian.little);
offset += 2;
final lastRssi = data.getInt16(offset, Endian.little);
offset += 2;
final packetsRecv = data.getUint32(offset, Endian.little);
offset += 4;
final packetsSent = data.getUint32(offset, Endian.little);
offset += 4;
final txAirSecs = data.getUint32(offset, Endian.little);
offset += 4;
final uptimeSecs = data.getUint32(offset, Endian.little);
offset += 4;
final floodTx = data.getUint32(offset, Endian.little);
offset += 4;
final directTx = data.getUint32(offset, Endian.little);
offset += 4;
final floodRx = data.getUint32(offset, Endian.little);
offset += 4;
final directRx = data.getUint32(offset, Endian.little);
offset += 4;
final errEvents = data.getUint16(offset, Endian.little);
offset += 2;
final lastSnrRaw = data.getInt16(offset, Endian.little);
offset += 2;
final directDups = data.getUint16(offset, Endian.little);
offset += 2;
final floodDups = data.getUint16(offset, Endian.little);
offset += 2;
final rxAirSecs = data.getUint32(offset, Endian.little);
_statusTimeout?.cancel();
if (!mounted) return;
setState(() {
_isLoading = false;
_batteryMv = batteryMv;
_queueLen = queueLen;
_noiseFloor = noiseFloor;
_lastRssi = lastRssi;
_packetsRecv = packetsRecv;
_packetsSent = packetsSent;
_txAirSecs = txAirSecs;
_rxAirSecs = rxAirSecs;
_uptimeSecs = uptimeSecs;
_floodTx = floodTx;
_directTx = directTx;
_floodRx = floodRx;
_directRx = directRx;
_debugFlags = errEvents;
_lastSnr = lastSnrRaw / 4.0;
_dupDirect = directDups;
_dupFlood = floodDups;
});
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false;
for (int i = 0; i < 6; i++) {
if (prefix[i] != target[i]) return false;
}
return true;
}
void _parseStatusResponse(String response) {
final trimmed = response.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
final data = jsonDecode(trimmed) as Map<String, dynamic>;
if (data.containsKey('battery_mv')) {
_batteryMv = _asInt(data['battery_mv']);
_uptimeSecs = _asInt(data['uptime_secs']);
_queueLen = _asInt(data['queue_len']);
_debugFlags = _asInt(data['errors']);
} else if (data.containsKey('noise_floor')) {
_noiseFloor = _asInt(data['noise_floor']);
_lastRssi = _asInt(data['last_rssi']);
_lastSnr = _asDouble(data['last_snr']);
_txAirSecs = _asInt(data['tx_air_secs']);
_rxAirSecs = _asInt(data['rx_air_secs']);
} else if (data.containsKey('recv') || data.containsKey('sent')) {
_packetsRecv = _asInt(data['recv']);
_packetsSent = _asInt(data['sent']);
_floodTx = _asInt(data['flood_tx']);
_directTx = _asInt(data['direct_tx']);
_floodRx = _asInt(data['flood_rx']);
_directRx = _asInt(data['direct_rx']);
_dupFlood = _asInt(data['dup_flood']);
_dupDirect = _asInt(data['dup_direct']);
}
} catch (_) {
// Ignore parse failures for non-JSON responses.
}
}
if (mounted) {
setState(() {});
}
}
Future<void> _loadStatus() async {
if (_commandService == null) return;
setState(() {
_isLoading = true;
_statusRequestedAt = DateTime.now();
_batteryMv = null;
_uptimeSecs = null;
_queueLen = null;
_debugFlags = null;
_lastRssi = null;
_lastSnr = null;
_noiseFloor = null;
_txAirSecs = null;
_rxAirSecs = null;
_packetsSent = null;
_packetsRecv = null;
_floodTx = null;
_directTx = null;
_floodRx = null;
_directRx = null;
_dupFlood = null;
_dupDirect = null;
});
try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey);
await connector.sendFrame(frame);
_statusTimeout?.cancel();
_statusTimeout = Timer(const Duration(seconds: 12), () {
if (!mounted) return;
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Status request timed out.'),
backgroundColor: Colors.red,
),
);
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading status: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Repeater Status'),
Text(
widget.repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
),
],
),
centerTitle: false,
actions: [
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: _isLoading ? null : _loadStatus,
tooltip: 'Refresh',
),
],
),
body: RefreshIndicator(
onRefresh: _loadStatus,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSystemInfoCard(),
const SizedBox(height: 16),
_buildRadioStatsCard(),
const SizedBox(height: 16),
_buildPacketStatsCard(),
],
),
),
);
}
Widget _buildSystemInfoCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'System Information',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Battery', _batteryText()),
_buildInfoRow('Clock (at login)', _clockText()),
_buildInfoRow('Uptime', _formatDuration(_uptimeSecs)),
_buildInfoRow('Queue Length', _formatValue(_queueLen)),
_buildInfoRow('Debug Flags', _formatValue(_debugFlags)),
],
),
),
);
}
Widget _buildRadioStatsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.radio, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'Radio Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Last RSSI', _formatValue(_lastRssi, suffix: ' dB')),
_buildInfoRow('Last SNR', _formatSnr(_lastSnr)),
_buildInfoRow('Noise Floor', _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow('TX Airtime', _formatDuration(_txAirSecs)),
_buildInfoRow('RX Airtime', _formatDuration(_rxAirSecs)),
],
),
),
);
}
Widget _buildPacketStatsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.analytics, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
const Text(
'Packet Statistics',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const Divider(),
_buildInfoRow('Sent', _packetTxText()),
_buildInfoRow('Received', _packetRxText()),
_buildInfoRow('Duplicates', _duplicateText()),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Text(
label,
style: TextStyle(
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
],
),
);
}
int? _asInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is double) return value.round();
return int.tryParse(value.toString());
}
double? _asDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
return double.tryParse(value.toString());
}
String _batteryText() {
if (_batteryMv == null) return '';
final percent = _batteryPercentFromMv(_batteryMv!);
final volts = (_batteryMv! / 1000.0).toStringAsFixed(2);
return '$percent% / ${volts}V';
}
int _batteryPercentFromMv(int millivolts) {
const minMv = 3000;
const maxMv = 4200;
if (millivolts <= minMv) return 0;
if (millivolts >= maxMv) return 100;
return (((millivolts - minMv) * 100) / (maxMv - minMv)).round();
}
String _clockText() {
if (_statusRequestedAt == null) return '';
final dt = _statusRequestedAt!;
final date = '${dt.day}/${dt.month}/${dt.year}';
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
return '$date $time';
}
String _formatDuration(int? seconds) {
if (seconds == null) return '';
final days = seconds ~/ 86400;
final hours = (seconds % 86400) ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
return '$days days ${hours}h ${minutes}m ${secs}s';
}
String _packetTxText() {
if (_packetsSent == null) return '';
final flood = _formatValue(_floodTx);
final direct = _formatValue(_directTx);
return 'Total: $_packetsSent, Flood: $flood, Direct: $direct';
}
String _packetRxText() {
if (_packetsRecv == null) return '';
final flood = _formatValue(_floodRx);
final direct = _formatValue(_directRx);
return 'Total: $_packetsRecv, Flood: $flood, Direct: $direct';
}
String _duplicateText() {
if (_dupFlood != null || _dupDirect != null) {
final flood = _formatValue(_dupFlood);
final direct = _formatValue(_dupDirect);
return 'Flood: $flood, Direct: $direct';
}
if (_packetsRecv == null || _floodRx == null || _directRx == null) return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return '';
return 'Total: $dupTotal';
}
String _formatValue(num? value, {String? suffix}) {
if (value == null) return '';
return suffix == null ? value.toString() : '$value$suffix';
}
String _formatSnr(double? snr) {
if (snr == null) return '';
return snr.toStringAsFixed(2);
}
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../widgets/device_tile.dart';
import 'device_screen.dart';
/// Screen for scanning and connecting to MeshCore devices
class ScannerScreen extends StatelessWidget {
const ScannerScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('MeshCore Open'),
centerTitle: true,
automaticallyImplyLeading: false,
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
children: [
// Status bar
_buildStatusBar(context, connector),
// Device list
Expanded(
child: _buildDeviceList(context, connector),
),
],
);
},
),
floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isScanning = connector.state == MeshCoreConnectionState.scanning;
return FloatingActionButton.extended(
onPressed: () {
if (isScanning) {
connector.stopScan();
} else {
connector.startScan();
}
},
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? 'Stop' : 'Scan'),
);
},
),
);
}
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
String statusText;
Color statusColor;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = 'Scanning for devices...';
statusColor = Colors.blue;
break;
case MeshCoreConnectionState.connecting:
statusText = 'Connecting...';
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.connected:
statusText = 'Connected to ${connector.device?.platformName}';
statusColor = Colors.green;
break;
case MeshCoreConnectionState.disconnecting:
statusText = 'Disconnecting...';
statusColor = Colors.orange;
break;
case MeshCoreConnectionState.disconnected:
statusText = 'Not connected';
statusColor = Colors.grey;
break;
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: statusColor.withValues(alpha: 0.1),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: statusColor),
const SizedBox(width: 8),
Text(
statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _buildDeviceList(BuildContext context, MeshCoreConnector connector) {
if (connector.scanResults.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bluetooth,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? 'Searching for MeshCore devices...'
: 'Tap Scan to find MeshCore devices',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: connector.scanResults.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final result = connector.scanResults[index];
return DeviceTile(
scanResult: result,
onTap: () => _connectToDevice(context, connector, result),
);
},
);
}
Future<void> _connectToDevice(
BuildContext context,
MeshCoreConnector connector,
ScanResult result,
) async {
try {
await connector.connect(result.device);
if (context.mounted && connector.isConnected) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeviceScreen(),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connection failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
}
+693
View File
@@ -0,0 +1,693 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../models/radio_settings.dart';
import '../services/app_settings_service.dart';
import 'app_settings_screen.dart';
import 'ble_debug_log_screen.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
centerTitle: true,
),
body: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildDeviceInfoCard(connector),
const SizedBox(height: 16),
_buildAppSettingsCard(context),
const SizedBox(height: 16),
_buildNodeSettingsCard(context, connector),
const SizedBox(height: 16),
_buildActionsCard(context, connector),
const SizedBox(height: 16),
_buildDebugCard(context),
const SizedBox(height: 16),
_buildAboutCard(context),
],
);
},
),
);
}
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Device Info',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildInfoRow('Name', connector.device?.platformName ?? 'Unknown'),
_buildInfoRow('ID', connector.device?.remoteId.toString() ?? 'Unknown'),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!),
if (connector.selfPublicKey != null)
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
],
),
),
);
}
Widget _buildAppSettingsCard(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('App Settings'),
subtitle: const Text('Notifications, messaging, and map preferences'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AppSettingsScreen()),
);
},
),
);
}
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Node Settings',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.person_outline),
title: const Text('Node Name'),
subtitle: Text(connector.selfName ?? 'Not set'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editNodeName(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.radio),
title: const Text('Radio Settings'),
subtitle: const Text('Frequency, power, spreading factor'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRadioSettings(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.location_on_outlined),
title: const Text('Location'),
subtitle: const Text('GPS coordinates'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _editLocation(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: const Text('Privacy Mode'),
subtitle: const Text('Hide name/location in advertisements'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _togglePrivacy(context, connector),
),
],
),
);
}
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Actions',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const Icon(Icons.cell_tower),
title: const Text('Send Advertisement'),
subtitle: const Text('Broadcast presence now'),
onTap: () => _sendAdvert(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Sync Time'),
subtitle: const Text('Set device clock to phone time'),
onTap: () => _syncTime(context, connector),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Refresh Contacts'),
subtitle: const Text('Reload contact list from device'),
onTap: () => connector.getContacts(),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.restart_alt, color: Colors.orange),
title: const Text('Reboot Device'),
subtitle: const Text('Restart the MeshCore device'),
onTap: () => _confirmReboot(context, connector),
),
],
),
);
}
Widget _buildAboutCard(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('About'),
subtitle: const Text('MeshCore Open v0.1.0'),
onTap: () => _showAbout(context),
),
);
}
Widget _buildDebugCard(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.bug_report_outlined),
title: const Text('BLE Debug Log'),
subtitle: const Text('Commands, responses, and status'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
);
},
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: Colors.grey[600])),
Flexible(
child: Text(
value,
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
final controller = TextEditingController(text: connector.selfName ?? '');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Node Name'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter node name',
border: OutlineInputBorder(),
),
maxLength: 31,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.sendCliCommand('set name ${controller.text}');
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Name updated')),
);
},
child: const Text('Save'),
),
],
),
);
}
void _showRadioSettings(BuildContext context, MeshCoreConnector connector) {
showDialog(
context: context,
builder: (context) => _RadioSettingsDialog(connector: connector),
);
}
void _editLocation(BuildContext context, MeshCoreConnector connector) {
final latController = TextEditingController();
final lonController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Location'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: latController,
decoration: const InputDecoration(
labelText: 'Latitude',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
const SizedBox(height: 16),
TextField(
controller: lonController,
decoration: const InputDecoration(
labelText: 'Longitude',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
var updated = false;
if (latController.text.isNotEmpty) {
await connector.sendCliCommand('set lat ${latController.text}');
updated = true;
}
if (lonController.text.isNotEmpty) {
await connector.sendCliCommand('set lon ${lonController.text}');
updated = true;
}
if (updated) {
await connector.refreshDeviceInfo();
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Location updated')),
);
},
child: const Text('Save'),
),
],
),
);
}
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Privacy Mode'),
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.sendCliCommand('set privacy on');
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode enabled')),
);
},
child: const Text('Enable'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await connector.sendCliCommand('set privacy off');
await connector.refreshDeviceInfo();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Privacy mode disabled')),
);
},
child: const Text('Disable'),
),
],
),
);
}
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
connector.sendCliCommand('advert');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Advertisement sent')),
);
}
void _syncTime(BuildContext context, MeshCoreConnector connector) {
connector.syncTime();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Time synchronized')),
);
}
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reboot Device'),
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
connector.sendCliCommand('reboot');
},
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
),
],
),
);
}
void _showAbout(BuildContext context) {
showAboutDialog(
context: context,
applicationName: 'MeshCore Open',
applicationVersion: '0.1.0',
applicationLegalese: '2024 MeshCore Open Source Project',
children: [
const SizedBox(height: 16),
const Text(
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
),
],
);
}
}
class _RadioSettingsDialog extends StatefulWidget {
final MeshCoreConnector connector;
const _RadioSettingsDialog({required this.connector});
@override
State<_RadioSettingsDialog> createState() => _RadioSettingsDialogState();
}
class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
final _frequencyController = TextEditingController();
LoRaBandwidth _bandwidth = LoRaBandwidth.bw125;
LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7;
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
final _txPowerController = TextEditingController(text: '20');
@override
void initState() {
super.initState();
// Populate with current settings if available
if (widget.connector.currentFreqHz != null) {
_frequencyController.text = (widget.connector.currentFreqHz! / 1000.0).toStringAsFixed(3);
} else {
_frequencyController.text = '915.0';
}
if (widget.connector.currentBwHz != null) {
// Find matching bandwidth enum
final bwValue = widget.connector.currentBwHz!;
for (var bw in LoRaBandwidth.values) {
if (bw.hz == bwValue) {
_bandwidth = bw;
break;
}
}
}
if (widget.connector.currentSf != null) {
// Find matching spreading factor enum
final sfValue = widget.connector.currentSf!;
for (var sf in LoRaSpreadingFactor.values) {
if (sf.value == sfValue) {
_spreadingFactor = sf;
break;
}
}
}
if (widget.connector.currentCr != null) {
// Find matching coding rate enum
final crValue = _toUiCodingRate(widget.connector.currentCr!);
for (var cr in LoRaCodingRate.values) {
if (cr.value == crValue) {
_codingRate = cr;
break;
}
}
}
if (widget.connector.currentTxPower != null) {
_txPowerController.text = widget.connector.currentTxPower.toString();
}
}
@override
void dispose() {
_frequencyController.dispose();
_txPowerController.dispose();
super.dispose();
}
void _applyPreset(RadioSettings preset) {
setState(() {
_frequencyController.text = preset.frequencyMHz.toString();
_bandwidth = preset.bandwidth;
_spreadingFactor = preset.spreadingFactor;
_codingRate = preset.codingRate;
_txPowerController.text = preset.txPowerDbm.toString();
});
}
Future<void> _saveSettings() async {
final freqMHz = double.tryParse(_frequencyController.text);
final txPower = int.tryParse(_txPowerController.text);
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
);
return;
}
if (txPower == null || txPower < 0 || txPower > 22) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid TX power (0-22 dBm)')),
);
return;
}
final freqHz = (freqMHz * 1000).round();
final bwHz = _bandwidth.hz;
final sf = _spreadingFactor.value;
final cr = _toDeviceCodingRate(_codingRate.value, widget.connector.currentCr);
try {
await widget.connector.sendFrame(buildSetRadioParamsFrame(freqHz, bwHz, sf, cr));
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
await widget.connector.refreshDeviceInfo();
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Radio settings updated')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
int _toUiCodingRate(int deviceCr) {
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
}
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
if (deviceCr != null && deviceCr <= 4) {
return uiCr - 4;
}
return uiCr;
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Radio Settings'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
_PresetChip(
label: '915 MHz',
onTap: () => _applyPreset(RadioSettings.preset915MHz),
),
_PresetChip(
label: '868 MHz',
onTap: () => _applyPreset(RadioSettings.preset868MHz),
),
_PresetChip(
label: '433 MHz',
onTap: () => _applyPreset(RadioSettings.preset433MHz),
),
_PresetChip(
label: 'Long Range',
onTap: () => _applyPreset(RadioSettings.presetLongRange),
),
_PresetChip(
label: 'Fast Speed',
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
),
],
),
const SizedBox(height: 24),
TextField(
controller: _frequencyController,
decoration: const InputDecoration(
labelText: 'Frequency (MHz)',
border: OutlineInputBorder(),
helperText: '300.0 - 2500.0',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaBandwidth>(
value: _bandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
),
items: LoRaBandwidth.values
.map((bw) => DropdownMenuItem(
value: bw,
child: Text(bw.label),
))
.toList(),
onChanged: (value) {
if (value != null) setState(() => _bandwidth = value);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaSpreadingFactor>(
value: _spreadingFactor,
decoration: const InputDecoration(
labelText: 'Spreading Factor',
border: OutlineInputBorder(),
),
items: LoRaSpreadingFactor.values
.map((sf) => DropdownMenuItem(
value: sf,
child: Text(sf.label),
))
.toList(),
onChanged: (value) {
if (value != null) setState(() => _spreadingFactor = value);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<LoRaCodingRate>(
value: _codingRate,
decoration: const InputDecoration(
labelText: 'Coding Rate',
border: OutlineInputBorder(),
),
items: LoRaCodingRate.values
.map((cr) => DropdownMenuItem(
value: cr,
child: Text(cr.label),
))
.toList(),
onChanged: (value) {
if (value != null) setState(() => _codingRate = value);
},
),
const SizedBox(height: 16),
TextField(
controller: _txPowerController,
decoration: const InputDecoration(
labelText: 'TX Power (dBm)',
border: OutlineInputBorder(),
helperText: '0 - 22',
),
keyboardType: TextInputType.number,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saveSettings,
child: const Text('Save'),
),
],
);
}
}
class _PresetChip extends StatelessWidget {
final String label;
final VoidCallback onTap;
const _PresetChip({required this.label, required this.onTap});
@override
Widget build(BuildContext context) {
return ActionChip(
label: Text(label),
onPressed: onTap,
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_settings.dart';
class AppSettingsService extends ChangeNotifier {
static const String _settingsKey = 'app_settings';
AppSettings _settings = AppSettings();
AppSettings get settings => _settings;
String batteryChemistryForDevice(String deviceId) {
final stored = _settings.batteryChemistryByDeviceId[deviceId];
if (stored == 'liion') return 'nmc';
return stored ?? 'nmc';
}
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_settingsKey);
if (jsonStr != null) {
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
_settings = AppSettings.fromJson(json);
notifyListeners();
} catch (e) {
// If parsing fails, use defaults
_settings = AppSettings();
}
}
}
Future<void> updateSettings(AppSettings newSettings) async {
_settings = newSettings;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(_settings.toJson());
await prefs.setString(_settingsKey, jsonStr);
}
Future<void> setClearPathOnMaxRetry(bool value) async {
await updateSettings(_settings.copyWith(clearPathOnMaxRetry: value));
}
Future<void> setMapShowRepeaters(bool value) async {
await updateSettings(_settings.copyWith(mapShowRepeaters: value));
}
Future<void> setMapShowChatNodes(bool value) async {
await updateSettings(_settings.copyWith(mapShowChatNodes: value));
}
Future<void> setMapShowOtherNodes(bool value) async {
await updateSettings(_settings.copyWith(mapShowOtherNodes: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}
Future<void> setMapKeyPrefixEnabled(bool value) async {
await updateSettings(_settings.copyWith(mapKeyPrefixEnabled: value));
}
Future<void> setMapKeyPrefix(String value) async {
await updateSettings(_settings.copyWith(mapKeyPrefix: value));
}
Future<void> setMapShowMarkers(bool value) async {
await updateSettings(_settings.copyWith(mapShowMarkers: value));
}
Future<void> setNotificationsEnabled(bool value) async {
await updateSettings(_settings.copyWith(notificationsEnabled: value));
}
Future<void> setNotifyOnNewMessage(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
}
Future<void> setNotifyOnNewAdvert(bool value) async {
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
}
Future<void> setAutoRouteRotationEnabled(bool value) async {
await updateSettings(_settings.copyWith(autoRouteRotationEnabled: value));
}
Future<void> setThemeMode(String value) async {
await updateSettings(_settings.copyWith(themeMode: value));
}
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../connector/meshcore_protocol.dart';
class BleDebugLogEntry {
final DateTime timestamp;
final bool outgoing;
final String description;
final Uint8List payload;
BleDebugLogEntry({
required this.timestamp,
required this.outgoing,
required this.description,
required this.payload,
});
String get hexPreview {
const maxBytes = 64;
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex;
}
}
class BleRawLogRxEntry {
final DateTime timestamp;
final Uint8List payload;
BleRawLogRxEntry({
required this.timestamp,
required this.payload,
});
String get hexPreview {
const maxBytes = 64;
final bytes = payload.length > maxBytes ? payload.sublist(0, maxBytes) : payload;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
return payload.length > maxBytes ? '$hex' : hex;
}
}
class BleDebugLogService extends ChangeNotifier {
static const int maxEntries = 500;
final List<BleDebugLogEntry> _entries = [];
final List<BleRawLogRxEntry> _rawLogRxEntries = [];
List<BleDebugLogEntry> get entries => List.unmodifiable(_entries);
List<BleRawLogRxEntry> get rawLogRxEntries => List.unmodifiable(_rawLogRxEntries);
void logFrame(Uint8List frame, {required bool outgoing, String? note}) {
if (frame.isEmpty) return;
final code = frame[0];
final description = _describeFrame(code, frame, outgoing, note);
_entries.add(
BleDebugLogEntry(
timestamp: DateTime.now(),
outgoing: outgoing,
description: description,
payload: Uint8List.fromList(frame),
),
);
if (_entries.length > maxEntries) {
_entries.removeRange(0, _entries.length - maxEntries);
}
if (!outgoing && code == pushCodeLogRxData && frame.length > 3) {
_rawLogRxEntries.add(
BleRawLogRxEntry(
timestamp: DateTime.now(),
payload: Uint8List.fromList(frame.sublist(3)),
),
);
if (_rawLogRxEntries.length > maxEntries) {
_rawLogRxEntries.removeRange(0, _rawLogRxEntries.length - maxEntries);
}
}
notifyListeners();
}
void clear() {
_entries.clear();
_rawLogRxEntries.clear();
notifyListeners();
}
String _describeFrame(int code, Uint8List frame, bool outgoing, String? note) {
final label = _codeLabel(code);
final prefix = outgoing ? 'TX' : 'RX';
final extra = _frameDetail(code, frame);
final noteText = note != null ? '$note' : '';
return '$prefix $label$extra$noteText';
}
String _codeLabel(int code) {
switch (code) {
case cmdAppStart:
return 'CMD_APP_START';
case cmdSendTxtMsg:
return 'CMD_SEND_TXT_MSG';
case cmdSendChannelTxtMsg:
return 'CMD_SEND_CHANNEL_TXT_MSG';
case cmdGetContacts:
return 'CMD_GET_CONTACTS';
case cmdGetDeviceTime:
return 'CMD_GET_DEVICE_TIME';
case cmdSetDeviceTime:
return 'CMD_SET_DEVICE_TIME';
case cmdSendSelfAdvert:
return 'CMD_SEND_SELF_ADVERT';
case cmdSetAdvertName:
return 'CMD_SET_ADVERT_NAME';
case cmdAddUpdateContact:
return 'CMD_ADD_UPDATE_CONTACT';
case cmdSyncNextMessage:
return 'CMD_SYNC_NEXT_MESSAGE';
case cmdSetRadioParams:
return 'CMD_SET_RADIO_PARAMS';
case cmdSetRadioTxPower:
return 'CMD_SET_RADIO_TX_POWER';
case cmdResetPath:
return 'CMD_RESET_PATH';
case cmdRemoveContact:
return 'CMD_REMOVE_CONTACT';
case cmdReboot:
return 'CMD_REBOOT';
case cmdGetBattAndStorage:
return 'CMD_GET_BATT_AND_STORAGE';
case cmdSendLogin:
return 'CMD_SEND_LOGIN';
case cmdGetChannel:
return 'CMD_GET_CHANNEL';
case cmdSetChannel:
return 'CMD_SET_CHANNEL';
case cmdGetRadioSettings:
return 'CMD_GET_RADIO_SETTINGS';
case respCodeOk:
return 'RESP_CODE_OK';
case respCodeErr:
return 'RESP_CODE_ERR';
case respCodeContactsStart:
return 'RESP_CODE_CONTACTS_START';
case respCodeContact:
return 'RESP_CODE_CONTACT';
case respCodeEndOfContacts:
return 'RESP_CODE_END_OF_CONTACTS';
case respCodeSelfInfo:
return 'RESP_CODE_SELF_INFO';
case respCodeSent:
return 'RESP_CODE_SENT';
case respCodeContactMsgRecv:
return 'RESP_CODE_CONTACT_MSG_RECV';
case respCodeChannelMsgRecv:
return 'RESP_CODE_CHANNEL_MSG_RECV';
case respCodeCurrTime:
return 'RESP_CODE_CURR_TIME';
case respCodeNoMoreMessages:
return 'RESP_CODE_NO_MORE_MESSAGES';
case respCodeBattAndStorage:
return 'RESP_CODE_BATT_AND_STORAGE';
case respCodeContactMsgRecvV3:
return 'RESP_CODE_CONTACT_MSG_RECV_V3';
case respCodeChannelMsgRecvV3:
return 'RESP_CODE_CHANNEL_MSG_RECV_V3';
case respCodeChannelInfo:
return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeAdvert:
return 'PUSH_CODE_ADVERT';
case pushCodePathUpdated:
return 'PUSH_CODE_PATH_UPDATED';
case pushCodeSendConfirmed:
return 'PUSH_CODE_SEND_CONFIRMED';
case pushCodeMsgWaiting:
return 'PUSH_CODE_MSG_WAITING';
case pushCodeLoginSuccess:
return 'PUSH_CODE_LOGIN_SUCCESS';
case pushCodeLoginFail:
return 'PUSH_CODE_LOGIN_FAIL';
case pushCodeLogRxData:
return 'PUSH_CODE_LOG_RX_DATA';
case pushCodeNewAdvert:
return 'PUSH_CODE_NEW_ADVERT';
default:
return 'CODE_$code';
}
}
String _frameDetail(int code, Uint8List frame) {
switch (code) {
case respCodeSent:
if (frame.length >= 10) {
final timeoutMs = readUint32LE(frame, 6);
return ' • timeout=${timeoutMs}ms';
}
return '';
case pushCodeSendConfirmed:
if (frame.length >= 9) {
final tripMs = readUint32LE(frame, 5);
return ' • trip=${tripMs}ms';
}
return '';
case pushCodeLoginSuccess:
return ' • login ok';
case pushCodeLoginFail:
return ' • login fail';
case respCodeBattAndStorage:
if (frame.length >= 3) {
final mv = readUint16LE(frame, 1);
return '${mv}mV';
}
return '';
default:
return '';
}
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
class MapMarkerService {
static const String _removedKey = 'map_removed_marker_ids';
Future<Set<String>> loadRemovedIds() async {
final prefs = await SharedPreferences.getInstance();
final items = prefs.getStringList(_removedKey) ?? const [];
return items.toSet();
}
Future<void> saveRemovedIds(Set<String> ids) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedKey, ids.toList());
}
}
+348
View File
@@ -0,0 +1,348 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/path_selection.dart';
import 'storage_service.dart';
import 'app_settings_service.dart';
class MessageRetryService extends ChangeNotifier {
static const int maxRetries = 5;
final StorageService _storage;
final Map<String, Timer> _timeoutTimers = {};
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {};
Function(Contact, String, bool, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback;
Function(int, int)? _calculateTimeoutCallback;
AppSettingsService? _appSettingsService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
MessageRetryService(this._storage);
void initialize({
required Function(Contact, String, bool, int, int) sendMessageCallback,
required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
AppSettingsService? appSettingsService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback,
}) {
_sendMessageCallback = sendMessageCallback;
_addMessageCallback = addMessageCallback;
_updateMessageCallback = updateMessageCallback;
_clearContactPathCallback = clearContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback;
_appSettingsService = appSettingsService;
_recordPathResultCallback = recordPathResultCallback;
}
Future<void> sendMessageWithRetry({
required Contact contact,
required String text,
bool forceFlood = false,
PathSelection? pathSelection,
Uint8List? pathBytes,
int? pathLength,
}) async {
final messageId = const Uuid().v4();
final effectiveForceFlood = forceFlood || (pathSelection?.useFlood ?? false);
final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, effectiveForceFlood, pathSelection);
final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, effectiveForceFlood, pathSelection);
final message = Message(
senderKey: contact.publicKey,
text: text,
timestamp: DateTime.now(),
isOutgoing: true,
status: MessageStatus.pending,
messageId: messageId,
retryCount: 0,
forceFlood: effectiveForceFlood,
pathLength: messagePathLength,
pathBytes: messagePathBytes,
);
_pendingMessages[messageId] = message;
_pendingContacts[messageId] = contact;
if (pathSelection != null) {
_pendingPathSelections[messageId] = pathSelection;
}
if (_addMessageCallback != null) {
_addMessageCallback!(contact.publicKeyHex, message);
}
await _attemptSend(messageId);
}
Future<void> _attemptSend(String messageId) async {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
if (message == null || contact == null) return;
Contact sendContact = contact;
final attempt = message.retryCount.clamp(0, 3);
if (message.forceFlood && contact.pathLength >= 0) {
sendContact = Contact(
publicKey: contact.publicKey,
name: contact.name,
type: contact.type,
pathLength: -1,
path: contact.path,
latitude: contact.latitude,
longitude: contact.longitude,
lastSeen: contact.lastSeen,
);
}
if (_sendMessageCallback != null) {
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
_sendMessageCallback!(
sendContact,
message.text,
message.forceFlood,
attempt,
timestampSeconds,
);
}
}
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.status == MessageStatus.pending) {
final contact = _pendingContacts[entry.key];
final selection = _pendingPathSelections[entry.key];
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
int actualTimeout = timeoutMs;
if (timeoutMs <= 0 && _calculateTimeoutCallback != null && contact != null) {
int pathLengthValue;
if (selection != null) {
pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
if (pathLengthValue < 0) pathLengthValue = contact.pathLength;
} else if (message.pathLength != null) {
pathLengthValue = message.pathLength!;
} else {
pathLengthValue = message.forceFlood ? -1 : contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
}
final updatedMessage = message.copyWith(
status: MessageStatus.sent,
expectedAckHash: ackHash,
estimatedTimeoutMs: actualTimeout,
sentAt: DateTime.now(),
);
_pendingMessages[entry.key] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
_startTimeoutTimer(entry.key, actualTimeout);
return;
}
}
}
void _startTimeoutTimer(String messageId, int timeoutMs) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers[messageId] = Timer(Duration(milliseconds: timeoutMs), () {
_handleTimeout(messageId);
});
}
void _handleTimeout(String messageId) {
final message = _pendingMessages[messageId];
final contact = _pendingContacts[messageId];
final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) return;
if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount);
final updatedMessage = message.copyWith(
retryCount: message.retryCount + 1,
status: MessageStatus.pending,
);
_pendingMessages[messageId] = updatedMessage;
if (_updateMessageCallback != null) {
_updateMessageCallback!(updatedMessage);
}
Timer(Duration(milliseconds: backoffMs), () {
_attemptSend(messageId);
});
} else {
// Max retries reached - mark as failed
final failedMessage = message.copyWith(status: MessageStatus.failed);
_pendingMessages.remove(messageId);
_pendingContacts.remove(messageId);
_pendingPathSelections.remove(messageId);
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
// Check if we should clear the path on max retry
if (_appSettingsService?.settings.clearPathOnMaxRetry == true &&
_clearContactPathCallback != null) {
_clearContactPathCallback!(contact);
}
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
}
notifyListeners();
}
}
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId;
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
matchedMessageId = entry.key;
break;
}
}
if (matchedMessageId != null) {
final message = _pendingMessages[matchedMessageId]!;
final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId];
_timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId);
final deliveredMessage = message.copyWith(
status: MessageStatus.delivered,
deliveredAt: DateTime.now(),
tripTimeMs: tripTimeMs,
);
_pendingMessages.remove(matchedMessageId);
_pendingContacts.remove(matchedMessageId);
_pendingPathSelections.remove(matchedMessageId);
if (_updateMessageCallback != null) {
_updateMessageCallback!(deliveredMessage);
}
if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
}
notifyListeners();
}
}
Uint8List _resolveMessagePathBytes(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0);
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes);
}
return contact.path;
}
int? _resolveMessagePathLength(
Contact contact,
bool forceFlood,
PathSelection? selection,
) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) {
return -1;
}
if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount;
}
return contact.pathLength;
}
String? getContactKeyForAckHash(Uint8List ackHash) {
for (var entry in _pendingMessages.entries) {
final message = entry.value;
if (message.expectedAckHash != null &&
listEquals(message.expectedAckHash, ackHash)) {
final contact = _pendingContacts[entry.key];
return contact?.publicKeyHex;
}
}
return null;
}
int calculateDefaultTimeout(Contact contact) {
if (contact.pathLength < 0) {
return 15000;
} else {
return 3000 + (3000 * contact.pathLength);
}
}
void _recordPathResultFromMessage(
String contactKey,
Message message,
PathSelection? selection,
bool success,
int? tripTimeMs,
) {
if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
}
PathSelection? _selectionFromMessage(Message message) {
if (message.forceFlood || (message.pathLength != null && message.pathLength! < 0)) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
if (message.pathBytes.isEmpty && message.pathLength == null) {
return null;
}
return PathSelection(
pathBytes: message.pathBytes,
hopCount: message.pathLength ?? message.pathBytes.length,
useFlood: false,
);
}
@override
void dispose() {
for (var timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
_pendingMessages.clear();
_pendingContacts.clear();
_pendingPathSelections.clear();
super.dispose();
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
try {
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
);
_isInitialized = true;
} catch (e) {
debugPrint('Error initializing notifications: $e');
}
}
Future<bool> requestPermissions() async {
if (!_isInitialized) {
await initialize();
}
// Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false;
}
// iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
return granted ?? false;
}
return true;
}
Future<void> showMessageNotification({
required String contactName,
required String message,
String? contactId,
}) async {
if (!_isInitialized) {
await initialize();
}
const androidDetails = AndroidNotificationDetails(
'messages',
'Messages',
channelDescription: 'New message notifications',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
contactId?.hashCode ?? 0,
'New message from $contactName',
message.length > 100 ? '${message.substring(0, 100)}...' : message,
notificationDetails,
payload: 'message:$contactId',
);
}
Future<void> showAdvertNotification({
required String contactName,
required String contactType,
String? contactId,
}) async {
if (!_isInitialized) {
await initialize();
}
const androidDetails = AndroidNotificationDetails(
'adverts',
'Advertisements',
channelDescription: 'New node advertisement notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _notifications.show(
contactId?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
'New $contactType discovered',
contactName,
notificationDetails,
payload: 'advert:$contactId',
);
}
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
debugPrint('Notification tapped: $payload');
// Handle navigation based on payload
// This can be extended to navigate to specific screens
}
}
Future<void> cancelAll() async {
await _notifications.cancelAll();
}
Future<void> cancel(int id) async {
await _notifications.cancel(id);
}
}
+324
View File
@@ -0,0 +1,324 @@
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart';
import 'storage_service.dart';
class PathHistoryService extends ChangeNotifier {
final StorageService _storage;
final Map<String, ContactPathHistory> _cache = {};
final Map<String, int> _autoRotationIndex = {};
final Map<String, _FloodStats> _floodStats = {};
static const int _maxHistoryEntries = 100;
static const int _autoRotationTopCount = 3;
PathHistoryService(this._storage);
Future<void> initialize() async {
// Load cached path histories on startup if needed
}
void handlePathUpdated(Contact contact) {
if (contact.pathLength < 0) return;
_addPathRecord(
contactPubKeyHex: contact.publicKeyHex,
hopCount: contact.pathLength,
tripTimeMs: 0,
wasFloodDiscovery: true,
pathBytes: contact.path,
successCount: 0,
failureCount: 0,
);
}
void recordPathAttempt(String contactPubKeyHex, PathSelection selection) {
if (selection.useFlood) {
_updateFloodStats(contactPubKeyHex);
return;
}
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
tripTimeMs: 0,
wasFloodDiscovery: false,
pathBytes: selection.pathBytes,
successCount: 0,
failureCount: 0,
);
}
void recordPathResult(
String contactPubKeyHex,
PathSelection selection, {
required bool success,
int? tripTimeMs,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
if (success) {
stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
} else {
stats.failureCount += 1;
}
stats.lastUsed = DateTime.now();
return;
}
final existing = _findPathRecord(contactPubKeyHex, selection.pathBytes);
final successCount = (existing?.successCount ?? 0) + (success ? 1 : 0);
final failureCount = (existing?.failureCount ?? 0) + (success ? 0 : 1);
_addPathRecord(
contactPubKeyHex: contactPubKeyHex,
hopCount: selection.hopCount,
tripTimeMs: success ? (tripTimeMs ?? 0) : (existing?.tripTimeMs ?? 0),
wasFloodDiscovery: existing?.wasFloodDiscovery ?? false,
pathBytes: selection.pathBytes,
successCount: successCount,
failureCount: failureCount,
);
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex)
.take(_autoRotationTopCount)
.toList();
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
final selections = ranked
.map((path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
))
.toList()
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
_autoRotationIndex[contactPubKeyHex] = currentIndex + 1;
return selection;
}
void _addPathRecord({
required String contactPubKeyHex,
required int hopCount,
required int tripTimeMs,
required bool wasFloodDiscovery,
required List<int> pathBytes,
required int successCount,
required int failureCount,
}) {
var history = _cache[contactPubKeyHex];
if (history == null) {
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
} else {
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: [],
);
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
});
return;
}
_addPathRecordInternal(
contactPubKeyHex,
hopCount,
tripTimeMs,
wasFloodDiscovery,
pathBytes,
successCount,
failureCount,
);
}
void _addPathRecordInternal(
String contactPubKeyHex,
int hopCount,
int tripTimeMs,
bool wasFloodDiscovery,
List<int> pathBytes,
int successCount,
int failureCount,
) {
var history = _cache[contactPubKeyHex];
if (history == null) return;
final existing = _findPathRecord(contactPubKeyHex, pathBytes);
if (existing != null) {
successCount = successCount == 0 ? existing.successCount : successCount;
failureCount = failureCount == 0 ? existing.failureCount : failureCount;
if (tripTimeMs == 0) {
tripTimeMs = existing.tripTimeMs;
}
wasFloodDiscovery = existing.wasFloodDiscovery || wasFloodDiscovery;
}
final newRecord = PathRecord(
hopCount: hopCount,
tripTimeMs: tripTimeMs,
timestamp: DateTime.now(),
wasFloodDiscovery: wasFloodDiscovery,
pathBytes: pathBytes,
successCount: successCount,
failureCount: failureCount,
);
final updatedPaths = List<PathRecord>.from(history.recentPaths);
updatedPaths.removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
if (existing == null && updatedPaths.length >= _maxHistoryEntries) {
return;
}
updatedPaths.insert(0, newRecord);
final updatedHistory = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: updatedPaths,
);
_cache[contactPubKeyHex] = updatedHistory;
_storage.savePathHistory(contactPubKeyHex, updatedHistory);
notifyListeners();
}
List<PathRecord> getRecentPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history != null) {
return history.recentPaths;
}
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
notifyListeners();
}
});
return [];
}
Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async {
return await _storage.loadPathHistory(contactPubKeyHex);
}
PathRecord? getFastestPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
return history?.fastest;
}
PathRecord? getMostRecentPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
return history?.mostRecent;
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
_autoRotationIndex.remove(contactPubKeyHex);
_floodStats.remove(contactPubKeyHex);
await _storage.clearPathHistory(contactPubKeyHex);
notifyListeners();
}
Future<void> removePathRecord(
String contactPubKeyHex,
List<int> pathBytes,
) async {
final history = _cache[contactPubKeyHex];
if (history == null) return;
final updatedPaths = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => _pathsEqual(p.pathBytes, pathBytes));
_cache[contactPubKeyHex] = ContactPathHistory(
contactPubKeyHex: contactPubKeyHex,
recentPaths: updatedPaths,
);
await _storage.savePathHistory(contactPubKeyHex, _cache[contactPubKeyHex]!);
notifyListeners();
}
PathRecord? _findPathRecord(String contactPubKeyHex, List<int> pathBytes) {
final history = _cache[contactPubKeyHex];
if (history == null) return null;
for (final record in history.recentPaths) {
if (_pathsEqual(record.pathBytes, pathBytes)) {
return record;
}
}
return null;
}
List<PathRecord> _getRankedPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history == null) return [];
final ranked = List<PathRecord>.from(history.recentPaths)
..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
}
final aTrip = a.tripTimeMs == 0 ? 999999 : a.tripTimeMs;
final bTrip = b.tripTimeMs == 0 ? 999999 : b.tripTimeMs;
if (aTrip != bTrip) return aTrip.compareTo(bTrip);
return b.timestamp.compareTo(a.timestamp);
});
return ranked;
}
bool _pathsEqual(List<int> a, List<int> b) {
return listEquals(a, b);
}
void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
stats.lastUsed = DateTime.now();
}
}
class _FloodStats {
int successCount = 0;
int failureCount = 0;
int lastTripTimeMs = 0;
DateTime? lastUsed;
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:typed_data';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
class RepeaterCommandService {
final MeshCoreConnector _connector;
final Map<String, Completer<String>> _pendingCommands = {};
final Map<String, Timer> _commandTimeouts = {};
final Map<String, String> _commandPrefixes = {};
final Map<String, String> _pendingByPrefix = {};
int _prefixCounter = 0;
static const int timeoutSeconds = 10; // Flood mode timeout
static const int maxRetries = 5;
RepeaterCommandService(this._connector);
/// Send a CLI command to a repeater with automatic retries
/// Returns a future that completes when a response is received or after max retries
Future<String> sendCommand(
Contact repeater,
String command, {
Function(String)? onResponse,
Function(int)? onAttempt,
}) async {
final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
if (hasPending) {
throw Exception('Another command is still awaiting a response.');
}
// Create completer for this command
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
final completer = Completer<String>();
_pendingCommands[commandId] = completer;
onAttempt?.call(0);
// Send frame once (no retries)
try {
final prefix = _nextPrefixToken();
_commandPrefixes[commandId] = prefix;
_pendingByPrefix[prefix] = commandId;
final framedCommand = '$prefix$command';
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0);
await _connector.sendFrame(frame);
} catch (e) {
_cleanup(commandId);
throw Exception('Failed to send command: $e');
}
// Set timeout for this attempt
_commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer(
Duration(seconds: timeoutSeconds),
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
_cleanup(commandId);
}
},
);
// Wait for response or timeout
try {
final response = await completer.future;
return response;
} finally {
_cleanup(commandId);
}
}
/// Call this when a text message response is received from a repeater
void handleResponse(Contact repeater, String responseText) {
// Find pending command for this repeater and complete it
final repeaterKey = repeater.publicKeyHex;
String? commandId;
String responsePayload = responseText;
if (responseText.length >= 3 && responseText[2] == '|') {
final prefix = responseText.substring(0, 3);
commandId = _pendingByPrefix[prefix];
responsePayload = responseText.substring(3).trimLeft();
}
commandId ??= _pendingCommands.keys.firstWhere(
(id) => id.startsWith(repeaterKey),
orElse: () => '',
);
if (commandId.isEmpty) return;
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.complete(responsePayload);
_cleanup(commandId);
}
}
void _cleanup(String commandId) {
_commandTimeouts[commandId]?.cancel();
_commandTimeouts.remove(commandId);
_pendingCommands.remove(commandId);
final prefix = _commandPrefixes.remove(commandId);
if (prefix != null) {
_pendingByPrefix.remove(prefix);
}
}
void dispose() {
for (final timer in _commandTimeouts.values) {
timer.cancel();
}
_commandTimeouts.clear();
_pendingCommands.clear();
_commandPrefixes.clear();
_pendingByPrefix.clear();
}
String _nextPrefixToken() {
for (var i = 0; i < 256; i++) {
final value = _prefixCounter++ & 0xFF;
final token = '${value.toRadixString(16).padLeft(2, '0').toUpperCase()}|';
if (!_pendingByPrefix.containsKey(token)) {
return token;
}
}
return '00|';
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/path_history.dart';
class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
static const String _pendingMessagesKey = 'pending_messages';
static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson());
await prefs.setString(key, jsonStr);
}
Future<ContactPathHistory?> loadPathHistory(String contactPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = prefs.getString(key);
if (jsonStr == null) return null;
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return ContactPathHistory.fromJson(contactPubKeyHex, json);
} catch (e) {
return null;
}
}
Future<void> clearPathHistory(String contactPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_pathHistoryPrefix$contactPubKeyHex';
await prefs.remove(key);
}
Future<void> clearAllPathHistories() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
final pathHistoryKeys =
keys.where((key) => key.startsWith(_pathHistoryPrefix));
for (final key in pathHistoryKeys) {
await prefs.remove(key);
}
}
Future<Map<String, String>> loadPendingMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMessagesKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as String));
} catch (e) {
return {};
}
}
Future<void> savePendingMessages(Map<String, String> pending) async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = jsonEncode(pending);
await prefs.setString(_pendingMessagesKey, jsonStr);
}
Future<void> clearPendingMessages() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_pendingMessagesKey);
}
/// Save a repeater password by public key hex
Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async {
final prefs = await SharedPreferences.getInstance();
final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password;
final jsonStr = jsonEncode(passwords);
await prefs.setString(_repeaterPasswordsKey, jsonStr);
}
/// Load all saved repeater passwords (map of pubKeyHex -> password)
Future<Map<String, String>> loadRepeaterPasswords() async {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_repeaterPasswordsKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(key, value as String));
} catch (e) {
return {};
}
}
/// Get a specific repeater's saved password
Future<String?> getRepeaterPassword(String repeaterPubKeyHex) async {
final passwords = await loadRepeaterPasswords();
return passwords[repeaterPubKeyHex];
}
/// Remove a saved repeater password
Future<void> removeRepeaterPassword(String repeaterPubKeyHex) async {
final prefs = await SharedPreferences.getInstance();
final passwords = await loadRepeaterPasswords();
passwords.remove(repeaterPubKeyHex);
final jsonStr = jsonEncode(passwords);
await prefs.setString(_repeaterPasswordsKey, jsonStr);
}
/// Clear all saved repeater passwords
Future<void> clearAllRepeaterPasswords() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_repeaterPasswordsKey);
}
}
+119
View File
@@ -0,0 +1,119 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/channel_message.dart';
import '../helpers/smaz.dart';
class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
/// Save messages for a specific channel
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$channelIndex';
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(key, jsonString);
}
/// Load messages for a specific channel
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$channelIndex';
final jsonString = prefs.getString(key);
if (jsonString == null) return [];
try {
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList.map((json) => _messageFromJson(json)).toList();
} catch (e) {
// If parsing fails, return empty list
return [];
}
}
/// Clear messages for a specific channel
Future<void> clearChannelMessages(int channelIndex) async {
final prefs = await SharedPreferences.getInstance();
final key = '$_keyPrefix$channelIndex';
await prefs.remove(key);
}
/// Clear all channel messages
Future<void> clearAllChannelMessages() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
for (var key in keys) {
await prefs.remove(key);
}
}
/// Convert ChannelMessage to JSON map
Map<String, dynamic> _messageToJson(ChannelMessage msg) {
return {
'senderKey': msg.senderKey != null ? base64Encode(msg.senderKey!) : null,
'senderName': msg.senderName,
'text': msg.text,
'timestamp': msg.timestamp.millisecondsSinceEpoch,
'isOutgoing': msg.isOutgoing,
'status': msg.status.index,
'channelIndex': msg.channelIndex,
'repeatCount': msg.repeatCount,
'pathLength': msg.pathLength,
'pathBytes': base64Encode(msg.pathBytes),
'repeats': msg.repeats.map(_repeatToJson).toList(),
};
}
/// Convert JSON map to ChannelMessage
ChannelMessage _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String;
final decodedText = Smaz.tryDecodePrefixed(rawText) ?? rawText;
return ChannelMessage(
senderKey: json['senderKey'] != null
? Uint8List.fromList(base64Decode(json['senderKey']))
: null,
senderName: json['senderName'] as String,
text: decodedText,
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
isOutgoing: json['isOutgoing'] as bool,
status: ChannelMessageStatus.values[json['status'] as int],
repeatCount: (json['repeatCount'] as int?) ?? 0,
pathLength: json['pathLength'] as int?,
pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
repeats: (json['repeats'] as List<dynamic>?)
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
.toList() ??
const [],
channelIndex: json['channelIndex'] as int?,
);
}
Map<String, dynamic> _repeatToJson(Repeat repeat) {
return {
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null,
'repeaterName': repeat.repeaterName,
'tripTimeMs': repeat.tripTimeMs,
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
};
}
Repeat _repeatFromJson(Map<String, dynamic> json) {
return Repeat(
repeaterKey: json['repeaterKey'] != null
? Uint8List.fromList(base64Decode(json['repeaterKey']))
: null,
repeaterName: json['repeaterName'] as String? ?? 'Unknown',
tripTimeMs: json['tripTimeMs'] as int? ?? 0,
path: (json['path'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(),
);
}
}

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