initial commit

This commit is contained in:
Janez T
2025-10-13 22:28:20 +02:00
commit 823a163123
175 changed files with 12697 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
.metadata Normal file
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: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: ios
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: linux
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: macos
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: web
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: windows
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# 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'

749
CLAUDE.md Normal file
View File

@@ -0,0 +1,749 @@
# CLAUDE.md - MeshCore SAR App Technical Reference
This document provides technical details for AI assistants (like Claude) working with this codebase.
## Project Overview
**Type**: Flutter Mobile Application
**Purpose**: Search and Rescue (SAR) operations with MeshCore mesh network devices
**Architecture**: Provider-based state management with BLE communication
**Target Platforms**: iOS 13+, Android 5.0+ (API 21+)
## Project Structure
```
lib/
├── models/ # Data models
│ ├── contact.dart # Contact with telemetry
│ ├── contact_telemetry.dart # GPS, battery, temperature
│ ├── message.dart # Messages and SAR markers
│ ├── sar_marker.dart # SAR tactical markers
│ ├── device_info.dart # BLE device connection state
│ └── map_layer.dart # Map tile layer definitions
├── services/ # Business logic services
│ ├── meshcore_ble_service.dart # BLE communication
│ ├── meshcore_constants.dart # Protocol constants
│ ├── buffer_reader.dart # Binary protocol reader
│ ├── buffer_writer.dart # Binary protocol writer
│ ├── cayenne_lpp_parser.dart # Telemetry decoder
│ └── tile_cache_service.dart # Offline map tiles
├── providers/ # State management
│ ├── connection_provider.dart # BLE connection state
│ ├── contacts_provider.dart # Contact list management
│ ├── messages_provider.dart # Message history + SAR markers
│ ├── map_provider.dart # Map navigation state
│ └── app_provider.dart # Coordinator provider
├── screens/ # UI screens
│ ├── home_screen.dart # Main screen with tabs
│ ├── messages_tab.dart # Message list view
│ ├── contacts_tab.dart # Contact list view
│ └── map_tab.dart # Interactive map view
├── widgets/ # Reusable UI components
│ └── map_markers.dart # Custom map marker widgets
├── utils/ # Utilities
│ └── sar_message_parser.dart # Parse S:<emoji>:lat,lon format
└── main.dart # App entry point
```
## Key Technologies
### Core Dependencies
- **flutter_blue_plus** (^2.0.0): BLE communication
- **flutter_map** (^8.2.2): Interactive mapping with OpenStreetMap
- **flutter_map_tile_caching** (^10.1.1): Offline map tile storage
- **provider** (^6.1.0): State management
- **latlong2** (^0.9.0): GPS coordinate handling
- **geolocator** (^14.0.2): Precise GPS location tracking
- **permission_handler** (^12.0.1): Runtime permissions
### MeshCore Protocol
The app implements the MeshCore BLE protocol based on https://github.com/meshcore-dev/meshcore.js
**BLE Service**: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E`
**RX Characteristic** (write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E`
**TX Characteristic** (notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E`
#### Command Codes (Client → Device)
- `4`: Get contacts list
- `2`: Send text message
- `39`: Request telemetry
#### Response Codes (Device → Client)
- `3`: Contact information
- `7`: Message received
- `0x8B` (139): Telemetry response (Cayenne LPP format)
#### Binary Protocol Format
All protocol messages use little-endian byte order.
**Get Contacts Request**:
```
[0x04] - Command code
```
**Contact Response**:
```
[0x03] - Response code
[32 bytes] - Public key
[1 byte] - Contact type (0=none, 1=chat, 2=repeater, 3=room)
[64 bytes] - Advertised name (null-terminated string)
[4 bytes] - Latitude (int32, divide by 10000 for degrees)
[4 bytes] - Longitude (int32, divide by 10000 for degrees)
```
**Message Received**:
```
[0x07] - Response code
[1 byte] - Message type (0=contact, 1=channel)
[4 bytes] - Sender public key prefix
[4 bytes] - Recipient public key prefix
[2 bytes] - Text length (uint16)
[N bytes] - UTF-8 text
```
**Send Text Message**:
```
[0x02] - Command code
[32 bytes] - Recipient public key
[2 bytes] - Text length (uint16)
[N bytes] - UTF-8 text
```
**Request Telemetry**:
```
[0x27] (39) - Command code
[32 bytes] - Contact public key
```
**Telemetry Response**:
```
[0x8B] (139) - Response code
[4 bytes] - Contact public key prefix
[N bytes] - Cayenne LPP payload
```
### Cayenne LPP Format
Telemetry data uses Cayenne Low Power Payload format:
```
[Channel] [Type] [Data...]
```
**Supported Types**:
- `136` (0x88): GPS Location
- 4 bytes: Latitude (int32, divide by 10000)
- 4 bytes: Longitude (int32, divide by 10000)
- 4 bytes: Altitude (int32, divide by 100)
- `103` (0x67): Temperature Sensor
- 2 bytes: Temperature (int16, divide by 10 for °C)
- `2` (0x02): Analog Input (used for battery voltage)
- 2 bytes: Value (uint16, divide by 100 for volts)
### SAR Message Format
Special tactical markers embedded in messages:
```
S:<emoji>:<latitude>,<longitude>
```
**Recognized Emojis**:
- `🧑` or `👤`: Found Person
- `🔥`: Fire Location
- `🏕️` or `⛺`: Staging Area
**Examples**:
- `S:🧑:46.0569,14.5058` - Person found at coordinates
- `S:🔥:46.0570,14.5060` - Fire detected
- `S:🏕️:46.0571,14.5062` - Base camp location
**Parsing Rules**:
- Must start with `S:`
- Single emoji character after first colon
- Comma-separated lat,lon after second colon
- Coordinates can be negative (e.g., `-12.3456`)
- No spaces allowed in format
## State Management Architecture
### Provider Hierarchy
```
MultiProvider
├── ConnectionProvider # BLE connection state
├── ContactsProvider # Contact list
├── MessagesProvider # Messages + SAR markers
├── MapProvider # Map navigation
└── AppProvider # Coordinator (uses all above)
```
### Event Flow
```
BLE Device → MeshCoreBleService → ConnectionProvider → AppProvider
ContactsProvider
MessagesProvider
UI
```
**Example: Receiving a Message**
1. BLE device sends message via TX characteristic
2. `MeshCoreBleService._onDataReceived()` parses binary data
3. Calls `onMessageReceived` callback
4. `ConnectionProvider` receives message
5. `AppProvider` enhances message (check for SAR format)
6. `MessagesProvider.addMessage()` stores message
7. UI rebuilds via `Consumer<MessagesProvider>`
### Contact Types
```dart
enum ContactType {
none(0), // Unknown/invalid
chat(1), // Team member (shown on map)
repeater(2), // Network repeater node
room(3), // Communication channel/room
}
```
**Map Display Rules**:
- Only `ContactType.chat` contacts with valid GPS are shown on map
- Repeaters and rooms are listed in Contacts tab but not mapped
## Map Implementation
### Tile Layers
Three tile sources are supported via `MapLayer` enum:
1. **OpenStreetMap** (default)
- URL: `https://tile.openstreetmap.org/{z}/{x}/{y}.png`
- Max zoom: 19
- Best for street-level navigation
2. **OpenTopoMap**
- URL: `https://a.tile.opentopomap.org/{z}/{x}/{y}.png`
- Max zoom: 17
- Shows topographic features, elevation contours
3. **ESRI World Imagery**
- URL: `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`
- Max zoom: 19
- Satellite imagery
### Offline Tile Caching
Uses `flutter_map_tile_caching` with ObjectBox backend:
```dart
// Initialize cache
await FMTCObjectBoxBackend().initialise();
final store = FMTCStore('meshcore_sar_tiles');
await store.manage.create();
// Download region
final region = RectangleRegion(bounds);
await store.download.startForeground(region: region);
```
**Cache Behavior**:
- `CacheBehavior.cacheFirst`: Use cached tiles if available
- 30-day validity period
- Automatic background updates when online
### Map Markers
**Team Member Markers** (Blue):
- CircleAvatar with person icon
- Battery percentage badge at top
- Name label at bottom
- Tap to show details dialog
**SAR Event Markers** (Color-coded):
- Found Person: Green with 🧑
- Fire: Red with 🔥
- Staging Area: Orange with 🏕️
- Time ago label at top
- Type label at bottom
- Tap to show details dialog
### Map Navigation
**Navigation from Messages Tab**:
1. User taps SAR marker message
2. `MapProvider.navigateToLocation()` called
3. Target location and zoom stored in provider
4. Tab switches to Map
5. `MapTab._handleMapNavigation()` moves map
6. `MapProvider.clearNavigation()` resets state
**Zoom State Preservation**:
- Current zoom stored in `MapProvider`
- Maintained across tab switches
- Updated on user zoom gestures
### User Location Tracking
The app tracks the user's precise GPS location in real-time:
**Permission Setup** (iOS Info.plist):
```xml
<key>NSLocationWhenInUseUsageDescription</key>
<string>MeshCore SAR needs location access to display team members and SAR markers on the map</string>
<key>NSLocationTemporaryPreciseUsageDescription</key>
<string>MeshCore SAR needs precise location for accurate positioning in SAR operations</string>
<key>NSLocationDefaultAccuracyReduced</key>
<false/>
```
**Implementation** (lib/screens/map_tab.dart):
```dart
Position? _currentPosition;
bool _trackingLocation = false;
// Request permission and start tracking
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
// Listen to continuous position updates
Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 10, // Update every 10 meters
),
).listen((Position position) {
setState(() => _currentPosition = position);
if (_trackingLocation) {
// Auto-center map on user location
_mapController.move(
LatLng(position.latitude, position.longitude),
_mapController.camera.zoom
);
}
});
```
**User Location Marker**:
- Blue pulsing circle showing current position
- Navigation icon indicating heading
- Tap location button to center map on user
- Tap again to enable tracking mode (map follows user movement)
### Map Legend
**Collapsible Legend** (lib/screens/map_tab.dart):
- Shows counts of team members and SAR markers
- Click to collapse to compact view
- Click again to expand
- Positioned in top-right corner
```dart
bool _showLegend = true;
GestureDetector(
onTap: () => setState(() => _showLegend = !_showLegend),
child: _showLegend
? _MapLegend(/* full legend with all counts */)
: Card(
child: Column([
Text('Legend'),
Icon(Icons.expand_more),
]),
),
)
```
## Building and Development
### Development Commands
```bash
# Install dependencies
flutter pub get
# Run in debug mode
flutter run
# Run with specific device
flutter run -d <device-id>
# Hot reload (during debug)
# Press 'r' in terminal
# Hot restart (during debug)
# Press 'R' in terminal
# Analyze code
flutter analyze
# Run tests
flutter test
# Format code
dart format lib/
# Clean build
flutter clean
```
### iOS Build
```bash
# Open Xcode workspace
open ios/Runner.xcworkspace
# Build from command line
flutter build ios --release
# Create IPA (requires signing)
flutter build ipa
```
**Key iOS Files**:
- `ios/Runner/Info.plist`: Permissions and app configuration
- `ios/Podfile`: CocoaPods dependencies
- `ios/Runner.xcodeproj`: Xcode project
### Android Build
```bash
# Debug APK
flutter build apk --debug
# Release APK
flutter build apk --release
# App Bundle (for Play Store)
flutter build appbundle --release
# Split APKs by ABI
flutter build apk --split-per-abi
```
**Key Android Files**:
- `android/app/src/main/AndroidManifest.xml`: Permissions and app configuration
- `android/app/build.gradle`: App-level build configuration
- `android/build.gradle`: Project-level build configuration
## Common Development Tasks
### Adding a New BLE Command
1. **Add command code** to `lib/services/meshcore_constants.dart`:
```dart
static const int cmdYourCommand = 42;
```
2. **Create command method** in `lib/services/meshcore_ble_service.dart`:
```dart
Future<void> yourCommand() async {
final writer = BufferWriter();
writer.writeByte(MeshCoreConstants.cmdYourCommand);
await _sendCommand(writer.toBytes());
}
```
3. **Handle response** in `_onDataReceived()`:
```dart
case MeshCoreConstants.respYourResponse:
// Parse response data
onYourCallback?.call(data);
break;
```
### Adding a New SAR Marker Type
1. **Update enum** in `lib/models/sar_marker.dart`:
```dart
enum SarMarkerType {
// existing types...
yourType('🆕', 'Your Type');
}
```
2. **Add to parser** in `lib/utils/sar_message_parser.dart`:
```dart
case '🆕':
return SarMarkerType.yourType;
```
3. **Add color** in `lib/widgets/map_markers.dart`:
```dart
case SarMarkerType.yourType:
return Colors.purple;
```
4. **Update providers** in `lib/providers/messages_provider.dart`:
```dart
List<SarMarker> get yourTypeMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.yourType).toList();
```
### Adding a New Map Layer
1. **Add to model** in `lib/models/map_layer.dart`:
```dart
static const yourLayer = MapLayer(
type: MapLayerType.yourLayer,
name: 'Your Layer',
urlTemplate: 'https://your-tile-server/{z}/{x}/{y}.png',
attribution: '© Your Attribution',
maxZoom: 19,
);
```
2. **Add to list**:
```dart
static const List<MapLayer> allLayers = [
openStreetMap,
openTopoMap,
esriWorldImagery,
yourLayer, // Add here
];
```
3. Layer automatically appears in layer selector UI
## Testing
### Unit Tests
```bash
# Run all tests
flutter test
# Run specific test file
flutter test test/widget_test.dart
# Run with coverage
flutter test --coverage
```
### Integration Tests
```bash
# Run integration tests
flutter drive --target=test_driver/app.dart
```
### Manual Testing Checklist
**BLE Connection**:
- [ ] Scan discovers MeshCore devices
- [ ] Connection successful
- [ ] Device info displayed in status bar
- [ ] Disconnect works properly
**Contacts**:
- [ ] Contacts load after connection
- [ ] Contacts grouped by type
- [ ] Telemetry request works
- [ ] Battery/GPS displayed correctly
**Messages**:
- [ ] Messages received and displayed
- [ ] SAR markers highlighted
- [ ] Tap SAR marker navigates to map
- [ ] Message timestamps correct
**Map**:
- [ ] Map loads and displays tiles
- [ ] Team member markers appear
- [ ] SAR markers appear with correct colors
- [ ] Layer switching works
- [ ] Zoom/pan gestures work
- [ ] Marker tap shows details
- [ ] Offline tiles load
## Troubleshooting
### BLE Issues
**"Bluetooth adapter not available"**:
- Check device Bluetooth is on
- Verify permissions granted
- iOS: Check Info.plist has usage descriptions
- Android: Check AndroidManifest.xml has permissions
**"Connection failed"**:
- Device must support BLE
- Check service UUID matches
- Verify device is in range (<10m typically)
- Try scanning again
### Runtime Issues
**MissingPluginException for geolocator or other plugins**:
Example error:
```
MissingPluginException(No implementation found for method isLocationServiceEnabled
on channel flutter.baseflow.com/geolocator_apple)
```
This occurs when native plugin implementations aren't properly installed. Common after adding new dependencies.
**Solution**:
```bash
# For iOS
cd ios
pod install
cd ..
# Clean and rebuild
flutter clean
flutter pub get
flutter run
```
**If still failing on iOS**:
```bash
cd ios
rm Podfile.lock
rm -rf Pods/
pod install
cd ..
flutter clean
flutter pub get
```
### Build Issues
**iOS Pod Install Fails**:
```bash
cd ios
rm Podfile.lock
rm -rf Pods/
pod install --repo-update
cd ..
```
**CocoaPods ObjectBox Version Conflict**:
This error occurs when flutter_map_tile_caching updates its ObjectBox dependency but the cached Podfile.lock has an older version:
```
[!] CocoaPods could not find compatible versions for pod "ObjectBox":
In snapshot (Podfile.lock): ObjectBox (= 1.9.2)
In Podfile: objectbox_flutter_libs depends on ObjectBox (= 4.4.1)
```
**Solution**:
```bash
# Navigate to iOS directory
cd ios
# Remove cached dependency lock file
rm Podfile.lock
# Remove all installed pods
rm -rf Pods/
# Update CocoaPods repository (this may take a few minutes)
pod repo update
# Reinstall all pods with updated versions
pod install
# Return to project root
cd ..
# Clean Flutter build cache
flutter clean
# Reinstall Flutter dependencies
flutter pub get
# Run the app
flutter run
```
**Alternative solution** (if the above doesn't work):
```bash
cd ios
rm Podfile.lock
rm -rf Pods/
pod deintegrate
pod cache clean --all
pod setup
pod install
cd ..
flutter clean
flutter pub get
```
**Note**: The `pod repo update` command can take 5-10 minutes as it downloads the entire CocoaPods specifications repository. This is normal.
**Android Gradle Timeout**:
```gradle
// android/gradle.properties
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx4096m
```
**Flutter Version Conflicts**:
```bash
flutter channel stable
flutter upgrade
flutter pub upgrade
```
## Performance Optimization
### BLE Communication
- Buffer incoming data to handle partial packets
- Throttle telemetry requests (max 1 per second per contact)
- Use `notifyListeners()` sparingly in providers
### Map Performance
- Limit visible markers (cluster if >100 markers)
- Use `repaint boundary` for marker widgets
- Implement marker virtualization for large datasets
### Memory Management
- Dispose controllers in `dispose()` methods
- Clear message history after 1000 messages
- Implement tile cache size limits
## Security Considerations
- **BLE**: No authentication in current protocol - add encryption for production
- **Permissions**: Request minimum required permissions
- **Data**: No sensitive data should be logged
- **Network**: Use HTTPS for all tile sources
## Future Enhancements
Potential features to add:
1. **Message Sending**: UI to compose and send messages
2. **Route Recording**: Track team member paths over time
3. **Geofencing**: Alerts when team members enter/exit areas
4. **Voice Notes**: Attach audio to SAR markers
5. **Team Chat**: Real-time chat between team members
6. **Mission Plans**: Pre-loaded search patterns
7. **Statistics**: Coverage analysis, search time tracking
## References
- [Flutter Documentation](https://docs.flutter.dev/)
- [flutter_blue_plus API](https://pub.dev/documentation/flutter_blue_plus/)
- [flutter_map Documentation](https://docs.fleaflet.dev/)
- [MeshCore Protocol](https://github.com/meshcore-dev/meshcore.js)
- [Cayenne LPP Specification](https://developers.mydevices.com/cayenne/docs/lora/#lora-cayenne-low-power-payload)
- [Provider Package](https://pub.dev/packages/provider)
## Contact
For questions or contributions, please refer to the project repository or contact the development team.

343
README.md Normal file
View File

@@ -0,0 +1,343 @@
# MeshCore SAR App
A Flutter-based Search and Rescue (SAR) application that communicates with MeshCore mesh network devices via Bluetooth Low Energy (BLE).
## Features
- **Real-time Messaging**: Receive and display messages from MeshCore mesh network
- **Contact Management**: Track team members, repeaters, and communication channels
- **SAR Markers**: Special location markers for found persons, fires, and staging areas
- **Interactive Map**: View team locations and SAR markers on an interactive map with multiple layer options:
- OpenStreetMap (default)
- OpenTopoMap (topographic)
- ESRI World Imagery (satellite)
- **Offline Support**: Map tiles cached for offline operation
- **Telemetry Tracking**: Monitor battery levels, GPS locations, and temperature for all contacts
- **BLE Communication**: Direct connection to MeshCore devices via Bluetooth
## Prerequisites
Before building the app, ensure you have:
- Flutter SDK 3.19.0 or higher
- Dart SDK 3.3.0 or higher
- Xcode 15+ (for iOS builds)
- Android Studio with Android SDK (for Android builds)
- CocoaPods (for iOS dependencies)
### Install Flutter
If you haven't installed Flutter yet:
```bash
# macOS/Linux
git clone https://github.com/flutter/flutter.git -b stable
export PATH="$PATH:`pwd`/flutter/bin"
# Verify installation
flutter doctor
```
## Setup
1. **Clone the repository**:
```bash
cd /path/to/meshcore-sar/meshcore_sar_app
```
2. **Install dependencies**:
```bash
flutter pub get
```
3. **Verify setup**:
```bash
flutter doctor
```
Fix any issues reported by Flutter Doctor before proceeding.
## Building and Running
### iOS
#### Requirements
- macOS computer
- Xcode 15 or higher
- Apple Developer account (for physical device deployment)
- iOS device with iOS 13.0 or higher
#### Steps
1. **Open iOS folder in Xcode**:
```bash
open ios/Runner.xcworkspace
```
2. **Configure signing**:
- In Xcode, select the Runner project
- Go to "Signing & Capabilities"
- Select your development team
- Xcode will automatically handle provisioning
3. **Connect your iOS device** via USB
4. **Enable Developer Mode** on your iOS device:
- Settings → Privacy & Security → Developer Mode → Enable
5. **Trust your Mac** on the iOS device when prompted
6. **Run the app**:
```bash
# Run in debug mode
flutter run
# Or build release
flutter build ios --release
```
7. **Install on device from Xcode**:
- Select your device in Xcode
- Click the "Run" button (▶️)
#### Common iOS Issues
**Problem**: "Runner has conflicting provisioning settings"
```bash
# Solution: Clean and rebuild
cd ios
pod deintegrate
pod install
cd ..
flutter clean
flutter pub get
```
**Problem**: Bluetooth permissions not working
- Ensure `Info.plist` contains all required permission keys
- Check that permissions are requested at runtime
### Android
#### Requirements
- Android Studio installed
- Android SDK 21 (Android 5.0) or higher
- Physical Android device or emulator
#### Steps
1. **Enable Developer Options** on your Android device:
- Settings → About Phone → Tap "Build Number" 7 times
- Go back → Developer Options → Enable "USB Debugging"
2. **Connect your Android device** via USB and authorize the computer
3. **Verify device connection**:
```bash
flutter devices
```
4. **Run the app**:
```bash
# Run in debug mode
flutter run
# Or specify device
flutter run -d <device-id>
```
5. **Build APK**:
```bash
# Debug APK
flutter build apk --debug
# Release APK
flutter build apk --release
# App Bundle (for Play Store)
flutter build appbundle --release
```
The APK will be located at:
- Debug: `build/app/outputs/flutter-apk/app-debug.apk`
- Release: `build/app/outputs/flutter-apk/app-release.apk`
6. **Install APK manually**:
```bash
# Install on connected device
flutter install
# Or use adb
adb install build/app/outputs/flutter-apk/app-release.apk
```
#### Common Android Issues
**Problem**: Gradle build fails
```bash
# Solution: Clean and rebuild
flutter clean
cd android
./gradlew clean
cd ..
flutter pub get
flutter build apk
```
**Problem**: Bluetooth permissions denied
- Ensure all Bluetooth permissions are in `AndroidManifest.xml`
- For Android 12+, request `BLUETOOTH_SCAN` and `BLUETOOTH_CONNECT` at runtime
**Problem**: "Execution failed for task ':app:minifyReleaseWithR8'"
```bash
# Add to android/app/build.gradle
android {
buildTypes {
release {
minifyEnabled false
}
}
}
```
## Running on Emulator/Simulator
### iOS Simulator
```bash
# List available simulators
flutter emulators
# Launch a simulator
flutter emulators --launch <simulator-id>
# Run app
flutter run
```
**Note**: BLE functionality will not work on iOS Simulator. Use a physical device for testing.
### Android Emulator
```bash
# List available emulators
flutter emulators
# Create new emulator in Android Studio:
# Tools → Device Manager → Create Virtual Device
# Launch emulator
flutter emulators --launch <emulator-id>
# Run app
flutter run
```
**Note**: BLE functionality requires specific emulator setup or physical device.
## Permissions
The app requires the following permissions:
### iOS (ios/Runner/Info.plist)
- `NSBluetoothAlwaysUsageDescription`: Bluetooth access for MeshCore devices
- `NSLocationWhenInUseUsageDescription`: Location access for map features
### Android (android/app/src/main/AndroidManifest.xml)
- `BLUETOOTH_SCAN`: Scan for BLE devices
- `BLUETOOTH_CONNECT`: Connect to BLE devices
- `ACCESS_FINE_LOCATION`: Required for BLE scanning
- `INTERNET`: Download map tiles
## Usage
1. **Connect to MeshCore Device**:
- Tap "Connect" in the status bar
- Select your MeshCore device from the scan results
- Wait for connection confirmation
2. **View Messages**:
- Messages tab shows all received messages
- SAR marker messages are highlighted
- Tap SAR marker to view on map
3. **Manage Contacts**:
- Contacts tab shows team members, repeaters, and channels
- Tap contact to view details
- Use refresh button to request telemetry updates
4. **View Map**:
- Map tab displays team locations and SAR markers
- Tap layer selector to switch between map styles
- Use zoom controls or pinch gestures
- Tap markers for details
- Tap items in bottom list to navigate
## SAR Marker Format
Messages can contain SAR markers using this format:
```
S:<emoji>:<latitude>,<longitude>
```
Examples:
- `S:🧑:46.0569,14.5058` - Found person
- `S:🔥:46.0570,14.5060` - Fire location
- `S:🏕️:46.0571,14.5062` - Staging area
## Architecture
- **Models**: Data structures for contacts, messages, markers, telemetry
- **Services**: BLE communication, tile caching, protocol parsing
- **Providers**: State management using Provider pattern
- **Screens**: UI components for messages, contacts, map
- **Widgets**: Reusable UI elements like map markers
## Dependencies
Key packages used:
- `flutter_blue_plus`: BLE communication
- `flutter_map`: Interactive mapping
- `flutter_map_tile_caching`: Offline map tiles
- `provider`: State management
- `latlong2`: GPS coordinate handling
- `permission_handler`: Runtime permissions
## Troubleshooting
### App crashes on launch
```bash
flutter clean
flutter pub get
flutter run
```
### BLE not working
- Ensure Bluetooth is enabled on device
- Check that all permissions are granted
- Verify MeshCore device is powered on and in range
### Map tiles not loading
- Check internet connection
- Verify tile URLs are accessible
- Clear tile cache and reload
### Build errors
```bash
# Complete clean rebuild
flutter clean
cd ios && pod deintegrate && pod install && cd ..
cd android && ./gradlew clean && cd ..
flutter pub get
flutter run
```
## License
This project is for Search and Rescue operations using MeshCore mesh network devices.
## Support
For issues related to:
- **Flutter**: https://flutter.dev/community
- **MeshCore Protocol**: https://github.com/meshcore-dev/meshcore.js
- **App Issues**: Open an issue in this repository

28
analysis_options.yaml Normal file
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
android/.gitignore vendored Normal file
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

View File

@@ -0,0 +1,44 @@
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.sar.meshcore_sar_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
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.sar.meshcore_sar_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -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>

View File

@@ -0,0 +1,59 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 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" />
<!-- Location permissions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Internet for map tiles -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:label="meshcore_sar_app"
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>

View File

@@ -0,0 +1,5 @@
package com.meshcore.sar.meshcore_sar_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
android/build.gradle.kts Normal file
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)
}

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

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

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
ios/.gitignore vendored Normal file
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

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>

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

118
ios/Podfile.lock Normal file
View File

@@ -0,0 +1,118 @@
PODS:
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- geolocator_apple (1.2.0):
- Flutter
- FlutterMacOS
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- Flutter
- ObjectBox (= 4.4.1)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3)
- share_plus (0.0.1):
- Flutter
- SwiftyGif (5.4.5)
DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- ObjectBox
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
objectbox_flutter_libs:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
COCOAPODS: 1.16.2

View File

@@ -0,0 +1,749 @@
// !$*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 */; };
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95A961E69DFB3804DF041D16 /* Pods_Runner.framework */; };
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 */; };
B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */; };
/* 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 */
0A6847CB76DAC5A39547F092 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
11010AF1B9B58058CB45D836 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
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>"; };
2FF0A626EBAC324A9D2CD7C8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; 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>"; };
3B8CF791ABE83A2374700CB3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
88D332C6C1988532993F282B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
8E8E561633FC42AD799C3EC6 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
95A961E69DFB3804DF041D16 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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 */
63128E4122D07543FBC5706D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B117D6FC7E214B5BDF078335 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6E07C83B534F125C2CBE6788 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
3C28992F8CC08CA6C1F157CE /* Pods */ = {
isa = PBXGroup;
children = (
0A6847CB76DAC5A39547F092 /* Pods-Runner.debug.xcconfig */,
88D332C6C1988532993F282B /* Pods-Runner.release.xcconfig */,
3B8CF791ABE83A2374700CB3 /* Pods-Runner.profile.xcconfig */,
8E8E561633FC42AD799C3EC6 /* Pods-RunnerTests.debug.xcconfig */,
2FF0A626EBAC324A9D2CD7C8 /* Pods-RunnerTests.release.xcconfig */,
11010AF1B9B58058CB45D836 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
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 */,
3C28992F8CC08CA6C1F157CE /* Pods */,
9C44871016BF8D8E5F823EE9 /* Frameworks */,
);
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>";
};
9C44871016BF8D8E5F823EE9 /* Frameworks */ = {
isa = PBXGroup;
children = (
95A961E69DFB3804DF041D16 /* Pods_Runner.framework */,
5E125ECD1538D6FD469DAD40 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
E57C8F1FC76A13F8392A3A72 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
63128E4122D07543FBC5706D /* Frameworks */,
);
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 = (
142118FEB8CC97859621AD22 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
0F5356670725D838348C6698 /* [CP] Embed Pods Frameworks */,
86AE9F22272E851DF6253AA1 /* [CP] Copy Pods Resources */,
);
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 */
0F5356670725D838348C6698 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
142118FEB8CC97859621AD22 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
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";
};
86AE9F22272E851DF6253AA1 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
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";
};
E57C8F1FC76A13F8392A3A72 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* 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)";
DEVELOPMENT_TEAM = PM784W7B8X;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp;
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;
baseConfigurationReference = 8E8E561633FC42AD799C3EC6 /* Pods-RunnerTests.debug.xcconfig */;
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.sar.meshcoreSarApp.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;
baseConfigurationReference = 2FF0A626EBAC324A9D2CD7C8 /* Pods-RunnerTests.release.xcconfig */;
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.sar.meshcoreSarApp.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;
baseConfigurationReference = 11010AF1B9B58058CB45D836 /* Pods-RunnerTests.profile.xcconfig */;
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.sar.meshcoreSarApp.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)";
DEVELOPMENT_TEAM = PM784W7B8X;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp;
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)";
DEVELOPMENT_TEAM = PM784W7B8X;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.sar.meshcoreSarApp;
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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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>

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>

61
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,61 @@
<?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 Sar App</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_sar_app</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>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>MeshCore SAR needs location access to display team members and SAR markers on the map</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>MeshCore SAR needs location access for offline map functionality during field operations</string>
<key>NSLocationTemporaryPreciseUsageDescription</key>
<string>MeshCore SAR needs precise location for accurate positioning in SAR operations</string>
<key>NSLocationDefaultAccuracyReduced</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

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.
}
}

View File

@@ -0,0 +1 @@
{"buildConfigurations":[{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98db4168bc813778add322f796c1886948","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/geolocator_apple","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"geolocator_apple","INFOPLIST_FILE":"Target Support Files/geolocator_apple/ResourceBundle-geolocator_apple_privacy-geolocator_apple-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"11.0","ONLY_ACTIVE_ARCH":"NO","PRODUCT_NAME":"geolocator_apple_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e984f90d418214f372cb5652188b6c03ab7","name":"Debug"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98eaf9cc3cd0e4bd5b5081a977e6dd395a","buildSettings":{"CLANG_ENABLE_OBJC_WEAK":"NO","CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/geolocator_apple","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"geolocator_apple","INFOPLIST_FILE":"Target Support Files/geolocator_apple/ResourceBundle-geolocator_apple_privacy-geolocator_apple-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"11.0","PRODUCT_NAME":"geolocator_apple_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e98ef1f6c2c0471787bb8c754d246f4e6f8","name":"Profile"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98eaf9cc3cd0e4bd5b5081a977e6dd395a","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/geolocator_apple","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"geolocator_apple","INFOPLIST_FILE":"Target Support Files/geolocator_apple/ResourceBundle-geolocator_apple_privacy-geolocator_apple-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"11.0","PRODUCT_NAME":"geolocator_apple_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e98e7e407f8bb5172b2db2019bb528593a9","name":"Release"}],"buildPhases":[{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e9884f731f5ac95fb5b060ee50b794122f2","type":"com.apple.buildphase.sources"},{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e982ce1691e61b20960a2ea685395560596","type":"com.apple.buildphase.frameworks"},{"buildFiles":[{"fileReference":"bfdfe7dc352907fc980b868725387e982d53e45b4eb2667e94d388d54e1ed196","guid":"bfdfe7dc352907fc980b868725387e98276a88038657a871d360f4cbfebc876d"}],"guid":"bfdfe7dc352907fc980b868725387e98cd4b69f26505cfa442ef066f8d043cc4","type":"com.apple.buildphase.resources"}],"buildRules":[],"dependencies":[],"guid":"bfdfe7dc352907fc980b868725387e98e1aba8ff8dc833f2269ce0a7182533b3","name":"geolocator_apple-geolocator_apple_privacy","productReference":{"guid":"bfdfe7dc352907fc980b868725387e980ae07e1806c3af2f5550d2e89780c766","name":"geolocator_apple_privacy.bundle","type":"product"},"productTypeIdentifier":"com.apple.product-type.bundle","provisioningSourceData":[{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Debug","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Profile","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Release","provisioningStyle":0}],"type":"standard"}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"buildConfigurations":[{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e984da6ed4b7d87dcd4c1eb08c9fc111bbf","buildSettings":{"ASSETCATALOG_COMPILER_APPICON_NAME":"AppIcon","ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME":"AccentColor","CLANG_ENABLE_OBJC_WEAK":"NO","ENABLE_USER_SCRIPT_SANDBOXING":"NO","IPHONEOS_DEPLOYMENT_TARGET":"13.0","LD_RUNPATH_SEARCH_PATHS":"$(inherited) @executable_path/Frameworks","ONLY_ACTIVE_ARCH":"NO","SDKROOT":"iphoneos","TARGETED_DEVICE_FAMILY":"1,2"},"guid":"bfdfe7dc352907fc980b868725387e980bc977b873df9b0e01b3c822e5c77429","name":"Debug"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e9831c1531f7c6e1b7490400c5fb70a79a5","buildSettings":{"ASSETCATALOG_COMPILER_APPICON_NAME":"AppIcon","ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME":"AccentColor","CLANG_ENABLE_OBJC_WEAK":"NO","ENABLE_USER_SCRIPT_SANDBOXING":"NO","IPHONEOS_DEPLOYMENT_TARGET":"13.0","LD_RUNPATH_SEARCH_PATHS":"$(inherited) @executable_path/Frameworks","SDKROOT":"iphoneos","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES"},"guid":"bfdfe7dc352907fc980b868725387e98b75274b69084014a6a5ac37ea7a9d4bc","name":"Profile"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e9831c1531f7c6e1b7490400c5fb70a79a5","buildSettings":{"ASSETCATALOG_COMPILER_APPICON_NAME":"AppIcon","ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME":"AccentColor","CLANG_ENABLE_OBJC_WEAK":"NO","ENABLE_USER_SCRIPT_SANDBOXING":"NO","IPHONEOS_DEPLOYMENT_TARGET":"13.0","LD_RUNPATH_SEARCH_PATHS":"$(inherited) @executable_path/Frameworks","SDKROOT":"iphoneos","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES"},"guid":"bfdfe7dc352907fc980b868725387e988b8e6347e534cb57e9bb1b22dc47b716","name":"Release"}],"buildPhases":[],"buildRules":[],"dependencies":[],"guid":"bfdfe7dc352907fc980b868725387e989da425bb6d6d5d8dbb95e4afffb82217","name":"Flutter","provisioningSourceData":[{"bundleIdentifierFromInfoPlist":"","configurationName":"Debug","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"","configurationName":"Profile","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"","configurationName":"Release","provisioningStyle":0}],"type":"aggregate"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"buildConfigurations":[{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e988447dbc9b1396f2f8b874665d7663344","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/package_info_plus","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"package_info_plus","INFOPLIST_FILE":"Target Support Files/package_info_plus/ResourceBundle-package_info_plus_privacy-package_info_plus-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"12.0","ONLY_ACTIVE_ARCH":"NO","PRODUCT_NAME":"package_info_plus_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e98ea615c640c384edc0e21ed1692291823","name":"Debug"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e982618b5a51037a32a885f4497ede4dcf3","buildSettings":{"CLANG_ENABLE_OBJC_WEAK":"NO","CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/package_info_plus","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"package_info_plus","INFOPLIST_FILE":"Target Support Files/package_info_plus/ResourceBundle-package_info_plus_privacy-package_info_plus-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"12.0","PRODUCT_NAME":"package_info_plus_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e984e23d167444e6f055f38dd7a69a18dd7","name":"Profile"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e982618b5a51037a32a885f4497ede4dcf3","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/package_info_plus","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"package_info_plus","INFOPLIST_FILE":"Target Support Files/package_info_plus/ResourceBundle-package_info_plus_privacy-package_info_plus-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"12.0","PRODUCT_NAME":"package_info_plus_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e9887dda0bca521f625d58f0030dc1ef821","name":"Release"}],"buildPhases":[{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e98bd08ace1fe49221266781def1534d061","type":"com.apple.buildphase.sources"},{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e987a1a049f7fcc890c93b2d478f2ebcdb3","type":"com.apple.buildphase.frameworks"},{"buildFiles":[{"fileReference":"bfdfe7dc352907fc980b868725387e987da0dca8e6b6cfc96465df580c4b6738","guid":"bfdfe7dc352907fc980b868725387e9840ce923bbef0d975d1f1dd0030483db8"}],"guid":"bfdfe7dc352907fc980b868725387e98d2fed6345309fbc2a5f123796da16852","type":"com.apple.buildphase.resources"}],"buildRules":[],"dependencies":[],"guid":"bfdfe7dc352907fc980b868725387e987b6c2f882d164ef4f3c76673562685a1","name":"package_info_plus-package_info_plus_privacy","productReference":{"guid":"bfdfe7dc352907fc980b868725387e982a9852aa81a16cf5578d0e8c78b5679a","name":"package_info_plus_privacy.bundle","type":"product"},"productTypeIdentifier":"com.apple.product-type.bundle","provisioningSourceData":[{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Debug","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Profile","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Release","provisioningStyle":0}],"type":"standard"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"buildConfigurations":[{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98c5ae77c3ed9c6121cedd0cc76f43439d","buildSettings":{"ASSETCATALOG_COMPILER_APPICON_NAME":"AppIcon","ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME":"AccentColor","CLANG_ENABLE_OBJC_WEAK":"NO","ENABLE_USER_SCRIPT_SANDBOXING":"NO","IPHONEOS_DEPLOYMENT_TARGET":"12.0","LD_RUNPATH_SEARCH_PATHS":"$(inherited) @executable_path/Frameworks","ONLY_ACTIVE_ARCH":"NO","SDKROOT":"iphoneos","TARGETED_DEVICE_FAMILY":"1,2"},"guid":"bfdfe7dc352907fc980b868725387e9805a7aa5f1f9ee500ad450f318d7c6bb9","name":"Debug"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98bc34136e26d305900a48578fde6599dd","buildSettings":{"ASSETCATALOG_COMPILER_APPICON_NAME":"AppIcon","ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME":"AccentColor","CLANG_ENABLE_OBJC_WEAK":"NO","ENABLE_USER_SCRIPT_SANDBOXING":"NO","IPHONEOS_DEPLOYMENT_TARGET":"12.0","LD_RUNPATH_SEARCH_PATHS":"$(inherited) @executable_path/Frameworks","SDKROOT":"iphoneos","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES"},"guid":"bfdfe7dc352907fc980b868725387e982ce0cb00cdbfda3dc319c833ad6ae38c","name":"Profile"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98bc34136e26d305900a48578fde6599dd","buildSettings":{"ASSETCATALOG_COMPILER_APPICON_NAME":"AppIcon","ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME":"AccentColor","CLANG_ENABLE_OBJC_WEAK":"NO","ENABLE_USER_SCRIPT_SANDBOXING":"NO","IPHONEOS_DEPLOYMENT_TARGET":"12.0","LD_RUNPATH_SEARCH_PATHS":"$(inherited) @executable_path/Frameworks","SDKROOT":"iphoneos","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES"},"guid":"bfdfe7dc352907fc980b868725387e982a78a0117e7c8676789927b020dc3cdc","name":"Release"}],"buildPhases":[{"alwaysOutOfDate":"false","alwaysRunForInstallHdrs":"false","buildFiles":[],"emitEnvironment":"false","guid":"bfdfe7dc352907fc980b868725387e9833b1d8959324e2199aa08cec6b52aa6c","inputFileListPaths":["${PODS_ROOT}/Target Support Files/ObjectBox/ObjectBox-xcframeworks-input-files.xcfilelist"],"inputFilePaths":[],"name":"[CP] Copy XCFrameworks","originalObjectID":"CF5046B1965C17C425C68434C5F4930A","outputFileListPaths":["${PODS_ROOT}/Target Support Files/ObjectBox/ObjectBox-xcframeworks-output-files.xcfilelist"],"outputFilePaths":[],"sandboxingOverride":"basedOnBuildSetting","scriptContents":"\"${PODS_ROOT}/Target Support Files/ObjectBox/ObjectBox-xcframeworks.sh\"\n","shellPath":"/bin/sh","type":"com.apple.buildphase.shell-script"}],"buildRules":[],"dependencies":[],"guid":"bfdfe7dc352907fc980b868725387e9899d473c1bf2fb661137a0585385808b4","name":"ObjectBox","provisioningSourceData":[{"bundleIdentifierFromInfoPlist":"","configurationName":"Debug","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"","configurationName":"Profile","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"","configurationName":"Release","provisioningStyle":0}],"type":"aggregate"}

View File

@@ -0,0 +1 @@
{"buildConfigurations":[{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e985d6c98d572eacea560ef85525944bc53","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/path_provider_foundation","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"path_provider_foundation","INFOPLIST_FILE":"Target Support Files/path_provider_foundation/ResourceBundle-path_provider_foundation_privacy-path_provider_foundation-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"12.0","ONLY_ACTIVE_ARCH":"NO","PRODUCT_NAME":"path_provider_foundation_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e9880add09333c603618ba57301ef150366","name":"Debug"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e983214452bd1fb01e59882991c3a0c6270","buildSettings":{"CLANG_ENABLE_OBJC_WEAK":"NO","CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/path_provider_foundation","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"path_provider_foundation","INFOPLIST_FILE":"Target Support Files/path_provider_foundation/ResourceBundle-path_provider_foundation_privacy-path_provider_foundation-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"12.0","PRODUCT_NAME":"path_provider_foundation_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e9847c9860dbbe72e455fd62321ab015d07","name":"Profile"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e983214452bd1fb01e59882991c3a0c6270","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/path_provider_foundation","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"path_provider_foundation","INFOPLIST_FILE":"Target Support Files/path_provider_foundation/ResourceBundle-path_provider_foundation_privacy-path_provider_foundation-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"12.0","PRODUCT_NAME":"path_provider_foundation_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e98015187516b84ba9413737c5495e2630f","name":"Release"}],"buildPhases":[{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e98e17b6ebc5129f9508fe634174cc60540","type":"com.apple.buildphase.sources"},{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e98b11ffdc4603d340d255431d37eda7cff","type":"com.apple.buildphase.frameworks"},{"buildFiles":[{"fileReference":"bfdfe7dc352907fc980b868725387e98f875f9d760fe29ff006006bf8a5e29db","guid":"bfdfe7dc352907fc980b868725387e98d6567728f9dc045f8d11b87239454a17"}],"guid":"bfdfe7dc352907fc980b868725387e983844082810a6bc94d89439820fcf9464","type":"com.apple.buildphase.resources"}],"buildRules":[],"dependencies":[],"guid":"bfdfe7dc352907fc980b868725387e987ea64ee8d53085bf9edd1a57aaf8cbb5","name":"path_provider_foundation-path_provider_foundation_privacy","productReference":{"guid":"bfdfe7dc352907fc980b868725387e986e649604f74c414a7c2dbe5ef4cc4e75","name":"path_provider_foundation_privacy.bundle","type":"product"},"productTypeIdentifier":"com.apple.product-type.bundle","provisioningSourceData":[{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Debug","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Profile","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Release","provisioningStyle":0}],"type":"standard"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"buildConfigurations":[{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e98b2d96934906ee3fbaef29d6d94829467","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/permission_handler_apple","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"permission_handler_apple","INFOPLIST_FILE":"Target Support Files/permission_handler_apple/ResourceBundle-permission_handler_apple_privacy-permission_handler_apple-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"9.0","ONLY_ACTIVE_ARCH":"NO","PRODUCT_NAME":"permission_handler_apple_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e98f766aa246d1b90c876b6e3208c1336fe","name":"Debug"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e987f457f86a057c20b67c9b014731ae27e","buildSettings":{"CLANG_ENABLE_OBJC_WEAK":"NO","CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/permission_handler_apple","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"permission_handler_apple","INFOPLIST_FILE":"Target Support Files/permission_handler_apple/ResourceBundle-permission_handler_apple_privacy-permission_handler_apple-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"9.0","PRODUCT_NAME":"permission_handler_apple_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","VALIDATE_PRODUCT":"YES","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e98e895a43ffa62f210c41ab4ed9f6b378d","name":"Profile"},{"baseConfigurationFileReference":"bfdfe7dc352907fc980b868725387e987f457f86a057c20b67c9b014731ae27e","buildSettings":{"CODE_SIGNING_ALLOWED":"NO","CODE_SIGNING_IDENTITY":"-","CODE_SIGNING_REQUIRED":"NO","CONFIGURATION_BUILD_DIR":"$(BUILD_DIR)/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/permission_handler_apple","EXPANDED_CODE_SIGN_IDENTITY":"-","IBSC_MODULE":"permission_handler_apple","INFOPLIST_FILE":"Target Support Files/permission_handler_apple/ResourceBundle-permission_handler_apple_privacy-permission_handler_apple-Info.plist","IPHONEOS_DEPLOYMENT_TARGET":"9.0","PRODUCT_NAME":"permission_handler_apple_privacy","SDKROOT":"iphoneos","SKIP_INSTALL":"YES","TARGETED_DEVICE_FAMILY":"1,2","WRAPPER_EXTENSION":"bundle"},"guid":"bfdfe7dc352907fc980b868725387e9859313236931631f81cee652c06e89e82","name":"Release"}],"buildPhases":[{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e987f3961a0227401794e66b2ad691417b2","type":"com.apple.buildphase.sources"},{"buildFiles":[],"guid":"bfdfe7dc352907fc980b868725387e98fd3f2136c497a33f11909a4555f0b9de","type":"com.apple.buildphase.frameworks"},{"buildFiles":[{"fileReference":"bfdfe7dc352907fc980b868725387e982eac7f03e53587ac5e1b2814ff7688b5","guid":"bfdfe7dc352907fc980b868725387e98692c4078ff878e239810d50f748be088"}],"guid":"bfdfe7dc352907fc980b868725387e98d8dfe8c885fcb7f295edd748d6ae4f67","type":"com.apple.buildphase.resources"}],"buildRules":[],"dependencies":[],"guid":"bfdfe7dc352907fc980b868725387e9802f35ab680609a626ebd2ddd692a3822","name":"permission_handler_apple-permission_handler_apple_privacy","productReference":{"guid":"bfdfe7dc352907fc980b868725387e983e9a904e8a35cb34b69458780be142b3","name":"permission_handler_apple_privacy.bundle","type":"product"},"productTypeIdentifier":"com.apple.product-type.bundle","provisioningSourceData":[{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Debug","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Profile","provisioningStyle":0},{"bundleIdentifierFromInfoPlist":"${PRODUCT_BUNDLE_IDENTIFIER}","configurationName":"Release","provisioningStyle":0}],"type":"standard"}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"guid":"dc4b70c03e8043e50e38f2068887b1d4","name":"Pods","path":"/Users/dz0ny/meshcore-sar/meshcore_sar_app/ios/Pods/Pods.xcodeproj/project.xcworkspace","projects":["PROJECT@v11_mod=8087a2fc1456ece247eee30e0163f06f_hash=bfdfe7dc352907fc980b868725387e98plugins=1OJSG6M1FOV3XYQCBH7Z29RZ0FPR9XDE1"]}

104
lib/main.dart Normal file
View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/connection_provider.dart';
import 'providers/contacts_provider.dart';
import 'providers/messages_provider.dart';
import 'providers/map_provider.dart';
import 'providers/app_provider.dart';
import 'services/tile_cache_service.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const MeshCoreSarApp());
}
class MeshCoreSarApp extends StatelessWidget {
const MeshCoreSarApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Core providers
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => ContactsProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything
ChangeNotifierProxyProvider4<ConnectionProvider, ContactsProvider,
MessagesProvider, TileCacheService, AppProvider>(
create: (context) => AppProvider(
connectionProvider: context.read<ConnectionProvider>(),
contactsProvider: context.read<ContactsProvider>(),
messagesProvider: context.read<MessagesProvider>(),
tileCacheService: context.read<TileCacheService>(),
),
update: (context, conn, contacts, messages, tileCache, previous) =>
previous ??
AppProvider(
connectionProvider: conn,
contactsProvider: contacts,
messagesProvider: messages,
tileCacheService: tileCache,
),
),
],
child: MaterialApp(
title: 'MeshCore SAR',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.orange,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.orange,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
),
),
themeMode: ThemeMode.system,
home: const HomeScreen(),
),
);
}
}

174
lib/models/contact.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
import 'contact_telemetry.dart';
/// MeshCore contact types
enum ContactType {
none(0),
chat(1),
repeater(2),
room(3);
const ContactType(this.value);
final int value;
static ContactType fromValue(int value) {
return ContactType.values.firstWhere(
(e) => e.value == value,
orElse: () => ContactType.none,
);
}
String get displayName {
switch (this) {
case ContactType.chat:
return 'Chat';
case ContactType.repeater:
return 'Repeater';
case ContactType.room:
return 'Room';
default:
return 'Unknown';
}
}
}
/// MeshCore contact model
class Contact {
final Uint8List publicKey;
final ContactType type;
final int flags;
final int outPathLen;
final Uint8List outPath;
final String advName;
final int lastAdvert; // Unix timestamp
final int advLat; // Latitude as int32
final int advLon; // Longitude as int32
final int lastMod; // Unix timestamp
// Telemetry data (updated separately)
ContactTelemetry? telemetry;
Contact({
required this.publicKey,
required this.type,
required this.flags,
required this.outPathLen,
required this.outPath,
required this.advName,
required this.lastAdvert,
required this.advLat,
required this.advLon,
required this.lastMod,
this.telemetry,
});
/// Get public key as hex string (first 8 bytes)
String get publicKeyShort {
if (publicKey.length < 8) return '';
return publicKey.sublist(0, 8).map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Get full public key as hex string
String get publicKeyHex {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Convert advLat/advLon to LatLng
LatLng? get advertLocation {
if (advLat == 0 && advLon == 0) return null;
// Convert from int32 to double (degrees)
final lat = advLat / 1e7;
final lon = advLon / 1e7;
return LatLng(lat, lon);
}
/// Get display location (prefer telemetry over advert)
LatLng? get displayLocation {
if (telemetry?.gpsLocation != null && telemetry!.isRecent) {
return telemetry!.gpsLocation;
}
return advertLocation;
}
/// Get display battery (from telemetry or null)
double? get displayBattery {
return telemetry?.batteryPercentage;
}
/// Check if contact is a chat type (team member)
bool get isChat => type == ContactType.chat;
/// Check if contact is a repeater
bool get isRepeater => type == ContactType.repeater;
/// Check if contact is a room/channel
bool get isRoom => type == ContactType.room;
/// Get last seen time
DateTime get lastSeenTime {
return DateTime.fromMillisecondsSinceEpoch(lastAdvert * 1000);
}
/// Get last modified time
DateTime get lastModifiedTime {
return DateTime.fromMillisecondsSinceEpoch(lastMod * 1000);
}
/// Check if contact was seen recently (within last 10 minutes)
bool get isRecentlySeen {
return DateTime.now().difference(lastSeenTime).inMinutes < 10;
}
/// Get friendly time since last seen
String get timeSinceLastSeen {
final diff = DateTime.now().difference(lastSeenTime);
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';
return '${diff.inDays}d ago';
}
Contact copyWith({
Uint8List? publicKey,
ContactType? type,
int? flags,
int? outPathLen,
Uint8List? outPath,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? lastMod,
ContactTelemetry? telemetry,
}) {
return Contact(
publicKey: publicKey ?? this.publicKey,
type: type ?? this.type,
flags: flags ?? this.flags,
outPathLen: outPathLen ?? this.outPathLen,
outPath: outPath ?? this.outPath,
advName: advName ?? this.advName,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
lastMod: lastMod ?? this.lastMod,
telemetry: telemetry ?? this.telemetry,
);
}
@override
String toString() {
return 'Contact(name: $advName, type: ${type.displayName}, key: $publicKeyShort)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Contact &&
publicKeyHex == other.publicKeyHex;
}
@override
int get hashCode => publicKeyHex.hashCode;
}

View File

@@ -0,0 +1,76 @@
import 'package:latlong2/latlong.dart';
/// Contact telemetry data from MeshCore device
class ContactTelemetry {
final LatLng? gpsLocation;
final double? batteryPercentage;
final double? batteryMilliVolts;
final double? temperature;
final DateTime timestamp;
// Additional sensor data
final double? humidity;
final double? pressure;
final Map<String, dynamic>? extraSensorData;
ContactTelemetry({
this.gpsLocation,
this.batteryPercentage,
this.batteryMilliVolts,
this.temperature,
required this.timestamp,
this.humidity,
this.pressure,
this.extraSensorData,
});
/// Check if telemetry data is recent (within last 5 minutes)
bool get isRecent {
return DateTime.now().difference(timestamp).inMinutes < 5;
}
/// Check if battery level is low (< 20%)
bool get isLowBattery {
return batteryPercentage != null && batteryPercentage! < 20.0;
}
/// Check if battery level is critical (< 10%)
bool get isCriticalBattery {
return batteryPercentage != null && batteryPercentage! < 10.0;
}
/// Get battery status color indicator
String get batteryStatus {
if (batteryPercentage == null) return 'unknown';
if (batteryPercentage! > 50) return 'good';
if (batteryPercentage! > 20) return 'medium';
return 'low';
}
ContactTelemetry copyWith({
LatLng? gpsLocation,
double? batteryPercentage,
double? batteryMilliVolts,
double? temperature,
DateTime? timestamp,
double? humidity,
double? pressure,
Map<String, dynamic>? extraSensorData,
}) {
return ContactTelemetry(
gpsLocation: gpsLocation ?? this.gpsLocation,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
temperature: temperature ?? this.temperature,
timestamp: timestamp ?? this.timestamp,
humidity: humidity ?? this.humidity,
pressure: pressure ?? this.pressure,
extraSensorData: extraSensorData ?? this.extraSensorData,
);
}
@override
String toString() {
return 'ContactTelemetry(gps: $gpsLocation, battery: $batteryPercentage%, temp: $temperature°C, time: $timestamp)';
}
}

173
lib/models/device_info.dart Normal file
View File

@@ -0,0 +1,173 @@
import 'dart:typed_data';
/// BLE connection state
enum ConnectionState {
disconnected,
connecting,
connected,
disconnecting,
error,
}
/// MeshCore device information
class DeviceInfo {
final String? deviceId;
final String? deviceName;
final ConnectionState connectionState;
final int? batteryMilliVolts;
final double? batteryPercentage;
final int? signalRssi;
final double? signalSnr;
final DateTime? lastUpdate;
// Self info from MeshCore device
final int? deviceType;
final int? txPower;
final int? maxTxPower;
final Uint8List? publicKey;
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
final int? radioCr;
final String? selfName;
// Firmware info
final int? firmwareVersion;
final String? firmwareBuildDate;
final String? manufacturerModel;
DeviceInfo({
this.deviceId,
this.deviceName,
this.connectionState = ConnectionState.disconnected,
this.batteryMilliVolts,
this.batteryPercentage,
this.signalRssi,
this.signalSnr,
this.lastUpdate,
this.deviceType,
this.txPower,
this.maxTxPower,
this.publicKey,
this.advLat,
this.advLon,
this.manualAddContacts,
this.radioFreq,
this.radioBw,
this.radioSf,
this.radioCr,
this.selfName,
this.firmwareVersion,
this.firmwareBuildDate,
this.manufacturerModel,
});
/// Check if device is connected
bool get isConnected => connectionState == ConnectionState.connected;
/// Check if device is connecting
bool get isConnecting => connectionState == ConnectionState.connecting;
/// Check if device has error
bool get hasError => connectionState == ConnectionState.error;
/// Get battery percentage (calculated or provided)
double? get batteryPercent {
if (batteryPercentage != null) return batteryPercentage!;
if (batteryMilliVolts == null) return null;
// Rough conversion from mV to percentage (3.0V = 0%, 4.2V = 100%)
final voltage = batteryMilliVolts! / 1000.0;
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
/// Get battery status
String get batteryStatus {
final percent = batteryPercent;
if (percent == null) return 'Unknown';
if (percent > 80) return 'Excellent';
if (percent > 50) return 'Good';
if (percent > 20) return 'Low';
return 'Critical';
}
/// Get signal strength category
String get signalStrength {
if (signalRssi == null) return 'Unknown';
if (signalRssi! > -60) return 'Excellent';
if (signalRssi! > -70) return 'Good';
if (signalRssi! > -80) return 'Fair';
return 'Poor';
}
/// Get public key as hex string (short)
String? get publicKeyShort {
if (publicKey == null || publicKey!.length < 8) return null;
return publicKey!
.sublist(0, 8)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
DeviceInfo copyWith({
String? deviceId,
String? deviceName,
ConnectionState? connectionState,
int? batteryMilliVolts,
double? batteryPercentage,
int? signalRssi,
double? signalSnr,
DateTime? lastUpdate,
int? deviceType,
int? txPower,
int? maxTxPower,
Uint8List? publicKey,
int? advLat,
int? advLon,
bool? manualAddContacts,
int? radioFreq,
int? radioBw,
int? radioSf,
int? radioCr,
String? selfName,
int? firmwareVersion,
String? firmwareBuildDate,
String? manufacturerModel,
}) {
return DeviceInfo(
deviceId: deviceId ?? this.deviceId,
deviceName: deviceName ?? this.deviceName,
connectionState: connectionState ?? this.connectionState,
batteryMilliVolts: batteryMilliVolts ?? this.batteryMilliVolts,
batteryPercentage: batteryPercentage ?? this.batteryPercentage,
signalRssi: signalRssi ?? this.signalRssi,
signalSnr: signalSnr ?? this.signalSnr,
lastUpdate: lastUpdate ?? this.lastUpdate,
deviceType: deviceType ?? this.deviceType,
txPower: txPower ?? this.txPower,
maxTxPower: maxTxPower ?? this.maxTxPower,
publicKey: publicKey ?? this.publicKey,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
radioCr: radioCr ?? this.radioCr,
selfName: selfName ?? this.selfName,
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
firmwareBuildDate: firmwareBuildDate ?? this.firmwareBuildDate,
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
);
}
@override
String toString() {
return 'DeviceInfo(name: $deviceName, state: $connectionState, battery: ${batteryPercent?.toStringAsFixed(0)}%, signal: $signalRssi dBm)';
}
}

56
lib/models/map_layer.dart Normal file
View File

@@ -0,0 +1,56 @@
enum MapLayerType {
openStreetMap,
openTopoMap,
esriWorldImagery,
}
class MapLayer {
final MapLayerType type;
final String name;
final String urlTemplate;
final String attribution;
final int maxZoom;
const MapLayer({
required this.type,
required this.name,
required this.urlTemplate,
required this.attribution,
required this.maxZoom,
});
static const openStreetMap = MapLayer(
type: MapLayerType.openStreetMap,
name: 'OpenStreetMap',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
);
static const openTopoMap = MapLayer(
type: MapLayerType.openTopoMap,
name: 'OpenTopoMap',
urlTemplate: 'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
attribution: '© OpenTopoMap (CC-BY-SA)',
maxZoom: 17,
);
static const esriWorldImagery = MapLayer(
type: MapLayerType.esriWorldImagery,
name: 'ESRI Satellite',
urlTemplate:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
maxZoom: 19,
);
static const List<MapLayer> allLayers = [
openStreetMap,
openTopoMap,
esriWorldImagery,
];
static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type);
}
}

171
lib/models/message.dart Normal file
View File

@@ -0,0 +1,171 @@
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
import 'sar_marker.dart';
/// Message text types from MeshCore protocol
enum MessageTextType {
plain(0),
cliData(1),
signedPlain(2);
const MessageTextType(this.value);
final int value;
static MessageTextType fromValue(int value) {
return MessageTextType.values.firstWhere(
(e) => e.value == value,
orElse: () => MessageTextType.plain,
);
}
}
/// Message type (contact or channel)
enum MessageType {
contact,
channel,
}
/// MeshCore message model
class Message {
final String id;
final MessageType messageType;
final Uint8List? senderPublicKeyPrefix; // 6 bytes for contact messages
final int? channelIdx; // For channel messages
final int pathLen;
final MessageTextType textType;
final int senderTimestamp; // Unix timestamp
final String text;
// SAR marker data (if this is a SAR message)
final bool isSarMarker;
final SarMarkerType? sarMarkerType;
final LatLng? sarGpsCoordinates;
// Display metadata
final DateTime receivedAt;
final String? senderName;
Message({
required this.id,
required this.messageType,
this.senderPublicKeyPrefix,
this.channelIdx,
required this.pathLen,
required this.textType,
required this.senderTimestamp,
required this.text,
this.isSarMarker = false,
this.sarMarkerType,
this.sarGpsCoordinates,
required this.receivedAt,
this.senderName,
});
/// Get sender public key as hex string
String? get senderKeyShort {
if (senderPublicKeyPrefix == null) return null;
return senderPublicKeyPrefix!
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get sender timestamp as DateTime
DateTime get sentAt {
return DateTime.fromMillisecondsSinceEpoch(senderTimestamp * 1000);
}
/// Check if message is from a channel
bool get isChannelMessage => messageType == MessageType.channel;
/// Check if message is from a contact
bool get isContactMessage => messageType == MessageType.contact;
/// Get friendly time since message was sent
String get timeAgo {
final diff = DateTime.now().difference(sentAt);
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';
return '${diff.inDays}d ago';
}
/// Get display name for sender
String get displaySender {
if (senderName != null && senderName!.isNotEmpty) {
return senderName!;
}
if (senderKeyShort != null) {
return senderKeyShort!.substring(0, 8);
}
if (isChannelMessage && channelIdx != null) {
return 'Channel $channelIdx';
}
return 'Unknown';
}
/// Convert to SAR marker if applicable
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
return null;
}
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: text,
);
}
Message copyWith({
String? id,
MessageType? messageType,
Uint8List? senderPublicKeyPrefix,
int? channelIdx,
int? pathLen,
MessageTextType? textType,
int? senderTimestamp,
String? text,
bool? isSarMarker,
SarMarkerType? sarMarkerType,
LatLng? sarGpsCoordinates,
DateTime? receivedAt,
String? senderName,
}) {
return Message(
id: id ?? this.id,
messageType: messageType ?? this.messageType,
senderPublicKeyPrefix: senderPublicKeyPrefix ?? this.senderPublicKeyPrefix,
channelIdx: channelIdx ?? this.channelIdx,
pathLen: pathLen ?? this.pathLen,
textType: textType ?? this.textType,
senderTimestamp: senderTimestamp ?? this.senderTimestamp,
text: text ?? this.text,
isSarMarker: isSarMarker ?? this.isSarMarker,
sarMarkerType: sarMarkerType ?? this.sarMarkerType,
sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates,
receivedAt: receivedAt ?? this.receivedAt,
senderName: senderName ?? this.senderName,
);
}
@override
String toString() {
if (isSarMarker) {
return 'Message(SAR: ${sarMarkerType?.displayName}, from: $displaySender)';
}
return 'Message(from: $displaySender, text: ${text.length > 30 ? '${text.substring(0, 30)}...' : text})';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Message && id == other.id;
}
@override
int get hashCode => id.hashCode;
}

126
lib/models/sar_marker.dart Normal file
View File

@@ -0,0 +1,126 @@
import 'dart:typed_data';
import 'package:latlong2/latlong.dart';
/// SAR (Search & Rescue) marker types
enum SarMarkerType {
foundPerson('🧑', 'Found Person'),
fire('🔥', 'Fire'),
stagingArea('🏕️', 'Staging Area'),
unknown('', 'Unknown');
const SarMarkerType(this.emoji, this.displayName);
final String emoji;
final String displayName;
static SarMarkerType fromEmoji(String emoji) {
switch (emoji) {
case '🧑':
case '👤':
return SarMarkerType.foundPerson;
case '🔥':
return SarMarkerType.fire;
case '🏕️':
case '':
return SarMarkerType.stagingArea;
default:
return SarMarkerType.unknown;
}
}
/// Get map marker color
String get markerColor {
switch (this) {
case SarMarkerType.foundPerson:
return '#4CAF50'; // Green
case SarMarkerType.fire:
return '#F44336'; // Red
case SarMarkerType.stagingArea:
return '#2196F3'; // Blue
default:
return '#9E9E9E'; // Gray
}
}
}
/// SAR marker from special messages
class SarMarker {
final String id;
final SarMarkerType type;
final LatLng location;
final DateTime timestamp;
final Uint8List? senderPublicKey;
final String? senderName;
final String? notes;
SarMarker({
required this.id,
required this.type,
required this.location,
required this.timestamp,
this.senderPublicKey,
this.senderName,
this.notes,
});
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
return senderPublicKey!
.sublist(0, 8)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
}
/// Get friendly time since marker was created
String get timeAgo {
final diff = DateTime.now().difference(timestamp);
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';
return '${diff.inDays}d ago';
}
/// Check if marker is recent (within last hour)
bool get isRecent {
return DateTime.now().difference(timestamp).inHours < 1;
}
/// Get display name
String get displayName {
return '${type.emoji} ${type.displayName}';
}
SarMarker copyWith({
String? id,
SarMarkerType? type,
LatLng? location,
DateTime? timestamp,
Uint8List? senderPublicKey,
String? senderName,
String? notes,
}) {
return SarMarker(
id: id ?? this.id,
type: type ?? this.type,
location: location ?? this.location,
timestamp: timestamp ?? this.timestamp,
senderPublicKey: senderPublicKey ?? this.senderPublicKey,
senderName: senderName ?? this.senderName,
notes: notes ?? this.notes,
);
}
@override
String toString() {
return 'SarMarker(type: ${type.displayName}, location: $location, sender: $senderName, time: $timeAgo)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SarMarker && id == other.id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/foundation.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
import 'messages_provider.dart';
import '../services/tile_cache_service.dart';
/// Main App Provider - coordinates all other providers
class AppProvider with ChangeNotifier {
final ConnectionProvider connectionProvider;
final ContactsProvider contactsProvider;
final MessagesProvider messagesProvider;
final TileCacheService tileCacheService;
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
AppProvider({
required this.connectionProvider,
required this.contactsProvider,
required this.messagesProvider,
required this.tileCacheService,
}) {
_setupCallbacks();
_initializeTileCache();
_isInitialized = true;
}
/// Initialize tile cache service
Future<void> _initializeTileCache() async {
try {
await tileCacheService.initialize();
debugPrint('Tile cache initialized');
} catch (e) {
debugPrint('Error initializing tile cache: $e');
}
}
/// Setup callbacks between providers
void _setupCallbacks() {
// When a contact is received from BLE
connectionProvider.onContactReceived = (contact) {
contactsProvider.addOrUpdateContact(contact);
};
// When all contacts are received
connectionProvider.onContactsComplete = (contacts) {
contactsProvider.addContacts(contacts);
debugPrint('Received ${contacts.length} contacts');
};
// When a message is received
connectionProvider.onMessageReceived = (message) {
messagesProvider.addMessage(message);
// Optionally update sender name from contacts
if (message.senderPublicKeyPrefix != null) {
final contact = contactsProvider
.findContactByKey(message.senderPublicKeyPrefix!);
if (contact != null) {
final updatedMessage = message.copyWith(senderName: contact.advName);
// Note: You might want to update the message in the list
}
}
};
// When telemetry is received
connectionProvider.onTelemetryReceived = (publicKey, lppData) {
contactsProvider.updateTelemetry(publicKey, lppData);
};
}
/// Initialize the app (load contacts, sync time, etc.)
Future<void> initialize() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
// Sync device time
await connectionProvider.syncDeviceTime();
// Load contacts
await connectionProvider.getContacts();
notifyListeners();
} catch (e) {
debugPrint('Initialization error: $e');
}
}
/// Refresh data (contacts, messages)
Future<void> refresh() async {
if (!connectionProvider.deviceInfo.isConnected) return;
try {
await connectionProvider.getContacts();
notifyListeners();
} catch (e) {
debugPrint('Refresh error: $e');
}
}
/// Clear all data
void clearAllData() {
contactsProvider.clearContacts();
messagesProvider.clearAll();
notifyListeners();
}
/// Get app statistics
Map<String, dynamic> get statistics {
return {
'connection': {
'isConnected': connectionProvider.deviceInfo.isConnected,
'deviceName': connectionProvider.deviceInfo.deviceName,
'battery': connectionProvider.deviceInfo.batteryPercent,
},
'contacts': contactsProvider.contactCounts,
'messages': messagesProvider.messageStats,
'sarMarkers': messagesProvider.sarMarkerStats,
};
}
}

View File

@@ -0,0 +1,240 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/device_info.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../services/meshcore_ble_service.dart';
import '../services/cayenne_lpp_parser.dart';
import '../utils/sar_message_parser.dart';
/// Connection Provider - manages MeshCore BLE connection
class ConnectionProvider with ChangeNotifier {
final MeshCoreBleService _bleService = MeshCoreBleService();
DeviceInfo _deviceInfo = DeviceInfo();
DeviceInfo get deviceInfo => _deviceInfo;
List<BluetoothDevice> _scannedDevices = [];
List<BluetoothDevice> get scannedDevices => _scannedDevices;
bool _isScanning = false;
bool get isScanning => _isScanning;
String? _error;
String? get error => _error;
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
Function(Message)? onMessageReceived;
Function(Uint8List publicKey, Uint8List lppData)? onTelemetryReceived;
ConnectionProvider() {
_initializeBleService();
}
void _initializeBleService() {
_bleService.onConnectionStateChanged = (isConnected) {
_deviceInfo = _deviceInfo.copyWith(
connectionState: isConnected
? ConnectionState.connected
: ConnectionState.disconnected,
lastUpdate: DateTime.now(),
);
notifyListeners();
};
_bleService.onError = (error) {
_error = error;
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
notifyListeners();
};
_bleService.onContactReceived = (contact) {
onContactReceived?.call(contact);
};
_bleService.onContactsComplete = (contacts) {
onContactsComplete?.call(contacts);
};
_bleService.onMessageReceived = (message) {
// Parse SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message);
onMessageReceived?.call(enhancedMessage);
};
_bleService.onTelemetryReceived = (publicKey, lppData) {
onTelemetryReceived?.call(publicKey, lppData);
};
}
/// Start scanning for MeshCore devices
Future<void> startScan() async {
_isScanning = true;
_scannedDevices.clear();
_error = null;
notifyListeners();
try {
await for (final device
in _bleService.scanForDevices(timeout: const Duration(seconds: 10))) {
if (!_scannedDevices.any((d) => d.remoteId == device.remoteId)) {
_scannedDevices.add(device);
notifyListeners();
}
}
} catch (e) {
_error = 'Scan error: $e';
} finally {
_isScanning = false;
notifyListeners();
}
}
/// Stop scanning
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
_isScanning = false;
notifyListeners();
}
/// Connect to a device
Future<bool> connect(BluetoothDevice device) async {
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty ? device.platformName : 'Unknown',
connectionState: ConnectionState.connecting,
);
_error = null;
notifyListeners();
final success = await _bleService.connect(device);
if (!success) {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
notifyListeners();
}
return success;
}
/// Disconnect from device
Future<void> disconnect() async {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.disconnecting,
);
notifyListeners();
await _bleService.disconnect();
_deviceInfo = DeviceInfo(
connectionState: ConnectionState.disconnected,
);
notifyListeners();
}
/// Get contacts from device
Future<void> getContacts() async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.getContacts();
} catch (e) {
_error = 'Failed to get contacts: $e';
notifyListeners();
}
}
/// Send text message to contact
Future<void> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
);
} catch (e) {
_error = 'Failed to send message: $e';
notifyListeners();
}
}
/// Send channel message
Future<void> sendChannelMessage({
required int channelIdx,
required String text,
}) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
} catch (e) {
_error = 'Failed to send channel message: $e';
notifyListeners();
}
}
/// Request telemetry from contact
Future<void> requestTelemetry(Uint8List contactPublicKey) async {
if (!_bleService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _bleService.requestTelemetry(contactPublicKey);
} catch (e) {
_error = 'Failed to request telemetry: $e';
notifyListeners();
}
}
/// Set device time to current time
Future<void> syncDeviceTime() async {
if (!_bleService.isConnected) return;
try {
await _bleService.setDeviceTime();
} catch (e) {
_error = 'Failed to sync time: $e';
notifyListeners();
}
}
/// Clear error message
void clearError() {
_error = null;
notifyListeners();
}
@override
void dispose() {
_bleService.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import '../models/contact.dart';
import '../models/contact_telemetry.dart';
import '../services/cayenne_lpp_parser.dart';
/// Contacts Provider - manages contact list and telemetry
class ContactsProvider with ChangeNotifier {
final Map<String, Contact> _contacts = {};
List<Contact> get contacts => _contacts.values.toList();
List<Contact> get chatContacts =>
contacts.where((c) => c.isChat).toList()..sort(_sortByLastSeen);
List<Contact> get repeaters =>
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
List<Contact> get rooms =>
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
/// Get contacts with location (for map display)
List<Contact> get contactsWithLocation =>
contacts.where((c) => c.displayLocation != null).toList();
/// Get chat contacts with location (team members on map)
List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList();
/// Sort contacts by last seen (most recent first)
int _sortByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime);
}
/// Add or update a contact
void addOrUpdateContact(Contact contact) {
_contacts[contact.publicKeyHex] = contact;
notifyListeners();
}
/// Add multiple contacts
void addContacts(List<Contact> contacts) {
for (final contact in contacts) {
_contacts[contact.publicKeyHex] = contact;
}
notifyListeners();
}
/// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
// Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) return;
try {
// Parse Cayenne LPP data
final telemetry = CayenneLppParser.parse(lppData);
// Update contact with new telemetry
final updatedContact = contact.copyWith(telemetry: telemetry);
_contacts[contact.publicKeyHex] = updatedContact;
notifyListeners();
} catch (e) {
debugPrint('Failed to parse telemetry: $e');
}
}
/// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null;
final prefixHex = prefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
for (final contact in contacts) {
if (contact.publicKeyHex.startsWith(prefixHex)) {
return contact;
}
}
return null;
}
/// Find contact by public key
Contact? findContactByKey(Uint8List publicKey) {
final keyHex =
publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
return _contacts[keyHex];
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
(c) => c.advName == name,
orElse: () => contacts.first,
);
}
/// Get contacts with low battery
List<Contact> get lowBatteryContacts {
return contacts.where((c) {
final battery = c.displayBattery;
return battery != null && battery < 20.0;
}).toList();
}
/// Get recently seen contacts (within last 10 minutes)
List<Contact> get recentlySeenContacts {
return contacts.where((c) => c.isRecentlySeen).toList();
}
/// Clear all contacts
void clearContacts() {
_contacts.clear();
notifyListeners();
}
/// Remove a contact
void removeContact(String publicKeyHex) {
_contacts.remove(publicKeyHex);
notifyListeners();
}
/// Get contact count by type
Map<String, int> get contactCounts {
return {
'chat': chatContacts.length,
'repeater': repeaters.length,
'room': rooms.length,
'total': contacts.length,
};
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
class MapProvider with ChangeNotifier {
LatLng? _targetLocation;
double? _targetZoom;
bool _shouldAnimate = false;
LatLng? get targetLocation => _targetLocation;
double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate;
void navigateToLocation({
required LatLng location,
double zoom = 15.0,
bool animate = true,
}) {
_targetLocation = location;
_targetZoom = zoom;
_shouldAnimate = animate;
notifyListeners();
}
void clearNavigation() {
_targetLocation = null;
_targetZoom = null;
_shouldAnimate = false;
// Don't notify listeners to avoid rebuilds
}
void updateZoom(double zoom) {
_targetZoom = zoom;
notifyListeners();
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../models/sar_marker.dart';
/// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier {
final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {};
List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages =>
_messages.where((m) => m.isContactMessage).toList();
List<Message> get channelMessages =>
_messages.where((m) => m.isChannelMessage).toList();
List<Message> get sarMarkerMessages =>
_messages.where((m) => m.isSarMarker).toList();
List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
List<SarMarker> get foundPersonMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
List<SarMarker> get fireMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.fire).toList();
List<SarMarker> get stagingAreaMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.stagingArea).toList();
/// Add a message
void addMessage(Message message) {
_messages.add(message);
// If it's a SAR marker message, extract and store the marker
if (message.isSarMarker) {
final marker = message.toSarMarker();
if (marker != null) {
_sarMarkers[marker.id] = marker;
}
}
notifyListeners();
}
/// Add multiple messages
void addMessages(List<Message> messages) {
for (final message in messages) {
_messages.add(message);
if (message.isSarMarker) {
final marker = message.toSarMarker();
if (marker != null) {
_sarMarkers[marker.id] = marker;
}
}
}
notifyListeners();
}
/// Get messages for a specific contact
List<Message> getMessagesForContact(String senderKeyShort) {
return _messages
.where((m) =>
m.isContactMessage &&
m.senderKeyShort != null &&
m.senderKeyShort!.startsWith(senderKeyShort))
.toList();
}
/// Get messages for a specific channel
List<Message> getMessagesForChannel(int channelIdx) {
return _messages
.where((m) => m.isChannelMessage && m.channelIdx == channelIdx)
.toList();
}
/// Get recent messages (last N messages)
List<Message> getRecentMessages({int count = 50}) {
final sorted = List<Message>.from(_messages)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
return sorted.take(count).toList();
}
/// Get messages from last N hours
List<Message> getMessagesSince(Duration duration) {
final cutoff = DateTime.now().subtract(duration);
return _messages.where((m) => m.sentAt.isAfter(cutoff)).toList();
}
/// Search messages by text
List<Message> searchMessages(String query) {
if (query.isEmpty) return [];
final lowerQuery = query.toLowerCase();
return _messages
.where((m) => m.text.toLowerCase().contains(lowerQuery))
.toList();
}
/// Get SAR marker by ID
SarMarker? getSarMarker(String id) {
return _sarMarkers[id];
}
/// Get recent SAR markers (within last hour)
List<SarMarker> getRecentSarMarkers() {
return sarMarkers.where((m) => m.isRecent).toList();
}
/// Remove a SAR marker
void removeSarMarker(String id) {
_sarMarkers.remove(id);
notifyListeners();
}
/// Clear all messages
void clearMessages() {
_messages.clear();
notifyListeners();
}
/// Clear all SAR markers
void clearSarMarkers() {
_sarMarkers.clear();
notifyListeners();
}
/// Clear all data
void clearAll() {
_messages.clear();
_sarMarkers.clear();
notifyListeners();
}
/// Get message statistics
Map<String, int> get messageStats {
return {
'total': _messages.length,
'contact': contactMessages.length,
'channel': channelMessages.length,
'sar': sarMarkerMessages.length,
'sarMarkers': sarMarkers.length,
};
}
/// Get SAR marker statistics
Map<String, int> get sarMarkerStats {
return {
'total': sarMarkers.length,
'foundPerson': foundPersonMarkers.length,
'fire': fireMarkers.length,
'stagingArea': stagingAreaMarkers.length,
};
}
}

View File

@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/connection_provider.dart';
import '../models/contact.dart';
class ContactsTab extends StatelessWidget {
const ContactsTab({super.key});
@override
Widget build(BuildContext context) {
return Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final chatContacts = contactsProvider.chatContacts;
final repeaters = contactsProvider.repeaters;
final rooms = contactsProvider.rooms;
if (contactsProvider.contacts.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.contacts_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No contacts yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Connect to a device and refresh to load contacts',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
return ListView(
padding: const EdgeInsets.all(8),
children: [
// Team Members (Chat contacts)
if (chatContacts.isNotEmpty) ...[
_SectionHeader(
title: 'Team Members',
count: chatContacts.length,
icon: Icons.people,
),
...chatContacts.map((contact) => _ContactTile(contact: contact)),
const Divider(height: 32),
],
// Repeaters
if (repeaters.isNotEmpty) ...[
_SectionHeader(
title: 'Repeaters',
count: repeaters.length,
icon: Icons.router,
),
...repeaters.map((contact) => _ContactTile(contact: contact)),
const Divider(height: 32),
],
// Rooms/Channels
if (rooms.isNotEmpty) ...[
_SectionHeader(
title: 'Rooms/Channels',
count: rooms.length,
icon: Icons.tag,
),
...rooms.map((contact) => _ContactTile(contact: contact)),
],
],
);
},
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final int count;
final IconData icon;
const _SectionHeader({
required this.title,
required this.count,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
count.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
);
}
}
class _ContactTile extends StatelessWidget {
final Contact contact;
const _ContactTile({required this.contact});
@override
Widget build(BuildContext context) {
final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent;
final battery = contact.displayBattery;
final location = contact.displayLocation;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: _getTypeColor(contact.type),
child: Icon(
_getTypeIcon(contact.type),
color: Colors.white,
),
),
title: Row(
children: [
Expanded(
child: Text(
contact.advName,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Battery indicator
if (battery != null) ...[
Icon(
_getBatteryIcon(battery),
size: 16,
color: _getBatteryColor(battery),
),
const SizedBox(width: 4),
Text(
'${battery.round()}%',
style: Theme.of(context).textTheme.labelSmall,
),
],
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
// Type and last seen
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: _getTypeColor(contact.type).withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
contact.type.displayName,
style: Theme.of(context).textTheme.labelSmall,
),
),
const SizedBox(width: 8),
Icon(
Icons.access_time,
size: 12,
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
contact.timeSinceLastSeen,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
const SizedBox(height: 4),
// Telemetry info
Row(
children: [
if (hasTelemetry)
const Icon(Icons.sensors, size: 12, color: Colors.green)
else
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
const SizedBox(width: 4),
if (location != null)
Expanded(
child: Text(
'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
)
else
Text(
'No GPS data',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
],
),
trailing: IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () {
final connectionProvider = context.read<ConnectionProvider>();
connectionProvider.requestTelemetry(contact.publicKey);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Requesting telemetry from ${contact.advName}'),
duration: const Duration(seconds: 2),
),
);
},
tooltip: 'Request telemetry',
),
onTap: () => _showContactDetails(context, contact),
),
);
}
void _showContactDetails(BuildContext context, Contact contact) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(contact.advName),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DetailRow('Type', contact.type.displayName),
_DetailRow('Public Key', contact.publicKeyShort),
_DetailRow('Last Seen', contact.timeSinceLastSeen),
const Divider(),
if (contact.displayLocation != null) ...[
const Text('Location:', style: TextStyle(fontWeight: FontWeight.bold)),
_DetailRow('Latitude', contact.displayLocation!.latitude.toStringAsFixed(6)),
_DetailRow('Longitude', contact.displayLocation!.longitude.toStringAsFixed(6)),
const Divider(),
],
if (contact.telemetry != null) ...[
const Text('Telemetry:', style: TextStyle(fontWeight: FontWeight.bold)),
if (contact.telemetry!.batteryPercentage != null)
_DetailRow('Battery', '${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%'),
if (contact.telemetry!.temperature != null)
_DetailRow('Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'),
_DetailRow('Updated', contact.telemetry!.isRecent ? 'Recently' : 'Stale'),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _DetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.chat:
return Icons.person;
case ContactType.repeater:
return Icons.router;
case ContactType.room:
return Icons.tag;
default:
return Icons.help;
}
}
Color _getTypeColor(ContactType type) {
switch (type) {
case ContactType.chat:
return Colors.blue;
case ContactType.repeater:
return Colors.green;
case ContactType.room:
return Colors.orange;
default:
return Colors.grey;
}
}
IconData _getBatteryIcon(double percentage) {
if (percentage > 80) return Icons.battery_full;
if (percentage > 50) return Icons.battery_5_bar;
if (percentage > 20) return Icons.battery_3_bar;
return Icons.battery_1_bar;
}
Color _getBatteryColor(double percentage) {
if (percentage > 50) return Colors.green;
if (percentage > 20) return Colors.orange;
return Colors.red;
}
}

View File

@@ -0,0 +1,514 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../models/device_info.dart' as models;
import '../services/tile_cache_service.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'map_tab.dart';
import 'map_management_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
int _currentIndex = 0;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
setState(() {
_currentIndex = _tabController.index;
});
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _showConnectionDialog(BuildContext context) {
final connectionProvider = context.read<ConnectionProvider>();
// Start scanning immediately
connectionProvider.startScan();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: const BoxDecoration(
color: Color(0xFF1E1E1E),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
connectionProvider.stopScan();
Navigator.pop(context);
},
),
const Expanded(
child: Column(
children: [
Text(
'MeshCore',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'Scanning for devices...',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.more_vert, color: Colors.white),
onPressed: () {},
),
],
),
),
// Info banner
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Colors.white),
SizedBox(width: 12),
Expanded(
child: Text(
'The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings.',
style: TextStyle(color: Colors.white, fontSize: 13),
),
),
],
),
),
const SizedBox(height: 16),
// Device list
Expanded(
child: Consumer<ConnectionProvider>(
builder: (context, provider, child) {
if (provider.isScanning && provider.scannedDevices.isEmpty) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (provider.scannedDevices.isEmpty) {
return const Center(
child: Text(
'No devices found',
style: TextStyle(color: Colors.grey, fontSize: 16),
),
);
}
return ListView.builder(
itemCount: provider.scannedDevices.length,
itemBuilder: (context, index) {
final device = provider.scannedDevices[index];
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF2D2D2D),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(
Icons.bluetooth,
color: Colors.white,
size: 32,
),
title: Text(
device.platformName.isNotEmpty
? device.platformName
: 'Unknown Device',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
subtitle: const Text(
'Tap to connect',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
trailing: const Icon(
Icons.chevron_right,
color: Colors.white,
),
onTap: () async {
Navigator.pop(context);
await provider.connect(device);
if (context.mounted &&
provider.deviceInfo.isConnected) {
final appProvider = context.read<AppProvider>();
await appProvider.initialize();
}
},
),
);
},
);
},
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: _buildCompactStatusBar(),
actions: [
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.refresh),
SizedBox(width: 8),
Text('Refresh Contacts'),
],
),
onTap: () async {
final appProvider = context.read<AppProvider>();
await appProvider.refresh();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Refreshed contacts')),
);
}
},
),
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.map),
SizedBox(width: 8),
Text('Map Management'),
],
),
onTap: () {
Future.delayed(Duration.zero, () {
final appProvider = context.read<AppProvider>();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapManagementScreen(
tileCacheService: appProvider.tileCacheService,
),
),
);
});
},
),
],
),
],
),
body: TabBarView(
controller: _tabController,
children: [
MessagesTab(onNavigateToMap: () => _tabController.animateTo(2)),
const ContactsTab(),
const MapTab(),
],
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.message), text: 'Messages'),
Tab(icon: Icon(Icons.contacts), text: 'Contacts'),
Tab(icon: Icon(Icons.map), text: 'Map'),
],
),
),
);
}
Widget _buildCompactStatusBar() {
return Consumer<ConnectionProvider>(
builder: (context, provider, child) {
final deviceInfo = provider.deviceInfo;
final isConnected = deviceInfo.isConnected;
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'MeshCore',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
isConnected
? deviceInfo.deviceName ?? 'Connected'
: 'Disconnected',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
if (!isConnected)
ElevatedButton.icon(
onPressed: () => _showConnectionDialog(context),
icon: const Icon(Icons.bluetooth, size: 18),
label: const Text('Connect'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
)
else
OutlinedButton(
onPressed: () async {
await provider.disconnect();
if (context.mounted) {
context.read<AppProvider>().clearAllData();
}
},
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Disconnect'),
),
],
);
},
);
}
Widget _buildStatusBar() {
return Consumer<ConnectionProvider>(
builder: (context, provider, child) {
final deviceInfo = provider.deviceInfo;
final isConnected = deviceInfo.isConnected;
return Container(
padding: const EdgeInsets.all(12),
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
Row(
children: [
// Connection status
Icon(
isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
color: isConnected ? Colors.green : Colors.grey,
),
const SizedBox(width: 8),
Expanded(
child: Text(
isConnected
? deviceInfo.deviceName ?? 'Connected'
: 'Not Connected',
style: Theme.of(context).textTheme.bodyMedium,
),
),
// Battery indicator
if (deviceInfo.batteryPercent != null) ...[
Icon(
_getBatteryIcon(deviceInfo.batteryPercent!),
color: _getBatteryColor(deviceInfo.batteryPercent!),
),
const SizedBox(width: 4),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: Theme.of(context).textTheme.bodySmall,
),
],
// Signal strength
if (deviceInfo.signalRssi != null) ...[
const SizedBox(width: 12),
Icon(
Icons.signal_cellular_alt,
color: _getSignalColor(deviceInfo.signalRssi!),
size: 20,
),
const SizedBox(width: 4),
Text(
'${deviceInfo.signalRssi} dBm',
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
const SizedBox(height: 8),
// Connection buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: isConnected
? null
: () => _showConnectionDialog(context),
icon: const Icon(Icons.bluetooth_searching, size: 18),
label: const Text('Connect'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: !isConnected
? null
: () async {
await provider.disconnect();
if (context.mounted) {
context.read<AppProvider>().clearAllData();
}
},
icon: const Icon(Icons.bluetooth_disabled, size: 18),
label: const Text('Disconnect'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
),
),
),
if (isConnected) ...[
const SizedBox(width: 8),
IconButton(
onPressed: () async {
final appProvider = context.read<AppProvider>();
await appProvider.refresh();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Refreshed contacts')),
);
}
},
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
],
),
// Error message
if (provider.error != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error, color: Colors.red, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
provider.error!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: provider.clearError,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
],
],
),
);
},
);
}
IconData _getBatteryIcon(double percentage) {
if (percentage > 80) return Icons.battery_full;
if (percentage > 50) return Icons.battery_5_bar;
if (percentage > 20) return Icons.battery_3_bar;
return Icons.battery_1_bar;
}
Color _getBatteryColor(double percentage) {
if (percentage > 50) return Colors.green;
if (percentage > 20) return Colors.orange;
return Colors.red;
}
Color _getSignalColor(int rssi) {
if (rssi > -60) return Colors.green;
if (rssi > -70) return Colors.orange;
return Colors.red;
}
}

View File

@@ -0,0 +1,728 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:file_picker/file_picker.dart';
import 'package:share_plus/share_plus.dart';
import '../services/tile_cache_service.dart';
import '../models/map_layer.dart';
class MapManagementScreen extends StatefulWidget {
final TileCacheService tileCacheService;
final MapLayer? initialLayer;
final LatLngBounds? initialBounds;
final int? initialZoom;
const MapManagementScreen({
super.key,
required this.tileCacheService,
this.initialLayer,
this.initialBounds,
this.initialZoom,
});
@override
State<MapManagementScreen> createState() => _MapManagementScreenState();
}
class _MapManagementScreenState extends State<MapManagementScreen> {
bool _isLoading = false;
String? _statusMessage;
Map<String, dynamic>? _cacheStats;
// Download parameters
late MapLayer _selectedLayer;
late TextEditingController _northController;
late TextEditingController _southController;
late TextEditingController _eastController;
late TextEditingController _westController;
late int _minZoom;
late int _maxZoom;
double _downloadProgress = 0.0;
bool _isDownloading = false;
@override
void initState() {
super.initState();
// Initialize with provided values or defaults
_selectedLayer = widget.initialLayer ?? MapLayer.openStreetMap;
if (widget.initialBounds != null) {
_northController = TextEditingController(
text: widget.initialBounds!.north.toStringAsFixed(4),
);
_southController = TextEditingController(
text: widget.initialBounds!.south.toStringAsFixed(4),
);
_eastController = TextEditingController(
text: widget.initialBounds!.east.toStringAsFixed(4),
);
_westController = TextEditingController(
text: widget.initialBounds!.west.toStringAsFixed(4),
);
} else {
_northController = TextEditingController(text: '46.1');
_southController = TextEditingController(text: '46.0');
_eastController = TextEditingController(text: '14.6');
_westController = TextEditingController(text: '14.4');
}
// Set zoom levels
if (widget.initialZoom != null) {
_minZoom = (widget.initialZoom! - 2).clamp(1, 19);
_maxZoom = (widget.initialZoom! + 2).clamp(1, 19);
} else {
_minZoom = 10;
_maxZoom = 16;
}
_loadCacheStats();
}
@override
void dispose() {
_northController.dispose();
_southController.dispose();
_eastController.dispose();
_westController.dispose();
super.dispose();
}
Future<void> _loadCacheStats() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final stats = await widget.tileCacheService.getStoreStats();
if (!mounted) return;
setState(() {
_cacheStats = stats;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_statusMessage = 'Error loading stats: $e';
_isLoading = false;
});
}
}
Future<void> _downloadRegion() async {
try {
final north = double.tryParse(_northController.text);
final south = double.tryParse(_southController.text);
final east = double.tryParse(_eastController.text);
final west = double.tryParse(_westController.text);
if (north == null || south == null || east == null || west == null) {
_showError('Invalid coordinates. Please enter valid numbers.');
return;
}
if (north <= south || east <= west) {
_showError('Invalid bounds. North must be > South, East must be > West.');
return;
}
final bounds = LatLngBounds(
LatLng(south, west),
LatLng(north, east),
);
if (!mounted) return;
setState(() {
_isDownloading = true;
_downloadProgress = 0.0;
_statusMessage = 'Starting download...';
});
await widget.tileCacheService.downloadRegion(
layer: _selectedLayer,
bounds: bounds,
minZoom: _minZoom,
maxZoom: _maxZoom,
onProgress: (progress) {
print('UI received progress update: $progress%');
if (!mounted) return;
setState(() {
_downloadProgress = progress;
_statusMessage = 'Downloading map tiles...';
});
},
);
if (!mounted) return;
setState(() {
_isDownloading = false;
_statusMessage = 'Download completed successfully!';
});
await _loadCacheStats();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Map download completed!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (!mounted) return;
setState(() {
_isDownloading = false;
_statusMessage = 'Download failed: $e';
});
_showError('Download failed: $e');
}
}
Future<void> _cancelDownload() async {
try {
if (!mounted) return;
setState(() => _statusMessage = 'Cancelling download...');
await widget.tileCacheService.cancelDownload();
if (!mounted) return;
setState(() {
_isDownloading = false;
_statusMessage = 'Download cancelled';
});
await _loadCacheStats();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download cancelled'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
if (!mounted) return;
setState(() {
_isDownloading = false;
_statusMessage = 'Cancel failed: $e';
});
_showError('Cancel failed: $e');
}
}
Future<void> _exportMaps() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final exportPath = await widget.tileCacheService.exportCache();
if (!mounted) return;
setState(() => _isLoading = false);
if (mounted) {
await Share.shareXFiles(
[XFile(exportPath)],
subject: 'MeshCore SAR Maps Export',
text: 'Offline maps export from MeshCore SAR',
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Maps exported to: $exportPath'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 5),
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
_showError('Export failed: $e');
}
}
Future<void> _importMaps() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['fmtc'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
return;
}
if (!mounted) return;
setState(() => _isLoading = true);
final filePath = result.files.first.path;
if (filePath == null) {
throw Exception('Invalid file path');
}
await widget.tileCacheService.importCache(filePath);
if (!mounted) return;
setState(() => _isLoading = false);
await _loadCacheStats();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Maps imported successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
_showError('Import failed: $e');
}
}
Future<void> _clearCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Cache'),
content: const Text(
'Are you sure you want to delete all downloaded maps? This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Clear'),
),
],
),
);
if (confirmed != true) return;
if (!mounted) return;
setState(() => _isLoading = true);
try {
await widget.tileCacheService.clearCache();
if (!mounted) return;
setState(() => _isLoading = false);
await _loadCacheStats();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cache cleared successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() => _isLoading = false);
_showError('Clear cache failed: $e');
}
}
void _showError(String message) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Map Management'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Cache Statistics
_buildStatisticsCard(),
const SizedBox(height: 16),
// Download Region
_buildDownloadCard(),
const SizedBox(height: 16),
// Import/Export/Clear
_buildActionsCard(),
],
),
),
);
}
Widget _buildStatisticsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Cache Statistics',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadCacheStats,
),
],
),
const SizedBox(height: 16),
if (_cacheStats != null) ...[
_buildStatRow(
'Total Tiles',
'${_cacheStats!['tileCount'] ?? 0}',
Icons.grid_on,
),
_buildStatRow(
'Cache Size',
'${(_cacheStats!['sizeMB'] ?? 0.0).toStringAsFixed(2)} MB',
Icons.storage,
),
_buildStatRow(
'Store Name',
_cacheStats!['storeName'] ?? 'Unknown',
Icons.folder,
),
] else
const Text('No cache statistics available'),
],
),
),
);
}
Widget _buildStatRow(String label, String value, IconData icon) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(icon, size: 20, color: Colors.grey[600]),
const SizedBox(width: 12),
Expanded(
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
),
Text(value, style: TextStyle(color: Colors.grey[600])),
],
),
);
}
Widget _buildDownloadCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Download Region',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
// Map Layer Selection
DropdownButtonFormField<MapLayer>(
value: _selectedLayer,
decoration: const InputDecoration(
labelText: 'Map Layer',
border: OutlineInputBorder(),
),
items: MapLayer.allLayers.map((layer) {
return DropdownMenuItem(
value: layer,
child: Text(layer.name),
);
}).toList(),
onChanged: _isDownloading ? null : (layer) {
if (layer != null) {
setState(() => _selectedLayer = layer);
}
},
),
const SizedBox(height: 16),
// Coordinates
Text(
'Region Bounds',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _northController,
decoration: const InputDecoration(
labelText: 'North',
border: OutlineInputBorder(),
hintText: '46.1',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_isDownloading,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _southController,
decoration: const InputDecoration(
labelText: 'South',
border: OutlineInputBorder(),
hintText: '46.0',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_isDownloading,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _eastController,
decoration: const InputDecoration(
labelText: 'East',
border: OutlineInputBorder(),
hintText: '14.6',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_isDownloading,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _westController,
decoration: const InputDecoration(
labelText: 'West',
border: OutlineInputBorder(),
hintText: '14.4',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
enabled: !_isDownloading,
),
),
],
),
const SizedBox(height: 16),
// Zoom Levels
Text(
'Zoom Levels',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Min: $_minZoom'),
Slider(
value: _minZoom.toDouble(),
min: 1,
max: 19,
divisions: 18,
label: '$_minZoom',
onChanged: _isDownloading ? null : (value) {
setState(() {
_minZoom = value.toInt();
if (_minZoom > _maxZoom) {
_maxZoom = _minZoom;
}
});
},
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Max: $_maxZoom'),
Slider(
value: _maxZoom.toDouble(),
min: 1,
max: 19,
divisions: 18,
label: '$_maxZoom',
onChanged: _isDownloading ? null : (value) {
setState(() {
_maxZoom = value.toInt();
if (_maxZoom < _minZoom) {
_minZoom = _maxZoom;
}
});
},
),
],
),
),
],
),
// Download Progress
if (_isDownloading) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_statusMessage ?? 'Downloading...',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
Text(
'${_downloadProgress.toStringAsFixed(1)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _downloadProgress / 100,
minHeight: 8,
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
],
const SizedBox(height: 16),
// Download/Cancel Button
if (_isDownloading)
ElevatedButton.icon(
onPressed: _cancelDownload,
icon: const Icon(Icons.cancel),
label: const Text('Cancel Download'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
)
else
ElevatedButton.icon(
onPressed: _downloadRegion,
icon: const Icon(Icons.download),
label: const Text('Download Region'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
const SizedBox(height: 8),
Text(
'Note: Large regions or high zoom levels may take significant time and storage.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
),
);
}
Widget _buildActionsCard() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Map Actions',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
// Export Button
ElevatedButton.icon(
onPressed: _isDownloading ? null : _exportMaps,
icon: const Icon(Icons.upload),
label: const Text('Export Maps'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
const SizedBox(height: 8),
// Import Button
ElevatedButton.icon(
onPressed: _isDownloading ? null : _importMaps,
icon: const Icon(Icons.download),
label: const Text('Import Maps'),
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
const SizedBox(height: 8),
// Clear Cache Button
OutlinedButton.icon(
onPressed: _isDownloading ? null : _clearCache,
icon: const Icon(Icons.delete_forever),
label: const Text('Clear All Maps'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
minimumSize: const Size.fromHeight(48),
),
),
],
),
),
);
}
}

626
lib/screens/map_tab.dart Normal file
View File

@@ -0,0 +1,626 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/map_provider.dart';
import '../providers/app_provider.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../models/map_layer.dart';
import '../services/tile_cache_service.dart';
import '../widgets/map_markers.dart';
import 'map_management_screen.dart';
class MapTab extends StatefulWidget {
const MapTab({super.key});
@override
State<MapTab> createState() => _MapTabState();
}
class _MapTabState extends State<MapTab> {
final MapController _mapController = MapController();
final TileCacheService _tileCache = TileCacheService();
bool _isInitialized = false;
MapLayer _currentLayer = MapLayer.openStreetMap;
Position? _currentPosition;
bool _showLegend = true;
double _gpsUpdateDistance = 3.0; // meters
StreamSubscription<Position>? _positionStreamSubscription;
// Default center point (will be updated based on markers)
static const LatLng _defaultCenter = LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
static const double _defaultZoom = 13.0;
@override
void initState() {
super.initState();
_initializeTileCache();
_requestLocationPermission();
// Listen to map provider for navigation requests
WidgetsBinding.instance.addPostFrameCallback((_) {
final mapProvider = context.read<MapProvider>();
mapProvider.addListener(_handleMapNavigation);
});
}
Future<void> _requestLocationPermission() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return;
}
}
if (permission == LocationPermission.deniedForever) {
return;
}
// Get initial position
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (mounted) {
setState(() {
_currentPosition = position;
});
}
} catch (e) {
debugPrint('Error getting location: $e');
}
// Start listening to location updates
_positionStreamSubscription = Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: _gpsUpdateDistance.toInt(),
),
).listen((Position position) {
if (mounted) {
setState(() {
_currentPosition = position;
});
}
});
}
void _handleMapNavigation() {
final mapProvider = context.read<MapProvider>();
if (mapProvider.targetLocation != null && _isInitialized) {
_mapController.move(
mapProvider.targetLocation!,
mapProvider.targetZoom ?? _defaultZoom,
);
// Clear the navigation request after handling
mapProvider.clearNavigation();
}
}
Future<void> _initializeTileCache() async {
try {
await _tileCache.initialize();
if (mounted) {
setState(() {
_isInitialized = true;
});
}
} catch (e) {
debugPrint('Error initializing tile cache: $e');
if (mounted) {
setState(() {
_isInitialized = true; // Continue without caching
});
}
}
}
@override
void dispose() {
final mapProvider = context.read<MapProvider>();
mapProvider.removeListener(_handleMapNavigation);
_positionStreamSubscription?.cancel();
_mapController.dispose();
_tileCache.dispose();
super.dispose();
}
LatLng _calculateCenter(List<Contact> contacts, List<SarMarker> sarMarkers) {
final allPoints = <LatLng>[];
for (final contact in contacts) {
if (contact.displayLocation != null) {
allPoints.add(contact.displayLocation!);
}
}
for (final marker in sarMarkers) {
allPoints.add(marker.location);
}
if (allPoints.isEmpty) return _defaultCenter;
double lat = 0, lng = 0;
for (final point in allPoints) {
lat += point.latitude;
lng += point.longitude;
}
return LatLng(lat / allPoints.length, lng / allPoints.length);
}
void _showLayerSelector(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.layers),
const SizedBox(width: 12),
Expanded(
child: Text(
'Select Map Layer',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.download),
tooltip: 'Download visible area',
onPressed: () {
Navigator.pop(context);
_navigateToDownload(context);
},
),
],
),
),
const Divider(),
...MapLayer.allLayers.map((layer) => ListTile(
leading: _currentLayer.type == layer.type
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.radio_button_unchecked),
title: Text(layer.name),
subtitle: Text(layer.attribution),
onTap: () {
setState(() {
_currentLayer = layer;
});
Navigator.pop(context);
},
)),
],
),
),
);
}
void _navigateToDownload(BuildContext context) {
// Get current map bounds
final bounds = _mapController.camera.visibleBounds;
final currentZoom = _mapController.camera.zoom.round();
// Navigate to Map Management screen with pre-populated data
final appProvider = context.read<AppProvider>();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapManagementScreen(
tileCacheService: appProvider.tileCacheService,
initialLayer: _currentLayer,
initialBounds: bounds,
initialZoom: currentZoom,
),
),
);
}
void _showOptionsMenu(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 12),
Text(
'Map Options',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(),
// Legend toggle
SwitchListTile(
secondary: const Icon(Icons.info_outline),
title: const Text('Show Legend'),
subtitle: const Text('Display marker type counts'),
value: _showLegend,
onChanged: (value) {
setState(() {
_showLegend = value;
});
setModalState(() {});
},
),
const Divider(),
// GPS Update Distance
ListTile(
leading: const Icon(Icons.gps_fixed),
title: const Text('GPS Update Distance'),
subtitle: Text('${_gpsUpdateDistance.toStringAsFixed(0)} meters'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
Slider(
value: _gpsUpdateDistance,
min: 1,
max: 20,
divisions: 19,
label: '${_gpsUpdateDistance.toStringAsFixed(0)}m',
onChanged: (value) {
setModalState(() {
_gpsUpdateDistance = value;
});
},
onChangeEnd: (value) {
setState(() {
_gpsUpdateDistance = value;
});
// Restart location stream with new distance
_restartLocationStream();
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'1m',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'20m',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
),
),
),
);
}
void _restartLocationStream() {
// Cancel existing subscription
_positionStreamSubscription?.cancel();
// Start new stream with updated distance
_positionStreamSubscription = Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: _gpsUpdateDistance.toInt(),
),
).listen((Position position) {
if (mounted) {
setState(() {
_currentPosition = position;
});
}
});
}
@override
Widget build(BuildContext context) {
return Consumer2<ContactsProvider, MessagesProvider>(
builder: (context, contactsProvider, messagesProvider, child) {
final contactsWithLocation = contactsProvider.chatContactsWithLocation;
final sarMarkers = messagesProvider.sarMarkers;
final center = _calculateCenter(contactsWithLocation, sarMarkers);
return Stack(
children: [
// Map widget
_isInitialized
? FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: center,
initialZoom: _defaultZoom,
minZoom: 5,
maxZoom: 18,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.all,
),
),
children: [
TileLayer(
urlTemplate: _currentLayer.urlTemplate,
tileProvider: _tileCache.getTileProvider(_currentLayer),
userAgentPackageName: 'com.meshcore.sar',
maxZoom: _currentLayer.maxZoom.toDouble(),
),
MarkerLayer(
markers: [
...MapMarkers.createTeamMemberMarkers(
contactsWithLocation,
context,
),
...MapMarkers.createSarMarkers(
sarMarkers,
context,
),
// User location marker
if (_currentPosition != null)
Marker(
point: LatLng(
_currentPosition!.latitude,
_currentPosition!.longitude,
),
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.3),
shape: BoxShape.circle,
),
child: Container(
margin: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: 4,
),
],
),
child: const Icon(
Icons.navigation,
color: Colors.white,
size: 16,
),
),
),
),
],
),
],
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Initializing map...',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
// Map legend overlay
if (_showLegend)
Positioned(
top: 16,
right: 16,
child: _MapLegend(
teamMemberCount: contactsWithLocation.length,
foundPersonCount: messagesProvider.foundPersonMarkers.length,
fireCount: messagesProvider.fireMarkers.length,
stagingAreaCount: messagesProvider.stagingAreaMarkers.length,
),
),
// Map controls - right side
Positioned(
bottom: 16,
right: 16,
child: Column(
children: [
FloatingActionButton.small(
heroTag: 'center_map',
onPressed: () async {
// Force update GPS location and jump to it
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (mounted) {
setState(() {
_currentPosition = position;
});
_mapController.move(
LatLng(position.latitude, position.longitude),
16,
);
}
} catch (e) {
debugPrint('Error getting location: $e');
// Fallback to cached position or default center
if (_currentPosition != null) {
_mapController.move(
LatLng(
_currentPosition!.latitude,
_currentPosition!.longitude,
),
16,
);
} else {
_mapController.move(center, _defaultZoom);
}
}
},
child: const Icon(Icons.my_location),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'layer_selector',
onPressed: () => _showLayerSelector(context),
child: const Icon(Icons.layers),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'options_menu',
onPressed: () => _showOptionsMenu(context),
child: const Icon(Icons.more_vert),
),
],
),
),
],
);
},
);
}
}
class _MapLegend extends StatelessWidget {
final int teamMemberCount;
final int foundPersonCount;
final int fireCount;
final int stagingAreaCount;
const _MapLegend({
required this.teamMemberCount,
required this.foundPersonCount,
required this.fireCount,
required this.stagingAreaCount,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Legend',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_LegendItem(
icon: Icons.person,
color: Colors.blue,
label: 'Team',
count: teamMemberCount,
),
_LegendItem(
icon: Icons.person_pin,
color: Colors.green,
label: 'Found',
count: foundPersonCount,
),
_LegendItem(
icon: Icons.local_fire_department,
color: Colors.red,
label: 'Fire',
count: fireCount,
),
_LegendItem(
icon: Icons.home_work,
color: Colors.orange,
label: 'Staging',
count: stagingAreaCount,
),
],
),
),
);
}
}
class _LegendItem extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
final int count;
const _LegendItem({
required this.icon,
required this.color,
required this.label,
required this.count,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
count.toString(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart';
import '../models/message.dart';
import '../utils/sar_message_parser.dart';
class MessagesTab extends StatelessWidget {
final VoidCallback onNavigateToMap;
const MessagesTab({super.key, required this.onNavigateToMap});
@override
Widget build(BuildContext context) {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = messagesProvider.getRecentMessages(count: 100);
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No messages yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Connect to a device to start receiving messages',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
return ListView.builder(
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return _MessageBubble(
message: message,
onTap: message.isSarMarker && message.sarGpsCoordinates != null
? () {
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
onNavigateToMap();
}
: null,
);
},
);
},
);
}
}
class _MessageBubble extends StatelessWidget {
final Message message;
final VoidCallback? onTap;
const _MessageBubble({
required this.message,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isSarMarker = message.isSarMarker;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSarMarker
? _getSarMarkerColor(context)
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(12),
border: isSarMarker
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Sender and time
Row(
children: [
if (message.isChannelMessage)
const Icon(Icons.tag, size: 16)
else
const Icon(Icons.person, size: 16),
const SizedBox(width: 4),
Text(
message.displaySender,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (isSarMarker)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'SAR',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Text(
message.timeAgo,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
const SizedBox(height: 8),
// SAR marker content
if (isSarMarker && message.sarMarkerType != null) ...[
Row(
children: [
Text(
message.sarMarkerType!.emoji,
style: const TextStyle(fontSize: 32),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.sarMarkerType!.displayName,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (message.sarGpsCoordinates != null)
Text(
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.primary,
),
],
),
const SizedBox(height: 4),
Text(
'Tap to view on map',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontStyle: FontStyle.italic,
),
),
]
// Regular message content
else
Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
Color _getSarMarkerColor(BuildContext context) {
return Theme.of(context).colorScheme.primaryContainer;
}
}

View File

@@ -0,0 +1,139 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer reader for parsing MeshCore protocol binary data
class BufferReader {
final Uint8List _buffer;
int _offset = 0;
BufferReader(this._buffer);
/// Get remaining bytes count
int get remainingBytesCount => _buffer.length - _offset;
/// Check if there are bytes remaining
bool get hasRemaining => _offset < _buffer.length;
/// Get current offset
int get offset => _offset;
/// Set offset
set offset(int value) => _offset = value;
/// Read a single byte (uint8)
int readByte() {
if (_offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
return _buffer[_offset++];
}
/// Read a signed byte (int8)
int readInt8() {
final value = readByte();
return value > 127 ? value - 256 : value;
}
/// Read unsigned 16-bit integer (little-endian)
int readUInt16LE() {
if (_offset + 2 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[_offset] | (_buffer[_offset + 1] << 8);
_offset += 2;
return value;
}
/// Read signed 16-bit integer (little-endian)
int readInt16LE() {
final value = readUInt16LE();
return value > 32767 ? value - 65536 : value;
}
/// Read unsigned 32-bit integer (little-endian)
int readUInt32LE() {
if (_offset + 4 > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final value = _buffer[_offset] |
(_buffer[_offset + 1] << 8) |
(_buffer[_offset + 2] << 16) |
(_buffer[_offset + 3] << 24);
_offset += 4;
return value;
}
/// Read signed 32-bit integer (little-endian)
int readInt32LE() {
final value = readUInt32LE();
return value > 2147483647 ? value - 4294967296 : value;
}
/// Read a fixed number of bytes
Uint8List readBytes(int length) {
if (_offset + length > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(_offset, _offset + length);
_offset += length;
return bytes;
}
/// Read remaining bytes
Uint8List readRemainingBytes() {
final bytes = _buffer.sublist(_offset);
_offset = _buffer.length;
return bytes;
}
/// Read null-terminated string (C-string) with max length
String readCString(int maxLength) {
if (_offset + maxLength > _buffer.length) {
throw Exception('Buffer overflow: attempting to read beyond buffer length');
}
final bytes = _buffer.sublist(_offset, _offset + maxLength);
_offset += maxLength;
// Find null terminator
int nullIndex = bytes.indexOf(0);
if (nullIndex == -1) {
nullIndex = maxLength;
}
// Decode string up to null terminator
return utf8.decode(bytes.sublist(0, nullIndex));
}
/// Read length-prefixed string (remaining bytes as UTF-8)
String readString() {
final bytes = readRemainingBytes();
return utf8.decode(bytes);
}
/// Peek at next byte without advancing offset
int peekByte() {
if (_offset >= _buffer.length) {
throw Exception('Buffer overflow: attempting to peek beyond buffer length');
}
return _buffer[_offset];
}
/// Skip bytes
void skip(int count) {
if (_offset + count > _buffer.length) {
throw Exception('Buffer overflow: attempting to skip beyond buffer length');
}
_offset += count;
}
/// Reset offset to beginning
void reset() {
_offset = 0;
}
@override
String toString() {
return 'BufferReader(length: ${_buffer.length}, offset: $_offset, remaining: $remainingBytesCount)';
}
}

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import 'dart:convert';
/// Buffer writer for creating MeshCore protocol binary data
class BufferWriter {
final List<int> _buffer = [];
/// Get current buffer length
int get length => _buffer.length;
/// Write a single byte (uint8)
void writeByte(int value) {
if (value < 0 || value > 255) {
throw ArgumentError('Byte value must be between 0 and 255');
}
_buffer.add(value);
}
/// Write a signed byte (int8)
void writeInt8(int value) {
if (value < -128 || value > 127) {
throw ArgumentError('Int8 value must be between -128 and 127');
}
_buffer.add(value < 0 ? value + 256 : value);
}
/// Write unsigned 16-bit integer (little-endian)
void writeUInt16LE(int value) {
if (value < 0 || value > 65535) {
throw ArgumentError('UInt16 value must be between 0 and 65535');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
}
/// Write signed 16-bit integer (little-endian)
void writeInt16LE(int value) {
if (value < -32768 || value > 32767) {
throw ArgumentError('Int16 value must be between -32768 and 32767');
}
final unsigned = value < 0 ? value + 65536 : value;
writeUInt16LE(unsigned);
}
/// Write unsigned 32-bit integer (little-endian)
void writeUInt32LE(int value) {
if (value < 0 || value > 4294967295) {
throw ArgumentError('UInt32 value must be between 0 and 4294967295');
}
_buffer.add(value & 0xFF);
_buffer.add((value >> 8) & 0xFF);
_buffer.add((value >> 16) & 0xFF);
_buffer.add((value >> 24) & 0xFF);
}
/// Write signed 32-bit integer (little-endian)
void writeInt32LE(int value) {
if (value < -2147483648 || value > 2147483647) {
throw ArgumentError('Int32 value must be between -2147483648 and 2147483647');
}
final unsigned = value < 0 ? value + 4294967296 : value;
writeUInt32LE(unsigned);
}
/// Write bytes from Uint8List
void writeBytes(Uint8List bytes) {
_buffer.addAll(bytes);
}
/// Write bytes from List<int>
void writeBytesFromList(List<int> bytes) {
_buffer.addAll(bytes);
}
/// Write null-terminated string (C-string) with fixed length
/// Pads with zeros if string is shorter than maxLength
void writeCString(String str, int maxLength) {
final bytes = utf8.encode(str);
// Ensure we don't exceed max length
final length = bytes.length < maxLength ? bytes.length : maxLength;
// Write string bytes
for (int i = 0; i < length; i++) {
_buffer.add(bytes[i]);
}
// Pad with zeros
for (int i = length; i < maxLength; i++) {
_buffer.add(0);
}
}
/// Write length-prefixed string
void writeString(String str) {
final bytes = utf8.encode(str);
_buffer.addAll(bytes);
}
/// Write string with length prefix (1 byte)
void writeLengthPrefixedString(String str) {
final bytes = utf8.encode(str);
if (bytes.length > 255) {
throw ArgumentError('String too long for length-prefixed format (max 255 bytes)');
}
writeByte(bytes.length);
_buffer.addAll(bytes);
}
/// Get buffer as Uint8List
Uint8List toBytes() {
return Uint8List.fromList(_buffer);
}
/// Clear the buffer
void clear() {
_buffer.clear();
}
/// Get buffer as hex string (for debugging)
String toHexString() {
return _buffer.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
@override
String toString() {
return 'BufferWriter(length: $length, hex: ${toHexString()})';
}
}

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