feat: Enhance device configuration and location tracking features

- Refetch device info after updating settings in device_config_screen.dart.
- Update map_tab.dart to use singleton instance of LocationTrackingService and streamline location tracking callbacks.
- Modify ble_response_handler.dart to handle contact not found errors and improve error callback structure.
- Enhance location_tracking_service.dart with retry logic for GPS position acquisition and initial position setting without broadcasting.
- Update meshcore_ble_service.dart to track last contact for auto-recovery on errors.
- Improve tile_cache_service.dart error messages and streamline tile download logic.
- Add current GPS location insertion feature in direct_message_sheet.dart with permission checks.
- Update pubspec.lock and pubspec.yaml to include integration_test dependency.
- Add screenshot automation script for iOS and Android devices.
- Create integration test driver for screenshot capturing.
This commit is contained in:
Janez T
2025-10-18 14:55:41 +02:00
parent 3a0bcabdea
commit 5fd090b5dc
29 changed files with 2643 additions and 129 deletions

View File

@@ -14,7 +14,9 @@
"Bash(dart:*)", "Bash(dart:*)",
"Bash(flutter pub:*)", "Bash(flutter pub:*)",
"Bash(flutter clean:*)", "Bash(flutter clean:*)",
"Bash(flutter build:*)" "Bash(flutter build:*)",
"Bash(xcrun simctl:*)",
"WebFetch(domain:github.com)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

310
AUTO_RECOVERY.md Normal file
View File

@@ -0,0 +1,310 @@
# Auto-Recovery System for ERR_CODE_NOT_FOUND
## Problem Statement
When sending a message to a room contact that exists in the app's contact list but not in the companion radio's contact table, the radio responds with:
```
ERROR (0x01) with error code 2 (ERR_CODE_NOT_FOUND)
```
This happens because:
1. Room contacts may have been deleted from the radio
2. New room contacts added to the app haven't been synced to the radio yet
3. The radio was factory reset but the app still has cached contacts
## Solution: Automatic Contact Recovery
The system now automatically detects and recovers from `ERR_CODE_NOT_FOUND` errors by:
1. **Detecting the error** in `BleResponseHandler` (ble_response_handler.dart:584-587)
2. **Tracking the failing contact** via `setLastContactPublicKey()`
3. **Triggering auto-recovery** via `onContactNotFound` callback
4. **Adding the missing contact** to the radio using `CMD_ADD_UPDATE_CONTACT`
5. **Retrying the send operation** automatically after 300ms delay
## Implementation Flow
```
┌─────────────────────────────────────────────────────────────┐
│ User sends message to room contact │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ConnectionProvider.sendTextMessage() │
│ - Tracks pending operation: _PendingSendOperation │
│ - Contains: contactPublicKey, text, messageId, contact │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ MeshCoreBleService.sendTextMessage() │
│ - Calls: responseHandler.setLastContactPublicKey() │
│ - Sends: CMD_SEND_TXT_MSG (0x02) │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Radio processes command │
│ ❌ Contact not found in radio's contact table │
│ → Responds: RESP_CODE_ERR (0x01) with errCode=2 │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ BleResponseHandler._handleError() │
│ - Detects: errorCode == 2 (ERR_CODE_NOT_FOUND) │
│ - Triggers: onContactNotFound(lastContactPublicKey) │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ConnectionProvider.onContactNotFound callback │
│ 1. Looks up pending operation by public key │
│ 2. Calls: bleService.addOrUpdateContact(contact) │
│ → Sends: CMD_ADD_UPDATE_CONTACT (0x09) │
│ 3. Waits 300ms for contact to be added │
│ 4. Retries: bleService.sendTextMessage() │
│ → Sends: CMD_SEND_TXT_MSG (0x02) again │
│ 5. Clears pending operation on success │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Radio processes retry │
│ ✅ Contact now exists in radio's contact table │
│ → Responds: RESP_CODE_SENT (0x06) with ACK tag │
└─────────────────┬───────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Message sent successfully │
│ → UI shows "sent" status │
│ → Normal delivery confirmation flow continues │
└─────────────────────────────────────────────────────────────┘
```
## Key Components
### 1. BleResponseHandler (lib/services/ble/ble_response_handler.dart)
**New Callbacks:**
```dart
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
```
**Error Detection:**
```dart
void _handleError(BufferReader reader) {
final errorCode = FrameParser.parseError(reader);
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
onContactNotFound?.call(_lastContactPublicKey);
}
onError?.call(errorMsg, errorCode: errorCode);
}
```
**Tracking:**
```dart
void setLastContactPublicKey(Uint8List? publicKey) {
_lastContactPublicKey = publicKey;
}
```
### 2. MeshCoreBleService (lib/services/meshcore_ble_service.dart)
**Updated Typedef:**
```dart
typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
```
**Callback Forwarding:**
```dart
_responseHandler.onError = (error, {int? errorCode}) {
onError?.call(error, errorCode: errorCode);
};
_responseHandler.onContactNotFound = (contactPublicKey) {
onContactNotFound?.call(contactPublicKey);
};
```
**Contact Tracking:**
```dart
Future<void> sendTextMessage({...}) async {
// Track the last contact for auto-recovery if contact not found
_responseHandler.setLastContactPublicKey(contactPublicKey);
await _commandSender.writeData(...);
}
```
### 3. ConnectionProvider (lib/providers/connection_provider.dart)
**Pending Operation Tracking:**
```dart
class _PendingSendOperation {
final Uint8List contactPublicKey;
final String text;
final String? messageId;
final Contact? contact;
final int retryAttempt;
}
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
```
**Auto-Recovery Logic:**
```dart
_bleService.onContactNotFound = (contactPublicKey) async {
// 1. Look up pending operation
final pendingOp = _pendingSendOperations[operationId];
// 2. Add contact to radio
await _bleService.addOrUpdateContact(pendingOp.contact!);
await Future.delayed(const Duration(milliseconds: 300));
// 3. Retry send
await _bleService.sendTextMessage(
contactPublicKey: pendingOp.contactPublicKey,
text: pendingOp.text,
attempt: pendingOp.retryAttempt,
);
// 4. Cleanup
_pendingSendOperations.remove(operationId);
};
```
**Send Message Tracking:**
```dart
Future<bool> sendTextMessage({...}) async {
// Track operation before sending
if (contact != null) {
_pendingSendOperations[operationId] = _PendingSendOperation(...);
}
await _bleService.sendTextMessage(...);
// Clear after 500ms (if no error occurs)
Future.delayed(const Duration(milliseconds: 500), () {
_pendingSendOperations.remove(operationId);
});
}
```
## Testing Scenarios
### Scenario 1: Room contact deleted from radio
1. User has room "SAR-Command" in app's contact list
2. Room was deleted from companion radio
3. User sends SAR marker to room
4. **Expected:** System automatically adds room and sends message
5. **Verify:** Message appears as "sent" in UI
### Scenario 2: New room added to app
1. User manually adds new room contact to app
2. Room not yet in companion radio's contact table
3. User sends message to new room
4. **Expected:** System automatically adds room and sends message
5. **Verify:** Message appears as "sent" in UI
### Scenario 3: Factory reset radio
1. Radio was factory reset (all contacts cleared)
2. App still has cached contacts
3. User sends message to any room
4. **Expected:** System automatically re-adds contact and sends message
5. **Verify:** Message appears as "sent" in UI
## Console Output Example
**Before (failed send):**
```
📤 [TX] Sending command: SEND_TXT_MSG (0x02)
Data size: 46 bytes
📥 [RX] Received: ERROR (0x01)
❌ [Error] Not found
⚠️ [Provider] BLE error received: Not found
```
**After (auto-recovery):**
```
📤 [TX] Sending command: SEND_TXT_MSG (0x02)
Data size: 46 bytes
📝 Tracked pending operation for auto-recovery: 8f:a0:f2:68:d0:c1
📥 [RX] Received: ERROR (0x01)
❌ [Error] Not found
⚠️ [Error] Contact not found in radio - attempting auto-recovery
🔧 [Provider] Contact not found error detected - initiating auto-recovery
📋 Found pending operation for: SAR-Command
📤 Step 1: Adding contact to radio...
📝 [BLE] Adding/updating contact on companion radio:
Name: SAR-Command
Public key prefix: 8f:a0:f2:68:d0:c1
Type: room (3)
📤 [TX] Sending command: ADD_UPDATE_CONTACT (0x09)
📥 [RX] Received: OK (0x00)
✅ Contact added successfully
🔄 Step 2: Retrying message send...
📤 [TX] Sending command: SEND_TXT_MSG (0x02)
📥 [RX] Received: SENT (0x06)
✅ Auto-recovery completed - message resent
```
## Error Handling
### If contact is null
```
⚠️ No pending operation found for recovery: 8f:a0:f2:68:d0:c1
```
This happens if the send was called without a Contact object. Cannot auto-recover.
### If add contact fails
```
❌ Auto-recovery failed: BLE write error
```
Error is logged and operation is cleared. User must manually retry.
### If retry send fails
Same as above - error logged, operation cleared.
## Benefits
1. **Zero user intervention** - automatic recovery
2. **Transparent operation** - user sees "sent" status as expected
3. **Robust handling** - works for all ERR_CODE_NOT_FOUND scenarios
4. **Minimal latency** - 300ms delay is imperceptible to user
5. **Clean architecture** - isolated to ConnectionProvider layer
## Future Enhancements
1. **Batch recovery** - if multiple contacts missing, add all at once
2. **Proactive sync** - periodically sync app contacts to radio
3. **Smart caching** - track which contacts have been successfully added
4. **UI feedback** - optional: show "Adding contact..." toast during recovery
5. **Telemetry** - track auto-recovery success rate for monitoring
## Related Files
- `lib/services/ble/ble_response_handler.dart` - Error detection
- `lib/services/meshcore_ble_service.dart` - Service layer coordination
- `lib/providers/connection_provider.dart` - Auto-recovery orchestration
- `lib/services/protocol/frame_parser.dart` - Error code parsing
- `lib/services/meshcore_constants.dart` - Error code constants
## MeshCore Protocol Reference
**Error Codes (ERR_CODE):**
- 1: ERR_CODE_UNSUPPORTED_CMD
- **2: ERR_CODE_NOT_FOUND** ← This is what we handle
- 3: ERR_CODE_TABLE_FULL
- 4: ERR_CODE_BAD_STATE
- 5: ERR_CODE_FILE_IO_ERROR
- 6: ERR_CODE_ILLEGAL_ARG
**Recovery Commands:**
- CMD_ADD_UPDATE_CONTACT (9): Adds contact to radio's contact table
- CMD_SEND_TXT_MSG (2): Sends message to contact (retried after add)
## Conclusion
The auto-recovery system provides a seamless user experience by automatically handling missing contacts in the radio's contact table. Users can now send messages to room contacts without worrying about synchronization issues, and the system will intelligently recover from `ERR_CODE_NOT_FOUND` errors.

289
BLOG_POST.md Normal file
View File

@@ -0,0 +1,289 @@
# MeshCore SAR: Off-Grid Communication for Search & Rescue Operations
When the grid goes down, lives are on the line. MeshCore SAR is a revolutionary mobile application that enables search and rescue teams to communicate, coordinate, and share critical information—even when cellular networks and internet connectivity are completely unavailable.
Built on cutting-edge mesh networking technology, MeshCore SAR transforms ordinary smartphones into powerful off-grid communication devices using low-power radio hardware. Whether you're coordinating a wilderness rescue, managing a disaster response, or operating in remote areas, MeshCore SAR keeps your team connected when it matters most.
## Why MeshCore SAR?
Traditional communication systems fail when you need them most:
- **Cellular networks** collapse during disasters or don't exist in remote wilderness
- **Satellite phones** are expensive and have limited messaging capabilities
- **Radio systems** require licensing and lack modern features like GPS integration
MeshCore SAR solves these problems by creating a resilient, self-healing mesh network that:
-**Works completely off-grid** - No cellular, WiFi, or internet required
-**Extends range through mesh routing** - Messages hop through nearby devices
-**Integrates GPS tracking** - Real-time location sharing on offline maps
-**Specializes in SAR operations** - Purpose-built features for emergency response
-**Uses affordable hardware** - Low-cost LoRa radios via Bluetooth
---
## 🗨️ Messages: Reliable Communication When Networks Fail
[*Image placeholder: Messages screen showing conversation with delivery status*]
At the heart of MeshCore SAR is a robust messaging system designed for mission-critical communication.
### Key Features:
**🎯 Multiple Communication Modes**
- **Direct Messages**: Private one-to-one communication with team members
- **Public Channel**: Broadcast updates to all nearby devices
- **Rooms**: Persistent message storage for coordination centers
**📡 Smart Message Delivery**
- **Intelligent routing**: Messages automatically find the best path through the mesh network
- **Delivery confirmation**: Know when your message reaches its destination with ACK tracking
- **Automatic retries**: Failed messages retry automatically with progressive timeouts
- **Flood fallback**: Critical messages use broadcast mode if routing fails
**🚨 SAR Marker Messages**
Send location-tagged alerts with a simple message format:
- **🧑 Found Person**: `S:🧑:37.7749,-122.4194:Survivor located, needs medical attention`
- **🔥 Fire Location**: `S:🔥:40.7128,-74.0060:Wildfire spreading rapidly northeast`
- **🏕️ Staging Area**: `S:🏕️:34.0522,-118.2437:Base camp established with supplies`
These special messages automatically appear as markers on the map, making critical information instantly visual for the entire team.
**📊 Message Status Tracking**
Every message shows its delivery status:
-**Sending** - Message is being transmitted
-**Sent** - Message queued with expected acknowledgment
- ✔️✔️ **Delivered** - Confirmation received with round-trip time
- 🔄 **Retrying** - Automatic retry in progress
-**Failed** - Delivery unsuccessful after all attempts
**🌍 Multilingual Support**
Full localization in English, Croatian (Hrvatski), and Slovenian (Slovenščina) ensures teams can communicate in their native language.
---
## 👥 Contacts: Know Your Team's Status and Location
[*Image placeholder: Contacts list showing team members with GPS locations and battery levels*]
MeshCore SAR's contact system goes beyond simple names and numbers—it provides real-time situational awareness for your entire team.
### Contact Intelligence:
**📍 Real-Time Location Tracking**
- GPS coordinates automatically broadcast at configurable intervals
- Location history tracking (last 100 positions per contact)
- Distance and bearing calculations from your position
- "Last seen" timestamps for situational awareness
**🔋 Battery Monitoring**
- Battery percentage displayed for each team member
- Voltage telemetry via Cayenne LPP format
- Early warning when team members need to conserve power
**🛤️ Mesh Network Routing**
The app shows routing information for each contact:
- **Direct (0 hops)**: Connected directly to your radio
- **Good path (1-2 hops)**: Reliable routing through 1-2 intermediate devices
- **Medium/Long path (3-5+ hops)**: Extended range through multiple hops
- **No path (flood mode)**: Messages broadcast to entire network
**👔 Role-Based Identification**
- Add emoji prefixes to names (🧑🏻‍🚒 for firefighter, 👮 for police, 🏥 for medical)
- Instant visual identification on map and in contact lists
- Customizable display names
**📡 Contact Types**
- **Chat** (Team Members): Standard team members shown on map
- **Repeater**: Network infrastructure nodes that extend range
- **Room**: Message servers with persistent storage and login capabilities
**📞 Contact Sharing**
- Export contacts as business cards
- Share contacts directly over the mesh network
- Import contacts from other team members
---
## 🗺️ Map: Offline Navigation and Tactical Awareness
[*Image placeholder: Map screen showing team members, SAR markers, and offline terrain*]
The map is where everything comes together—combining team locations, SAR events, and offline navigation into a single, comprehensive tactical display.
### Map Features:
**🗺️ Offline Vector Maps**
- **MBTiles format**: Lightweight vector maps that work completely offline
- **Multiple layers**: Street maps (OpenStreetMap), topographic (OpenTopoMap), satellite imagery (ESRI)
- **High zoom levels**: Street-level detail up to zoom level 19
- **Smart caching**: Tiles cached locally for 30 days
**📍 Team Member Tracking**
Each team member appears on the map with:
- Blue circle markers with role emoji
- Battery level badge (green/yellow/red indicators)
- Distance from your location
- Tap to view detailed information and message directly
**🚨 SAR Event Markers**
Critical events appear as color-coded markers:
- 🟢 **Green** - Found Person
- 🔴 **Red** - Fire Location
- 🔵 **Blue** - Staging Area
- 🟣 **Purple** - Object
- Time elapsed since report
- Tap to navigate and view details
**✏️ Map Drawing Tools**
Collaborative tactical planning:
- **Line drawings**: Sketch routes, boundaries, or directions
- **Rectangle areas**: Mark zones, perimeters, or sectors
- **8 color palette**: Red, blue, green, yellow, orange, purple, pink, cyan
- **Share drawings**: Send to channel or specific rooms via mesh network
- **Collaborative editing**: All team members see drawings in real-time
- **Ultra-compact format**: Efficient JSON encoding reduces bandwidth usage by 37%
**🧭 Detailed Compass Dialog**
Ultra-compact location display:
- Current GPS coordinates
- Toggle between Decimal Degrees (DD) and Degrees/Minutes/Seconds (DMS)
- Tap outside to close (no buttons needed)
- Perfect for quick location checks
**📡 User Location Tracking**
- Blue pulsing circle shows your current position
- Navigation icon for direction
- Tap to center and track your movement
- Configurable accuracy settings
**📊 Map Legend**
Collapsible legend in top-right corner:
- Team member count
- SAR marker count by type
- Quick reference for marker colors
**🎯 Smart Navigation**
- Tap any SAR marker in messages to navigate on map
- Automatic tab switching
- Map centers and zooms to selected location
- Clears navigation after viewing
---
## 🔧 Technical Innovation
### Mesh Network Protocol
- **MeshCore Protocol**: Open-source, battle-tested mesh networking
- **LoRa Radio**: Long-range, low-power wireless technology
- **BLE Connection**: Smartphone connects to companion radio via Bluetooth
- **Intelligent routing**: Self-healing paths through the network
- **Flood mode fallback**: Guaranteed delivery for critical messages
### Smart Features
- **Adaptive location broadcasting**: Only sends updates when you move significantly
- **Progressive retry logic**: Failed messages retry with increasing timeouts
- **Cayenne LPP telemetry**: Standardized sensor data format
- **Contact synchronization**: Automatic contact list updates
- **Message persistence**: Rooms store messages for later retrieval
### Built for Reliability
- **Provider-based architecture**: Efficient state management
- **Automatic reconnection**: Handles radio disconnections gracefully
- **Message queue management**: Synchronizes messages in order
- **Battery optimization**: Configurable tracking intervals
- **Memory management**: Automatic history cleanup
---
## 🌟 Real-World Applications
**Search & Rescue Operations**
- Wilderness rescue coordination
- Missing person searches
- Cave rescue operations
- Mountain rescue teams
**Disaster Response**
- Hurricane and flood response
- Earthquake emergency communication
- Infrastructure failure scenarios
- Mass casualty incidents
**Remote Operations**
- Forestry operations
- Mining site communication
- Border patrol and security
- Wildlife management
**Training & Exercises**
- Team coordination drills
- Radio discipline training
- Navigation exercises
- Emergency preparedness
---
## 🚀 Getting Started
MeshCore SAR works with affordable LoRa companion radios that connect to your smartphone via Bluetooth. The app handles all the complexity—you just:
1. **Connect** your radio via Bluetooth
2. **Add contacts** to your team
3. **Start messaging** and tracking locations
4. **Download maps** for your area
5. **Coordinate** your mission
No cellular service. No internet. No limits.
---
## 🌍 Open Source & Community-Driven
MeshCore SAR is built on the open-source MeshCore protocol, fostering a community of developers and users who contribute to its continuous improvement. Whether you're a first responder, amateur radio enthusiast, or outdoor adventurer, you're part of a global network working to keep people connected when it matters most.
---
## 💡 The Future of Off-Grid Communication
In a world increasingly dependent on fragile infrastructure, MeshCore SAR represents a paradigm shift: resilient, decentralized communication that works when everything else fails. As climate change drives more frequent disasters and teams operate in increasingly remote locations, mesh networking isn't just an alternative—it's essential.
**MeshCore SAR isn't just an app. It's a lifeline.**
---
*MeshCore SAR is compatible with iOS 13+ and Android API 21+. Requires compatible LoRa companion radio hardware.*
**Repository**: [github.com/meshcore-dev/meshcore.js](https://github.com/meshcore-dev/meshcore.js)
---
## 📸 Screenshots
*[Placeholder sections for images]*
### Messages
*Screenshot showing conversation with delivery status, SAR marker messages, and message options*
### Contacts
*Screenshot showing contact list with GPS locations, battery levels, and routing information*
### Map - Team View
*Screenshot showing map with multiple team members, distance indicators, and user location*
### Map - SAR Markers
*Screenshot showing map with various SAR markers (person, fire, staging area) and color coding*
### Map - Drawing Tools
*Screenshot showing map with line and rectangle drawings, color palette, and toolbar*
### Map - Offline Layers
*Screenshot showing different map layers (street, topographic, satellite) selection*
### Settings
*Screenshot showing connection status, radio parameters, and location tracking settings*
### Contact Details
*Screenshot showing detailed contact information, telemetry data, and message history*
---
**Ready to experience off-grid communication?** Connect your radio and join the mesh network today.

367
SCREENSHOTS.md Normal file
View File

@@ -0,0 +1,367 @@
# Screenshot Automation Guide
Comprehensive guide for capturing App Store screenshots for MeshCore SAR app using Flutter integration tests.
## Overview
This project includes automated screenshot capture for:
- **App Store submission** (iOS + Android)
- **Documentation and training materials**
- **Marketing assets**
- **Multiple devices and screen sizes**
- **Multiple locales** (English, Croatian, Slovenian)
## Quick Start
### Prerequisites
1. **Flutter SDK** installed and configured
2. **iOS**: Xcode with simulators installed
3. **Android**: Android Studio with emulators configured
4. **Dependencies installed**:
```bash
flutter pub get
```
### Take Screenshots (All Devices)
```bash
./scripts/take_screenshots.sh
```
Screenshots will be saved to `screenshots/` directory.
## Detailed Usage
### Command Line Options
```bash
# All devices (iOS + Android)
./scripts/take_screenshots.sh
# iOS devices only
./scripts/take_screenshots.sh --ios
# Android devices only
./scripts/take_screenshots.sh --android
# Specific device
./scripts/take_screenshots.sh --device "iPhone 15 Pro Max"
# List available devices
./scripts/take_screenshots.sh --list
# Show help
./scripts/take_screenshots.sh --help
```
### Manual Test Execution
You can also run the integration test manually:
```bash
# iOS
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/app_screenshots_test.dart \
-d "iPhone 15 Pro Max"
# Android (start emulator first)
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/app_screenshots_test.dart \
-d emulator-5554
```
## Screenshot Coverage
The automated test captures the following screens:
1. **Home Screen (Disconnected)** - Initial state showing connect button
2. **Messages List** - Messages with SAR markers displayed
3. **SAR Marker Detail** - Detailed view of a SAR event
4. **Contacts List** - Team members and repeaters
5. **Contact Detail** - Individual contact information
6. **Map View** - Map with team markers and SAR markers
7. **Map Legend** - Legend showing marker types
8. **Settings Screen** - App settings and preferences
## Device Configurations
### iOS Devices (App Store Requirements)
The script is configured for App Store screenshot requirements:
| Device | Screen Size | Resolution | Required for App Store |
|--------|-------------|------------|----------------------|
| iPhone 15 Pro Max | 6.7" | 1290x2796 | ✅ Yes (primary) |
| iPhone 14 Pro Max | 6.7" | 1290x2796 | ✅ Yes (backup) |
| iPhone 8 Plus | 5.5" | 1242x2208 | ✅ Yes (smaller size) |
**App Store Notes:**
- 6.7" display is **required** as of 2024
- 5.5" display provides compatibility with older devices
- Screenshots must be in PNG or JPEG format
- Maximum 10 screenshots per device size
### Android Devices (Google Play Requirements)
| Device | Type | Resolution | Required for Play Store |
|--------|------|------------|------------------------|
| Pixel 7 Pro | Phone | 1440x3120 | ✅ Recommended |
| Pixel Tablet | Tablet | 2560x1600 | ✅ Recommended |
**Google Play Notes:**
- Phone screenshots: 16:9 or 9:16 ratio recommended
- Tablet screenshots: Optional but recommended
- Minimum 2 screenshots, maximum 8 per device type
- PNG or JPEG format accepted
## Project Structure
```
meshcore_sar_app/
├── integration_test/
│ ├── app_screenshots_test.dart # Main screenshot test
│ └── helpers/
│ ├── mock_data.dart # Mock contacts, messages, markers
│ └── screenshot_helper.dart # Screenshot utilities
├── test_driver/
│ └── integration_test.dart # Integration test driver
├── scripts/
│ └── take_screenshots.sh # Automated screenshot script
└── screenshots/ # Output directory
├── ios/
│ ├── iPhone_15_Pro_Max/
│ ├── iPhone_14_Pro_Max/
│ └── iPhone_8_Plus/
└── android/
├── pixel_7_pro/
└── pixel_tablet/
```
## Mock Data
The test uses predictable mock data for consistent screenshots:
### Contacts (6 total)
- **Alpha Team Lead** - Battery: 3850mV, 1 hop, -45 dBm
- **Bravo Scout** - Battery: 3700mV, 2 hops, -68 dBm
- **Charlie Base** - Battery: 4100mV, 0 hops, -35 dBm
- **Delta Medic** - Battery: 3600mV, 3 hops, -75 dBm
- **Mountain Repeater 1** - Repeater type
- **SAR Command Room** - Room type
### Messages (8 total)
- Team communications
- SAR marker messages
- Public channel broadcasts
### SAR Markers (3 total)
- 🧑 **Found Person** at 46.0589, 14.5078 (Bravo Scout)
- 🏕️ **Staging Area** at 46.0549, 14.5038 (Charlie Base)
- 🔥 **Fire Location** at 46.0620, 14.5120 (Alpha Team Lead)
All mock data is defined in `integration_test/helpers/mock_data.dart`.
## Customization
### Adding More Screens
Edit `integration_test/app_screenshots_test.dart`:
```dart
// Navigate to your screen
await tester.tapAndSettle(find.text('Your Screen'));
// Take screenshot
await screenshotHelper.takeScreenshot(
tester,
'your_screen_name',
);
```
### Changing Mock Data
Edit `integration_test/helpers/mock_data.dart`:
```dart
static List<Contact> getMockContacts() {
return [
Contact(
publicKey: '0x...',
name: 'Your Contact Name',
// ... more fields
),
];
}
```
### Adding Devices
Edit `scripts/take_screenshots.sh`:
```bash
# iOS
IOS_DEVICES=(
"iPhone 15 Pro Max"
"Your Device Name"
)
# Android
ANDROID_DEVICES=(
"pixel_7_pro"
"your_emulator_name"
)
```
## Localization
To capture screenshots in different languages:
1. **Set system language** on simulator/emulator
2. **Run the screenshot script**
3. **Organize by locale** in output directory
Example for Croatian screenshots:
```bash
# 1. Set iOS simulator to Croatian
xcrun simctl spawn booted defaults write "Apple Global Domain" AppleLanguages -array hr
# 2. Run screenshots
./scripts/take_screenshots.sh --ios
# 3. Move to locale-specific folder
mkdir -p screenshots/ios/hr-HR
mv screenshots/ios/iPhone_15_Pro_Max/* screenshots/ios/hr-HR/
```
For automation, you can modify the script to handle locale switching.
## Troubleshooting
### Simulator Not Found
```bash
# List available simulators
xcrun simctl list devices available
# Create new simulator
xcrun simctl create "iPhone 15 Pro Max" "iPhone 15 Pro Max"
```
### Emulator Issues
```bash
# List available emulators
emulator -list-avds
# Create new emulator (use Android Studio AVD Manager)
# Or via command line:
avdmanager create avd -n pixel_7_pro -k "system-images;android-33;google_apis;x86_64"
```
### Screenshots Not Appearing
1. Check test output for errors
2. Verify `integration_test/app_screenshots_test.dart` runs successfully
3. Check `ScreenshotHelper` is calling `binding.takeScreenshot()`
4. Ensure output directory has write permissions
### Test Times Out
```bash
# Increase timeout in test
await tester.pumpAndSettle(const Duration(seconds: 10));
# Or modify flutter drive timeout
flutter drive --timeout=120s ...
```
### BLE/Permissions Errors in Tests
Integration tests run in a sandboxed environment. The test uses **mock data** instead of real BLE connections, so:
- ✅ No actual BLE device needed
- ✅ No location permissions required
- ✅ Predictable, repeatable screenshots
- ❌ Cannot test actual BLE connectivity (use manual testing for that)
## Best Practices
### For App Store Screenshots
1. **Use the largest device first** (iPhone 15 Pro Max, Pixel 7 Pro)
2. **Highlight key features** in each screenshot
3. **Add localization** for target markets
4. **Keep consistent ordering** across all devices
5. **Review before submission** - ensure no sensitive data visible
### For Quality Screenshots
1. **Clean state** - Use mock data for predictable content
2. **Good lighting** - Ensure sufficient contrast in UI
3. **Meaningful content** - Show realistic usage scenarios
4. **No debug info** - Disable debug banners/overlays
5. **Proper timing** - Wait for animations to complete
### File Organization
Recommended structure for App Store submission:
```
screenshots/
├── en-US/ # English (default)
│ ├── 6.7-inch/ # iPhone 15 Pro Max
│ │ ├── 01_home.png
│ │ ├── 02_messages.png
│ │ └── ...
│ ├── 5.5-inch/ # iPhone 8 Plus
│ └── android-phone/ # Pixel 7 Pro
├── hr-HR/ # Croatian
│ └── ...
└── sl-SI/ # Slovenian
└── ...
```
## Advanced: CI/CD Integration
For automated screenshot generation in CI/CD pipelines:
```yaml
# .github/workflows/screenshots.yml
name: Generate Screenshots
on:
workflow_dispatch: # Manual trigger
jobs:
screenshots:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
- name: Install dependencies
run: flutter pub get
- name: Take screenshots
run: ./scripts/take_screenshots.sh --ios
- name: Upload screenshots
uses: actions/upload-artifact@v3
with:
name: screenshots
path: screenshots/
```
## Resources
- [Flutter Integration Testing](https://docs.flutter.dev/testing/integration-tests)
- [App Store Screenshot Requirements](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/)
- [Google Play Screenshot Requirements](https://support.google.com/googleplay/android-developer/answer/9866151)
- [MeshCore SAR Documentation](./CLAUDE.md)
## Support
For issues or questions:
1. Check the [Troubleshooting](#troubleshooting) section
2. Review Flutter integration test docs
3. Open an issue in the project repository

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:provider/provider.dart';
import 'package:meshcore_sar_app/main.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'helpers/screenshot_helper.dart';
import 'helpers/mock_data.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('App Screenshots', () {
late ScreenshotHelper screenshotHelper;
testWidgets('Capture all app screens with mock data', (tester) async {
// Initialize screenshot helper
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
screenshotHelper = ScreenshotHelper(binding);
// Pump the app
await tester.pumpWidget(const MeshCoreSarApp());
await tester.pumpAndSettle();
// ===================================================================
// 1. DISCONNECTED STATE - Home Screen
// ===================================================================
await screenshotHelper.takeScreenshot(
tester,
'home_disconnected',
wait: const Duration(seconds: 1),
);
// ===================================================================
// 2. SIMULATED CONNECTED STATE (inject mock data into providers)
// ===================================================================
// Note: Since we can't actually connect to a BLE device in tests,
// we'll need to manually inject mock data into the providers.
// This requires accessing the providers through the context.
final context = tester.element(find.byType(MaterialApp));
final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
// Inject mock contacts
final mockContacts = MockData.getMockContacts();
for (final contact in mockContacts) {
contactsProvider.addOrUpdateContact(contact);
}
// Inject mock messages
final mockMessages = MockData.getMockMessages();
for (final message in mockMessages) {
messagesProvider.receiveMessage(message);
}
// Inject mock SAR markers
final mockSarMarkers = MockData.getMockSarMarkers();
for (final marker in mockSarMarkers) {
messagesProvider.addSarMarker(marker);
}
await tester.pumpAndSettle();
// ===================================================================
// 3. MESSAGES TAB WITH DATA
// ===================================================================
// The app should default to Messages tab (index 0)
await screenshotHelper.takeScreenshot(
tester,
'messages_list_with_sar_markers',
);
// Try to find and tap a SAR marker to show detail
final sarMarkerFinder = find.byWidgetPredicate(
(widget) => widget.runtimeType.toString().contains('SarMarker'),
);
if (sarMarkerFinder.hasFound) {
await tester.tapAndSettle(sarMarkerFinder.first);
await screenshotHelper.takeScreenshot(
tester,
'messages_sar_marker_detail',
);
// Go back
await tester.pageBack();
await tester.pumpAndSettle();
}
// ===================================================================
// 4. CONTACTS TAB
// ===================================================================
// Find and tap the Contacts tab
final contactsTab = find.text('Contacts');
if (contactsTab.hasFound) {
await tester.tapAndSettle(contactsTab);
} else {
// Try icon-based navigation
final tabBar = find.byType(TabBar);
if (tabBar.hasFound) {
final contactsIcon = find.descendant(
of: tabBar,
matching: find.byIcon(Icons.contacts),
);
await tester.tapAndSettle(contactsIcon);
}
}
await screenshotHelper.takeScreenshot(
tester,
'contacts_list_with_teams',
);
// Try to find and tap a contact to show detail
final contactListTile = find.byType(ListTile).first;
if (contactListTile.hasFound) {
await tester.tapAndSettle(contactListTile);
await screenshotHelper.takeScreenshot(
tester,
'contacts_detail_dialog',
);
// Close dialog (tap outside or back button)
await tester.pageBack();
await tester.pumpAndSettle();
}
// ===================================================================
// 5. MAP TAB
// ===================================================================
// Find and tap the Map tab
final mapTab = find.text('Map');
if (mapTab.hasFound) {
await tester.tapAndSettle(mapTab);
} else {
// Try icon-based navigation
final tabBar = find.byType(TabBar);
if (tabBar.hasFound) {
final mapIcon = find.descendant(
of: tabBar,
matching: find.byIcon(Icons.map),
);
await tester.tapAndSettle(mapIcon);
}
}
// Wait for map to load
await tester.pumpAndSettle(const Duration(seconds: 2));
await screenshotHelper.takeScreenshot(
tester,
'map_with_markers_and_sar',
);
// Try to find and open the map legend
final legendFinder = find.byWidgetPredicate(
(widget) => widget.runtimeType.toString().contains('Legend'),
);
if (legendFinder.hasFound) {
await tester.tapAndSettle(legendFinder.first);
await screenshotHelper.takeScreenshot(
tester,
'map_legend_expanded',
);
}
// ===================================================================
// 6. SETTINGS SCREEN (via menu)
// ===================================================================
// Go back to Messages tab
final messagesTab = find.text('Messages');
if (messagesTab.hasFound) {
await tester.tapAndSettle(messagesTab);
}
// Find and tap the menu button
final menuButton = find.byIcon(Icons.more_vert);
if (menuButton.hasFound) {
await tester.tapAndSettle(menuButton);
// Find and tap Settings in the popup menu
final settingsMenuItem = find.text('Settings');
if (settingsMenuItem.hasFound) {
await tester.tapAndSettle(settingsMenuItem);
await tester.pumpAndSettle(const Duration(seconds: 1));
await screenshotHelper.takeScreenshot(
tester,
'settings_screen',
);
// Go back
await tester.pageBack();
await tester.pumpAndSettle();
}
}
// ===================================================================
// 7. SIMULATED DEVICE CONNECTION DIALOG
// ===================================================================
// NOTE: This is challenging without actual BLE, but we can try to
// trigger the connection dialog
// For now, we'll skip this as it requires disconnecting first
// ===================================================================
// SUMMARY
// ===================================================================
print('\n✅ Screenshot capture complete!');
print('📸 Total screenshots taken: ${screenshotHelper.screenshotCount}');
print('\nScreenshots are saved in the default integration test output directory.');
print('To view them, check your flutter drive output folder.\n');
});
});
}

View File

@@ -0,0 +1,230 @@
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/sar_marker.dart';
/// Mock data for integration tests and screenshots
class MockData {
/// Generate mock contacts with predictable data
static List<Contact> getMockContacts() {
return [
Contact(
publicKey: '0x1111111111111111111111111111111111111111111111111111111111111111',
name: 'Alpha Team Lead',
contactType: ContactType.chat,
lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 2)),
lastLocation: const ContactLocation(
latitude: 46.0569,
longitude: 14.5058,
altitude: 295.0,
),
batteryMillivolts: 3850,
hopCount: 1,
rssi: -45,
),
Contact(
publicKey: '0x2222222222222222222222222222222222222222222222222222222222222222',
name: 'Bravo Scout',
contactType: ContactType.chat,
lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 5)),
lastLocation: const ContactLocation(
latitude: 46.0589,
longitude: 14.5078,
altitude: 310.0,
),
batteryMillivolts: 3700,
hopCount: 2,
rssi: -68,
),
Contact(
publicKey: '0x3333333333333333333333333333333333333333333333333333333333333333',
name: 'Charlie Base',
contactType: ContactType.chat,
lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 1)),
lastLocation: const ContactLocation(
latitude: 46.0549,
longitude: 14.5038,
altitude: 285.0,
),
batteryMillivolts: 4100,
hopCount: 0,
rssi: -35,
),
Contact(
publicKey: '0x4444444444444444444444444444444444444444444444444444444444444444',
name: 'Delta Medic',
contactType: ContactType.chat,
lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 8)),
lastLocation: const ContactLocation(
latitude: 46.0609,
longitude: 14.5098,
altitude: 320.0,
),
batteryMillivolts: 3600,
hopCount: 3,
rssi: -75,
),
Contact(
publicKey: '0x5555555555555555555555555555555555555555555555555555555555555555',
name: 'Mountain Repeater 1',
contactType: ContactType.repeater,
lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 1)),
lastLocation: const ContactLocation(
latitude: 46.0650,
longitude: 14.5150,
altitude: 450.0,
),
batteryMillivolts: 4150,
hopCount: 0,
rssi: -40,
),
Contact(
publicKey: '0x6666666666666666666666666666666666666666666666666666666666666666',
name: 'SAR Command Room',
contactType: ContactType.room,
lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 30)),
hopCount: 1,
rssi: -50,
),
];
}
/// Generate mock messages with SAR markers
static List<Message> getMockMessages() {
final now = DateTime.now();
return [
Message(
id: 'msg1',
sender: '0x1111111111111111111111111111111111111111111111111111111111111111',
senderName: 'Alpha Team Lead',
content: 'Team Alpha in position, beginning sweep of sector 3',
timestamp: now.subtract(const Duration(minutes: 15)),
isSent: false,
isPublicChannel: false,
),
Message(
id: 'msg2',
sender: '0x2222222222222222222222222222222222222222222222222222222222222222',
senderName: 'Bravo Scout',
content: 'S:🧑:46.0589,14.5078:Found injured hiker near trail marker 7',
timestamp: now.subtract(const Duration(minutes: 12)),
isSent: false,
isPublicChannel: false,
),
Message(
id: 'msg3',
sender: 'self',
senderName: 'You',
content: 'Copy that Bravo, sending medic to your location',
timestamp: now.subtract(const Duration(minutes: 11)),
isSent: true,
isPublicChannel: false,
),
Message(
id: 'msg4',
sender: '0x4444444444444444444444444444444444444444444444444444444444444444',
senderName: 'Delta Medic',
content: 'En route to Bravo position, ETA 5 minutes',
timestamp: now.subtract(const Duration(minutes: 10)),
isSent: false,
isPublicChannel: false,
),
Message(
id: 'msg5',
sender: '0x3333333333333333333333333333333333333333333333333333333333333333',
senderName: 'Charlie Base',
content: 'S:🏕️:46.0549,14.5038:Staging area established, supplies available',
timestamp: now.subtract(const Duration(minutes: 8)),
isSent: false,
isPublicChannel: false,
),
Message(
id: 'msg6',
sender: '0x1111111111111111111111111111111111111111111111111111111111111111',
senderName: 'Alpha Team Lead',
content: 'S:🔥:46.0620,14.5120:Small campfire spotted in sector 4, monitoring',
timestamp: now.subtract(const Duration(minutes: 5)),
isSent: false,
isPublicChannel: false,
),
Message(
id: 'msg7',
sender: '0x2222222222222222222222222222222222222222222222222222222222222222',
senderName: 'Bravo Scout',
content: 'Patient stabilized, waiting for extraction',
timestamp: now.subtract(const Duration(minutes: 3)),
isSent: false,
isPublicChannel: false,
),
Message(
id: 'msg8',
sender: 'self',
senderName: 'You',
content: 'All teams: Weather window closing in 2 hours, prepare to RTB',
timestamp: now.subtract(const Duration(minutes: 1)),
isSent: true,
isPublicChannel: true,
),
];
}
/// Generate mock SAR markers from messages
static List<SarMarker> getMockSarMarkers() {
final now = DateTime.now();
return [
SarMarker(
id: 'sar1',
type: SarMarkerType.foundPerson,
latitude: 46.0589,
longitude: 14.5078,
message: 'Found injured hiker near trail marker 7',
timestamp: now.subtract(const Duration(minutes: 12)),
sender: '0x2222222222222222222222222222222222222222222222222222222222222222',
senderName: 'Bravo Scout',
),
SarMarker(
id: 'sar2',
type: SarMarkerType.stagingArea,
latitude: 46.0549,
longitude: 14.5038,
message: 'Staging area established, supplies available',
timestamp: now.subtract(const Duration(minutes: 8)),
sender: '0x3333333333333333333333333333333333333333333333333333333333333333',
senderName: 'Charlie Base',
),
SarMarker(
id: 'sar3',
type: SarMarkerType.fireLocation,
latitude: 46.0620,
longitude: 14.5120,
message: 'Small campfire spotted in sector 4, monitoring',
timestamp: now.subtract(const Duration(minutes: 5)),
sender: '0x1111111111111111111111111111111111111111111111111111111111111111',
senderName: 'Alpha Team Lead',
),
];
}
/// Mock device info for connection status
static Map<String, dynamic> getMockDeviceInfo() {
return {
'deviceName': 'MeshCore-SAR-DEMO',
'firmwareVersion': '2.1.0',
'hardwareVersion': 'v3',
'publicKey': '0xAABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899',
'batteryMillivolts': 3950,
'storageUsed': 1024 * 512, // 512 KB
'storageTotal': 1024 * 1024 * 4, // 4 MB
};
}
/// Mock radio parameters
static Map<String, dynamic> getMockRadioParams() {
return {
'frequency': 915.0,
'bandwidth': 125.0,
'spreadingFactor': 9,
'codingRate': 7,
'txPower': 20,
};
}
}

View File

@@ -0,0 +1,116 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// Helper class for taking screenshots during integration tests
class ScreenshotHelper {
final IntegrationTestWidgetsFlutterBinding binding;
final String outputDir;
int _screenshotCounter = 0;
ScreenshotHelper(this.binding, {this.outputDir = 'screenshots'});
/// Take a screenshot with automatic numbering and description
Future<void> takeScreenshot(
WidgetTester tester,
String description, {
Duration? wait,
}) async {
// Wait for UI to settle
await tester.pumpAndSettle(wait ?? const Duration(milliseconds: 500));
// Add extra delay for animations
await Future.delayed(const Duration(milliseconds: 300));
// Increment counter
_screenshotCounter++;
// Format filename: 01_description.png
final filename =
'${_screenshotCounter.toString().padLeft(2, '0')}_${_sanitizeFilename(description)}.png';
if (kDebugMode) {
print('📸 Taking screenshot: $filename');
}
// Take screenshot
await binding.takeScreenshot(filename);
}
/// Sanitize filename by removing special characters
String _sanitizeFilename(String input) {
return input
.toLowerCase()
.replaceAll(RegExp(r'[^\w\s-]'), '')
.replaceAll(RegExp(r'[\s_]+'), '_')
.replaceAll(RegExp(r'^-+|-+$'), '');
}
/// Reset counter (useful for multiple test runs)
void resetCounter() {
_screenshotCounter = 0;
}
/// Get current screenshot count
int get screenshotCount => _screenshotCounter;
/// Create output directory if it doesn't exist
static Future<void> ensureOutputDir(String path) async {
final dir = Directory(path);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
}
}
/// Extension methods for easier screenshot taking
extension ScreenshotTestExtension on WidgetTester {
/// Wait for a specific widget to appear
Future<void> waitFor(
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await pump();
if (finder.evaluate().isNotEmpty) {
return;
}
await Future.delayed(const Duration(milliseconds: 100));
}
throw Exception('Widget not found: $finder');
}
/// Tap and wait for navigation
Future<void> tapAndSettle(Finder finder, {Duration? settleDuration}) async {
await tap(finder);
await pumpAndSettle(settleDuration ?? const Duration(milliseconds: 500));
}
/// Scroll until widget is visible
Future<void> scrollUntilVisible(
Finder finder,
Finder scrollable, {
double delta = 100,
int maxScrolls = 50,
}) async {
int scrollCount = 0;
while (finder.evaluate().isEmpty && scrollCount < maxScrolls) {
await drag(scrollable, Offset(0, -delta));
await pump(const Duration(milliseconds: 100));
scrollCount++;
}
if (finder.evaluate().isEmpty) {
throw Exception('Could not scroll to widget: $finder');
}
}
/// Fill text field and dismiss keyboard
Future<void> enterTextAndDismiss(Finder finder, String text) async {
await enterText(finder, text);
await pump();
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle();
}
}

View File

@@ -48,6 +48,8 @@ PODS:
- geolocator_apple (1.2.0): - geolocator_apple (1.2.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- integration_test (0.0.1):
- Flutter
- ObjectBox (4.4.1) - ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1): - objectbox_flutter_libs (0.0.1):
- Flutter - Flutter
@@ -80,6 +82,7 @@ DEPENDENCIES:
- flutter_compass (from `.symlinks/plugins/flutter_compass/ios`) - flutter_compass (from `.symlinks/plugins/flutter_compass/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
@@ -113,6 +116,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios" :path: ".symlinks/plugins/flutter_local_notifications/ios"
geolocator_apple: geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin" :path: ".symlinks/plugins/geolocator_apple/darwin"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
objectbox_flutter_libs: objectbox_flutter_libs:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios" :path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus: package_info_plus:
@@ -139,6 +144,7 @@ SPEC CHECKSUMS:
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499

Binary file not shown.

Binary file not shown.

View File

@@ -278,10 +278,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -333,10 +337,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -489,7 +497,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +519,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -530,7 +538,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -547,7 +555,7 @@
buildSettings = { buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@@ -679,7 +687,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +710,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -12,6 +12,22 @@
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>MeshCore SAR</string> <string>MeshCore SAR</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>MBTiles Map File</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>public.database</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -27,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>24</string> <string>27</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
@@ -49,10 +65,10 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>location</string>
<string>fetch</string> <string>bluetooth-central</string>
<string>processing</string> <string>processing</string>
<string>external-accessory</string> <string>external-accessory</string>
<string>bluetooth-central</string> <string>fetch</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
@@ -71,6 +87,8 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIUserNotificationSettings</key> <key>UIUserNotificationSettings</key>
<dict> <dict>
<key>UIUserNotificationTypesEnabled</key> <key>UIUserNotificationTypesEnabled</key>
@@ -80,36 +98,18 @@
<string>UIUserNotificationTypeSound</string> <string>UIUserNotificationTypeSound</string>
</array> </array>
</dict> </dict>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>MBTiles Map File</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>public.database</string>
<string>public.data</string>
</array>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
</dict>
</array>
<key>UTImportedTypeDeclarations</key> <key>UTImportedTypeDeclarations</key>
<array> <array>
<dict> <dict>
<key>UTTypeIdentifier</key>
<string>com.mapbox.mbtiles</string>
<key>UTTypeDescription</key>
<string>MBTiles Map Archive</string>
<key>UTTypeConformsTo</key> <key>UTTypeConformsTo</key>
<array> <array>
<string>public.database</string> <string>public.database</string>
<string>public.data</string> <string>public.data</string>
</array> </array>
<key>UTTypeDescription</key>
<string>MBTiles Map Archive</string>
<key>UTTypeIdentifier</key>
<string>com.mapbox.mbtiles</string>
<key>UTTypeTagSpecification</key> <key>UTTypeTagSpecification</key>
<dict> <dict>
<key>public.filename-extension</key> <key>public.filename-extension</key>

View File

@@ -5,19 +5,24 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000236"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000186">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.26262"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.464562">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="38.833639"> <testcase classname="fastlane.lanes" name="2: build_app" time="126.069776">
<failure message="/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:22:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Error building the application - see the log above" /> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_testflight" time="29032.321843">
<failure message="/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:23:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/fastlane/lib/fastlane/cli_tools_distributor.rb:123:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/gems/fastlane-2.228.0/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/fastlane/2.228.0/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;undefined method &apos;refresh!&apos; for nil" />
</testcase> </testcase>

View File

@@ -64,6 +64,11 @@ class Message {
final DateTime? deliveredAt; // When delivery was confirmed final DateTime? deliveredAt; // When delivery was confirmed
final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry) final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry)
// Retry tracking (for automatic retry with progressive timeouts)
final int retryAttempt; // Current retry attempt (0-3), 0 = first send
final DateTime? lastRetryAt; // When last retry was sent
final bool usedFloodFallback; // Whether message fell back to flood mode after retries
// Read status tracking // Read status tracking
final bool isRead; // Whether message has been read by user final bool isRead; // Whether message has been read by user
@@ -88,6 +93,9 @@ class Message {
this.roundTripTimeMs, this.roundTripTimeMs,
this.deliveredAt, this.deliveredAt,
this.recipientPublicKey, this.recipientPublicKey,
this.retryAttempt = 0,
this.lastRetryAt,
this.usedFloodFallback = false,
this.isRead = false, this.isRead = false,
}); });
@@ -172,16 +180,38 @@ class Message {
String get deliveryStatusText { String get deliveryStatusText {
switch (deliveryStatus) { switch (deliveryStatus) {
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
if (retryAttempt > 0) {
return 'Retrying ($retryAttempt/3)...';
}
return 'Sending...'; return 'Sending...';
case MessageDeliveryStatus.sent: case MessageDeliveryStatus.sent:
if (retryAttempt > 0) {
return 'Sent (retry $retryAttempt)';
}
return 'Sent'; return 'Sent';
case MessageDeliveryStatus.delivered: case MessageDeliveryStatus.delivered:
if (roundTripTimeMs != null) { final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : '';
return 'Delivered (${roundTripTimeMs}ms)'; if (retryAttempt > 0 && rttText.isNotEmpty) {
return 'Delivered ($rttText) [retry $retryAttempt]';
} else if (retryAttempt > 0) {
return 'Delivered [retry $retryAttempt]';
} else if (rttText.isNotEmpty) {
return 'Delivered ($rttText)';
} }
return 'Delivered'; return 'Delivered';
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
if (usedFloodFallback) {
return 'Failed (tried flood)';
}
if (retryAttempt > 0) {
final retryWord = retryAttempt == 1 ? 'retry' : 'retries';
return 'Failed (after $retryAttempt $retryWord)';
}
return 'Failed'; return 'Failed';
case MessageDeliveryStatus.received: case MessageDeliveryStatus.received:
return ''; return '';
} }
@@ -229,6 +259,9 @@ class Message {
int? roundTripTimeMs, int? roundTripTimeMs,
DateTime? deliveredAt, DateTime? deliveredAt,
Uint8List? recipientPublicKey, Uint8List? recipientPublicKey,
int? retryAttempt,
DateTime? lastRetryAt,
bool? usedFloodFallback,
bool? isRead, bool? isRead,
}) { }) {
return Message( return Message(
@@ -252,6 +285,9 @@ class Message {
roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs, roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs,
deliveredAt: deliveredAt ?? this.deliveredAt, deliveredAt: deliveredAt ?? this.deliveredAt,
recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey, recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey,
retryAttempt: retryAttempt ?? this.retryAttempt,
lastRetryAt: lastRetryAt ?? this.lastRetryAt,
usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback,
isRead: isRead ?? this.isRead, isRead: isRead ?? this.isRead,
); );
} }

View File

@@ -198,6 +198,23 @@ class AppProvider with ChangeNotifier {
debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms'); debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms');
messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs); messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs);
}; };
// Wire up MessagesProvider's sendMessageCallback for retry logic
messagesProvider.sendMessageCallback = ({
required contactPublicKey,
required text,
required messageId,
required contact,
retryAttempt = 0,
}) async {
return await connectionProvider.sendTextMessage(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
};
} }
/// Initialize the app (load contacts, sync time, etc.) /// Initialize the app (load contacts, sync time, etc.)

View File

@@ -11,6 +11,23 @@ import 'helpers/room_login_manager.dart';
import 'helpers/message_delivery_tracker.dart'; import 'helpers/message_delivery_tracker.dart';
import 'helpers/ping_tracker.dart'; import 'helpers/ping_tracker.dart';
/// Pending send operation for auto-recovery
class _PendingSendOperation {
final Uint8List contactPublicKey;
final String text;
final String? messageId;
final Contact? contact;
final int retryAttempt;
_PendingSendOperation({
required this.contactPublicKey,
required this.text,
this.messageId,
this.contact,
this.retryAttempt = 0,
});
}
/// Result of a ping (telemetry request) operation /// Result of a ping (telemetry request) operation
class PingResult { class PingResult {
final bool success; final bool success;
@@ -117,6 +134,9 @@ class ConnectionProvider with ChangeNotifier {
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered; Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
// Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {};
ConnectionProvider() { ConnectionProvider() {
_initializeBleService(); _initializeBleService();
} }
@@ -147,8 +167,9 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
}; };
_bleService.onError = (error) { _bleService.onError = (error, {int? errorCode}) {
print('⚠️ [Provider] BLE error received: $error'); print('⚠️ [Provider] BLE error received: $error');
print(' Error code: ${errorCode ?? "none"}');
print(' Current connection state: ${_deviceInfo.connectionState}'); print(' Current connection state: ${_deviceInfo.connectionState}');
_error = error; _error = error;
@@ -169,6 +190,57 @@ class ConnectionProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
}; };
_bleService.onContactNotFound = (contactPublicKey) async {
print('🔧 [Provider] Contact not found error detected - initiating auto-recovery');
if (contactPublicKey == null) {
print(' ⚠️ No contact public key available for recovery');
return;
}
// Generate operation ID from public key
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
final pendingOp = _pendingSendOperations[operationId];
if (pendingOp == null || pendingOp.contact == null) {
print(' ⚠️ No pending operation found for recovery: $operationId');
return;
}
print(' 📋 Found pending operation for: ${pendingOp.contact!.advName}');
print(' 📤 Step 1: Adding contact to radio...');
try {
// Step 1: Add the contact to the radio
await _bleService.addOrUpdateContact(pendingOp.contact!);
// Small delay to ensure contact is added before retrying
await Future.delayed(const Duration(milliseconds: 300));
print(' ✅ Contact added successfully');
print(' 🔄 Step 2: Retrying message send...');
// Step 2: Retry the send operation
await _bleService.sendTextMessage(
contactPublicKey: pendingOp.contactPublicKey,
text: pendingOp.text,
attempt: pendingOp.retryAttempt,
);
print(' ✅ Auto-recovery completed - message resent');
// Clear pending operation after successful recovery
_pendingSendOperations.remove(operationId);
} catch (e) {
print(' ❌ Auto-recovery failed: $e');
_error = 'Auto-recovery failed: $e';
notifyListeners();
// Clear pending operation after failed recovery
_pendingSendOperations.remove(operationId);
}
};
_bleService.onContactReceived = (contact) { _bleService.onContactReceived = (contact) {
onContactReceived?.call(contact); onContactReceived?.call(contact);
}; };
@@ -529,6 +601,7 @@ class ConnectionProvider with ChangeNotifier {
_roomLoginManager _roomLoginManager
.clearRoomLoginStates(); // Clear login states on disconnect .clearRoomLoginStates(); // Clear login states on disconnect
_pingTracker.clearAll(); // Clear pending pings on disconnect _pingTracker.clearAll(); // Clear pending pings on disconnect
_pendingSendOperations.clear(); // Clear pending operations on disconnect
notifyListeners(); notifyListeners();
} }
@@ -582,11 +655,13 @@ class ConnectionProvider with ChangeNotifier {
/// ///
/// [messageId] - optional message ID to track delivery status /// [messageId] - optional message ID to track delivery status
/// [contact] - optional contact object for path status logging /// [contact] - optional contact object for path status logging
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
Future<bool> sendTextMessage({ Future<bool> sendTextMessage({
required Uint8List contactPublicKey, required Uint8List contactPublicKey,
required String text, required String text,
String? messageId, String? messageId,
Contact? contact, Contact? contact,
int retryAttempt = 0,
}) async { }) async {
if (!_bleService.isConnected) { if (!_bleService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -595,9 +670,13 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
// Log path status if contact info is available // Log path status and retry info
if (contact != null) { if (contact != null) {
print('📤 [ConnectionProvider] Sending message to ${contact.advName}'); if (retryAttempt > 0) {
print('🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)');
} else {
print('📤 [ConnectionProvider] Sending message to ${contact.advName}');
}
print(' Type: ${contact.type.displayName}'); print(' Type: ${contact.type.displayName}');
print(' Path status: ${contact.pathDescription}'); print(' Path status: ${contact.pathDescription}');
if (contact.hasPath) { if (contact.hasPath) {
@@ -605,6 +684,21 @@ class ConnectionProvider with ChangeNotifier {
} else { } else {
print(' ⚠️ No path available - will use flood mode'); print(' ⚠️ No path available - will use flood mode');
} }
} else if (retryAttempt > 0) {
print('🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)');
}
// Track pending operation for auto-recovery (if contact not found in radio)
if (contact != null) {
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
_pendingSendOperations[operationId] = _PendingSendOperation(
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
retryAttempt: retryAttempt,
);
print(' 📝 Tracked pending operation for auto-recovery: $operationId');
} }
// IMPORTANT: Track pending message BEFORE sending to avoid race condition // IMPORTANT: Track pending message BEFORE sending to avoid race condition
@@ -615,12 +709,23 @@ class ConnectionProvider with ChangeNotifier {
print(' Added message ID to pending queue BEFORE sending: $messageId'); print(' Added message ID to pending queue BEFORE sending: $messageId');
} }
// Send the message // Send the message with retry attempt info
await _bleService.sendTextMessage( await _bleService.sendTextMessage(
contactPublicKey: contactPublicKey, contactPublicKey: contactPublicKey,
text: text, text: text,
attempt: retryAttempt,
); );
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) {
final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
// Use a small delay to allow error response to arrive before clearing
Future.delayed(const Duration(milliseconds: 500), () {
_pendingSendOperations.remove(operationId);
});
}
return true; return true;
} catch (e) { } catch (e) {
_error = 'Failed to send message: $e'; _error = 'Failed to send message: $e';

View File

@@ -0,0 +1,89 @@
import '../../models/message.dart';
import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
class MessageRetryManager {
// Track retry state for each message ID
final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {};
// Progressive timeout values in milliseconds
static const List<int> _timeouts = [4000, 8000, 12000];
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
}
return _timeouts[attempt];
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts
/// - Contact still has no path
/// - Hasn't already used flood fallback
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
!contact.hasPath &&
!message.usedFloodFallback;
}
/// Track a retry attempt for a message
void trackRetry(String messageId, int attempt) {
_retryAttempts[messageId] = attempt;
_lastRetryTimes[messageId] = DateTime.now();
}
/// Clear retry tracking for a message (on success or permanent failure)
void clearRetry(String messageId) {
_retryAttempts.remove(messageId);
_lastRetryTimes.remove(messageId);
}
/// Clear all retry tracking (on disconnect)
void clearAll() {
_retryAttempts.clear();
_lastRetryTimes.clear();
}
/// Get current retry attempt for a message (for debugging)
int? getRetryAttempt(String messageId) {
return _retryAttempts[messageId];
}
/// Get last retry time for a message (for debugging)
DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[messageId];
}
}

View File

@@ -2,11 +2,13 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart'; import '../models/sar_marker.dart';
import '../services/message_storage_service.dart'; import '../services/message_storage_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
/// Messages Provider - manages message history and SAR markers /// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
@@ -23,6 +25,21 @@ class MessagesProvider with ChangeNotifier {
// Track timeout timers for pending messages // Track timeout timers for pending messages
final Map<int, Timer> _timeoutTimers = {}; final Map<int, Timer> _timeoutTimers = {};
// Retry management
final MessageRetryManager _retryManager = MessageRetryManager();
// Track which contact each sent message was sent to (for retry logic)
final Map<String, Contact> _messageContactMap = {};
// Callback to connection provider for sending messages (set by AppProvider)
Future<bool> Function({
required Uint8List contactPublicKey,
required String text,
required String messageId,
required Contact contact,
int retryAttempt,
})? sendMessageCallback;
List<Message> get messages => List.unmodifiable(_messages); List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages => List<Message> get contactMessages =>
@@ -176,7 +193,16 @@ class MessagesProvider with ChangeNotifier {
/// 2. Same channel index (for channel messages) /// 2. Same channel index (for channel messages)
/// 3. Same sender timestamp /// 3. Same sender timestamp
/// 4. Same text content /// 4. Same text content
///
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates
/// because they can be retried with different message IDs
bool _isDuplicate(Message message) { bool _isDuplicate(Message message) {
// Sent messages (our own messages) should never be considered duplicates
// They can be retried multiple times with different IDs
if (message.isSentMessage) {
return false;
}
return _messages.any((existing) { return _messages.any((existing) {
// Check message type matches // Check message type matches
if (existing.messageType != message.messageType) { if (existing.messageType != message.messageType) {
@@ -468,7 +494,7 @@ class MessagesProvider with ChangeNotifier {
} }
/// Add a sent message with initial status /// Add a sent message with initial status
void addSentMessage(Message message) { void addSentMessage(Message message, {Contact? contact}) {
print('📝 [MessagesProvider] addSentMessage called'); print('📝 [MessagesProvider] addSentMessage called');
print(' Message ID: ${message.id}'); print(' Message ID: ${message.id}');
print(' Message type: ${message.messageType}'); print(' Message type: ${message.messageType}');
@@ -493,6 +519,12 @@ class MessagesProvider with ChangeNotifier {
print(' ✅ Message added to list at index ${_messages.length - 1}'); print(' ✅ Message added to list at index ${_messages.length - 1}');
print(' Total messages in list: ${_messages.length}'); print(' Total messages in list: ${_messages.length}');
// Store contact mapping for retry logic
if (contact != null) {
_messageContactMap[message.id] = contact;
print(' ✅ Stored contact mapping for retry logic');
}
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (sendingMessage.isSarMarker) { if (sendingMessage.isSarMarker) {
final marker = sendingMessage.toSarMarker(); final marker = sendingMessage.toSarMarker();
@@ -599,6 +631,9 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending // Remove from pending
_pendingSentMessages.remove(ackCode); _pendingSentMessages.remove(ackCode);
// Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id);
print('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)'); print('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)');
print(' Updated status to: ${updatedMessage.deliveryStatus}'); print(' Updated status to: ${updatedMessage.deliveryStatus}');
print(' Calling notifyListeners() to update UI'); print(' Calling notifyListeners() to update UI');
@@ -640,15 +675,130 @@ class MessagesProvider with ChangeNotifier {
} }
} }
/// Update message status to failed /// Update message status to failed (with retry logic)
void markMessageFailed(String messageId) { void markMessageFailed(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
print('⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId');
return;
}
final message = _messages[index];
final contact = _messageContactMap[messageId];
print('❌ [MessagesProvider] Message $messageId timeout/failed');
print(' Retry attempt: ${message.retryAttempt}');
print(' Contact has path: ${contact?.hasPath ?? false}');
print(' Used flood fallback: ${message.usedFloodFallback}');
// Decision tree for retry/flood/fail
if (contact != null && _retryManager.canRetry(message, contact)) {
// RETRY: Contact has path and retry attempts < 3
_scheduleRetry(messageId, message, contact);
} else if (contact != null && _retryManager.shouldUseFloodFallback(message, contact)) {
// FLOOD FALLBACK: After 3 retries failed, try flood once
_sendWithFloodMode(messageId, message, contact);
} else {
// PERMANENTLY FAILED: No retry possible
_markAsPermanentlyFailed(messageId, message);
}
}
/// Schedule a retry with progressive timeout
void _scheduleRetry(String messageId, Message message, Contact contact) {
final nextAttempt = message.retryAttempt + 1;
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
print('🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId');
print(' Timeout: ${timeout}ms');
// Update message with new retry attempt
final index = _messages.indexWhere((m) => m.id == messageId); final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) { if (index != -1) {
final message = _messages[index]; _messages[index] = message.copyWith(
final updatedMessage = message.copyWith( retryAttempt: nextAttempt,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Cancel old timeout timer
if (message.expectedAckTag != null) {
_timeoutTimers[message.expectedAckTag]?.cancel();
_timeoutTimers.remove(message.expectedAckTag);
_pendingSentMessages.remove(message.expectedAckTag);
}
// Track retry
_retryManager.trackRetry(messageId, nextAttempt);
notifyListeners(); // Update UI to show "Retrying (X/3)..."
// Schedule actual retry after delay
Timer(Duration(milliseconds: timeout), () async {
print('⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId');
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: nextAttempt,
);
} else {
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry');
}
});
_persistMessages();
}
}
/// Send message with flood mode as last resort
Future<void> _sendWithFloodMode(String messageId, Message message, Contact contact) async {
print('🌊 [MessagesProvider] Trying flood mode for message $messageId');
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
_messages[index] = message.copyWith(
usedFloodFallback: true,
deliveryStatus: MessageDeliveryStatus.sending,
);
// Cancel old timeout timer
if (message.expectedAckTag != null) {
_timeoutTimers[message.expectedAckTag]?.cancel();
_timeoutTimers.remove(message.expectedAckTag);
_pendingSentMessages.remove(message.expectedAckTag);
}
notifyListeners();
// Send with flood mode (no retry after this)
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0, // Reset attempt for flood
);
} else {
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood');
}
_persistMessages();
}
}
/// Mark message as permanently failed
void _markAsPermanentlyFailed(String messageId, Message message) {
print('❌ [MessagesProvider] Message $messageId permanently failed');
final index = _messages.indexWhere((m) => m.id == messageId);
if (index != -1) {
_messages[index] = message.copyWith(
deliveryStatus: MessageDeliveryStatus.failed, deliveryStatus: MessageDeliveryStatus.failed,
); );
_messages[index] = updatedMessage;
// Cancel timeout timer if it exists // Cancel timeout timer if it exists
if (message.expectedAckTag != null) { if (message.expectedAckTag != null) {
@@ -657,13 +807,61 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
print('❌ [MessagesProvider] Message $messageId marked as failed'); // Clear retry tracking
_retryManager.clearRetry(messageId);
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
} }
} }
/// Resend a failed message
Future<void> resendMessage(String messageId) async {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
print('⚠️ [MessagesProvider] resendMessage: Message not found: $messageId');
return;
}
final message = _messages[index];
final contact = _messageContactMap[messageId];
if (contact == null) {
print('⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId');
return;
}
print('🔁 [MessagesProvider] Resending message $messageId');
// Reset retry state
_messages[index] = message.copyWith(
retryAttempt: 0,
usedFloodFallback: false,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Clear retry tracking
_retryManager.clearRetry(messageId);
notifyListeners();
// Send again
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0,
);
} else {
print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend');
}
_persistMessages();
}
@override @override
void dispose() { void dispose() {
// Cancel all pending timeout timers // Cancel all pending timeout timers
@@ -671,6 +869,10 @@ class MessagesProvider with ChangeNotifier {
timer.cancel(); timer.cancel();
} }
_timeoutTimers.clear(); _timeoutTimers.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose(); super.dispose();
} }
} }

View File

@@ -194,6 +194,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
); );
} }
// Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -264,6 +267,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Save TX power // Save TX power
await connectionProvider.setTxPower(txPowerResult.value!); await connectionProvider.setTxPower(txPowerResult.value!);
// Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(

View File

@@ -52,7 +52,8 @@ class MapTab extends StatefulWidget {
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin { class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final MapController _mapController = MapController(); final MapController _mapController = MapController();
final TileCacheService _tileCache = TileCacheService(); final TileCacheService _tileCache = TileCacheService();
final LocationTrackingService _locationService = LocationTrackingService(); // DO NOT create a new LocationTrackingService instance here
// Use the singleton from AppProvider instead via _locationService getter
final MapMarkerService _markerService = MapMarkerService(); final MapMarkerService _markerService = MapMarkerService();
bool _isInitialized = false; bool _isInitialized = false;
bool _isMapReady = false; // Track when map widget is actually rendered bool _isMapReady = false; // Track when map widget is actually rendered
@@ -90,13 +91,16 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
// Access the singleton LocationTrackingService from AppProvider
LocationTrackingService get _locationService => LocationTrackingService();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadSettings(); _loadSettings();
_loadMbtilesLayers(); _loadMbtilesLayers();
_initializeTileCache(); _initializeTileCache();
_initLocationTracking(); _setupLocationCallbacks();
_startCompassTracking(); _startCompassTracking();
// Listen to map provider for navigation requests // Listen to map provider for navigation requests
@@ -113,16 +117,22 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
}); });
} }
Future<void> _initLocationTracking() async { /// Setup location tracking callbacks for map-specific features
// Initialize LocationTrackingService /// Note: LocationTrackingService is initialized and started by AppProvider
final appProvider = context.read<AppProvider>(); /// This method only adds map-specific callbacks for rotation and UI updates
await _locationService.initialize(appProvider.connectionProvider.bleService); void _setupLocationCallbacks() {
// Store the original callback from AppProvider
final originalCallback = _locationService.onPositionUpdate;
// Set up callbacks // Add map-specific callback that chains with the original
_locationService.onPositionUpdate = (position) { _locationService.onPositionUpdate = (position) {
// Call original callback first (AppProvider's logging)
originalCallback?.call(position);
// Then handle map-specific logic
if (mounted) { if (mounted) {
setState(() { setState(() {
// Position updates are now handled by the service // Position updates trigger UI rebuild for markers
}); });
// Rotate map if rotation mode is enabled and heading is available // Rotate map if rotation mode is enabled and heading is available
@@ -140,16 +150,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
} }
} }
}; };
_locationService.onError = (error) {
debugPrint('Location tracking error: $error');
};
// Request permissions and start tracking
final hasPermission = await _locationService.requestPermissions();
if (hasPermission) {
await _locationService.startTracking(distanceThreshold: _gpsUpdateDistance);
}
} }
void _startCompassTracking() { void _startCompassTracking() {
@@ -372,7 +372,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final mapProvider = context.read<MapProvider>(); final mapProvider = context.read<MapProvider>();
mapProvider.removeListener(_handleMapNavigation); mapProvider.removeListener(_handleMapNavigation);
_compassStreamSubscription?.cancel(); _compassStreamSubscription?.cancel();
_locationService.stopTracking(); // DO NOT stop location tracking - it's managed by AppProvider
// Just clear the map-specific callback
_locationService.onPositionUpdate = null;
_mapController.dispose(); _mapController.dispose();
_tileCache.dispose(); _tileCache.dispose();
super.dispose(); super.dispose();

View File

@@ -29,7 +29,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error); typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
/// Processes incoming responses from the BLE device /// Processes incoming responses from the BLE device
class BleResponseHandler { class BleResponseHandler {
@@ -58,8 +59,12 @@ class BleResponseHandler {
OnBinaryResponseCallback? onBinaryResponse; OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage; OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError; OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
VoidCallback? onRxActivity; VoidCallback? onRxActivity;
// Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND
Uint8List? _lastContactPublicKey;
// Getters // Getters
int get rxPacketCount => _rxPacketCount; int get rxPacketCount => _rxPacketCount;
List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs); List<BlePacketLog> get packetLogs => List.unmodifiable(_packetLogs);
@@ -574,13 +579,25 @@ class BleResponseHandler {
if (errorCode != null) { if (errorCode != null) {
final errorMsg = FrameParser.getErrorMessage(errorCode); final errorMsg = FrameParser.getErrorMessage(errorCode);
print(' ❌ [Error] $errorMsg'); print(' ❌ [Error] $errorMsg');
onError?.call(errorMsg);
// Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio
if (errorCode == 2) { // ERR_CODE_NOT_FOUND
print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery');
onContactNotFound?.call(_lastContactPublicKey);
}
onError?.call(errorMsg, errorCode: errorCode);
} }
} catch (e) { } catch (e) {
print(' ❌ [Error] Parsing error: $e'); print(' ❌ [Error] Parsing error: $e');
} }
} }
/// Track the last contact public key for retry logic
void setLastContactPublicKey(Uint8List? publicKey) {
_lastContactPublicKey = publicKey;
}
/// Log a packet /// Log a packet
void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) {
_packetLogs.add(BlePacketLog( _packetLogs.add(BlePacketLog(

View File

@@ -77,6 +77,9 @@ class LocationTrackingService {
/// Whether service has been initialized with BLE service /// Whether service has been initialized with BLE service
bool _isInitialized = false; bool _isInitialized = false;
/// Whether the first stable position has been set (without broadcast)
bool _firstPositionSet = false;
// ============================================================================ // ============================================================================
// Private Properties // Private Properties
// ============================================================================ // ============================================================================
@@ -172,22 +175,52 @@ class LocationTrackingService {
/// Get current GPS position /// Get current GPS position
/// ///
/// Returns null if position unavailable or permissions denied. /// Returns null if position unavailable or permissions denied.
Future<Position?> getCurrentPosition() async { /// [timeLimit] - Maximum time to wait for position (default: 15 seconds)
try { /// [retryCount] - Number of retry attempts (default: 2)
final position = await Geolocator.getCurrentPosition( Future<Position?> getCurrentPosition({
locationSettings: const LocationSettings( Duration timeLimit = const Duration(seconds: 15),
accuracy: LocationAccuracy.best, int retryCount = 2,
timeLimit: Duration(seconds: 5), }) async {
), for (int attempt = 0; attempt <= retryCount; attempt++) {
); try {
if (attempt > 0) {
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
// Exponential backoff: wait 2^attempt seconds before retry
await Future.delayed(Duration(seconds: 1 << attempt));
}
currentPosition = position; final position = await Geolocator.getCurrentPosition(
return position; locationSettings: LocationSettings(
} catch (e) { accuracy: LocationAccuracy.best,
debugPrint('❌ [LocationTracking] Error getting position: $e'); timeLimit: timeLimit,
onError?.call('Failed to get current position: $e'); ),
return null; );
currentPosition = position;
if (attempt > 0) {
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
}
return position;
} catch (e) {
final isLastAttempt = attempt == retryCount;
if (isLastAttempt) {
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
// Only call error callback on final failure, and make it user-friendly
if (e.toString().contains('TimeoutException')) {
onError?.call('GPS signal weak. Position stream will continue trying...');
} else {
onError?.call('Failed to get GPS position. Check device settings.');
}
} else {
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
}
if (isLastAttempt) {
return null;
}
}
} }
return null;
} }
/// Get position stream with configurable distance filter /// Get position stream with configurable distance filter
@@ -211,6 +244,8 @@ class LocationTrackingService {
/// [distanceThreshold] - GPS update distance filter /// [distanceThreshold] - GPS update distance filter
/// ///
/// Returns true if successful, false otherwise. /// Returns true if successful, false otherwise.
/// Note: This method returns immediately after starting the position stream.
/// Initial position acquisition happens asynchronously in the background.
Future<bool> startTracking({double? distanceThreshold}) async { Future<bool> startTracking({double? distanceThreshold}) async {
if (!_isInitialized || _bleService == null) { if (!_isInitialized || _bleService == null) {
debugPrint( debugPrint(
@@ -239,17 +274,28 @@ class LocationTrackingService {
// Save settings // Save settings
await saveSettings(); await saveSettings();
// Get initial position // Try to get initial position in background (non-blocking)
await getCurrentPosition(); // This will populate currentPosition but won't block tracking startup
getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
).then((position) {
if (position != null) {
debugPrint('✅ [LocationTracking] Initial position acquired in background');
}
}).catchError((error) {
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
// Not critical - position stream will eventually provide position
});
// Start position stream // Start position stream immediately (don't wait for initial position)
try { try {
_positionSubscription = getPositionStream(distanceFilter: threshold) _positionSubscription = getPositionStream(distanceFilter: threshold)
.listen( .listen(
_handlePositionUpdate, _handlePositionUpdate,
onError: (error) { onError: (error) {
debugPrint('❌ [LocationTracking] Position stream error: $error'); debugPrint('❌ [LocationTracking] Position stream error: $error');
onError?.call('Position stream error: $error'); onError?.call('GPS stream error. Retrying...');
}, },
); );
@@ -259,10 +305,11 @@ class LocationTrackingService {
debugPrint( debugPrint(
'✅ [LocationTracking] Tracking started with ${threshold}m threshold', '✅ [LocationTracking] Tracking started with ${threshold}m threshold',
); );
debugPrint('📡 [LocationTracking] Waiting for GPS signal...');
return true; return true;
} catch (e) { } catch (e) {
debugPrint('❌ [LocationTracking] Failed to start tracking: $e'); debugPrint('❌ [LocationTracking] Failed to start tracking: $e');
onError?.call('Failed to start tracking: $e'); onError?.call('Failed to start GPS tracking: $e');
return false; return false;
} }
} }
@@ -277,6 +324,9 @@ class LocationTrackingService {
isTracking = false; isTracking = false;
onTrackingStateChanged?.call(false); onTrackingStateChanged?.call(false);
// Reset first position flag so next connection starts fresh
_firstPositionSet = false;
// Save disabled state // Save disabled state
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false); await prefs.setBool(_prefKeyEnabled, false);
@@ -316,10 +366,57 @@ class LocationTrackingService {
// Notify listeners // Notify listeners
onPositionUpdate?.call(position); onPositionUpdate?.call(position);
// SPECIAL CASE: First stable position after connection
// Set lat/lon on device WITHOUT broadcasting to mesh network
if (!_firstPositionSet) {
_setInitialPosition(position);
return;
}
// Check if we should broadcast to mesh network // Check if we should broadcast to mesh network
_checkAndBroadcast(position); _checkAndBroadcast(position);
} }
/// Set initial position on device without broadcasting
///
/// Called only for the first stable GPS position after connection starts.
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
void _setInitialPosition(Position position) async {
if (_bleService == null || !_bleService!.isConnected) {
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
return;
}
try {
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
// Update device's advertised location WITHOUT sending advertisement
await _bleService!.setAdvertLatLon(
latitude: position.latitude,
longitude: position.longitude,
);
// Mark first position as set
_firstPositionSet = true;
// Update last broadcast position to prevent immediate broadcast on next update
_lastBroadcastPosition = position;
_lastBroadcastTime = DateTime.now();
// Save to preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
} catch (e) {
debugPrint('❌ [LocationTracking] Failed to set initial position: $e');
onError?.call('Failed to set initial position: $e');
// Don't mark as set on failure, so it will retry on next update
}
}
/// Check if position should be broadcast based on distance and time thresholds /// Check if position should be broadcast based on distance and time thresholds
void _checkAndBroadcast(Position position) { void _checkAndBroadcast(Position position) {
// If never broadcast before, do it now // If never broadcast before, do it now

View File

@@ -28,7 +28,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim
typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData);
typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData);
typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb);
typedef OnErrorCallback = void Function(String error); typedef OnErrorCallback = void Function(String error, {int? errorCode});
typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey);
typedef OnConnectionStateCallback = void Function(bool isConnected); typedef OnConnectionStateCallback = void Function(bool isConnected);
typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts); typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts);
typedef OnRssiUpdateCallback = void Function(int rssi); typedef OnRssiUpdateCallback = void Function(int rssi);
@@ -62,6 +63,7 @@ class MeshCoreBleService {
OnBinaryResponseCallback? onBinaryResponse; OnBinaryResponseCallback? onBinaryResponse;
OnBatteryAndStorageCallback? onBatteryAndStorage; OnBatteryAndStorageCallback? onBatteryAndStorage;
OnErrorCallback? onError; OnErrorCallback? onError;
OnContactNotFoundCallback? onContactNotFound;
// Activity callbacks (for blinking indicators) // Activity callbacks (for blinking indicators)
VoidCallback? onRxActivity; VoidCallback? onRxActivity;
@@ -149,8 +151,11 @@ class MeshCoreBleService {
_responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) { _responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) {
onBatteryAndStorage?.call(millivolts, usedKb, totalKb); onBatteryAndStorage?.call(millivolts, usedKb, totalKb);
}; };
_responseHandler.onError = (error) { _responseHandler.onError = (error, {int? errorCode}) {
onError?.call(error); onError?.call(error, errorCode: errorCode);
};
_responseHandler.onContactNotFound = (contactPublicKey) {
onContactNotFound?.call(contactPublicKey);
}; };
_responseHandler.onRxActivity = () { _responseHandler.onRxActivity = () {
onRxActivity?.call(); onRxActivity?.call();
@@ -247,6 +252,9 @@ class MeshCoreBleService {
throw ArgumentError('Text message exceeds 160 character limit'); throw ArgumentError('Text message exceeds 160 character limit');
} }
// Track the last contact for auto-recovery if contact not found
_responseHandler.setLastContactPublicKey(contactPublicKey);
await _commandSender.writeData(FrameBuilder.buildSendTxtMsg( await _commandSender.writeData(FrameBuilder.buildSendTxtMsg(
contactPublicKey: contactPublicKey, contactPublicKey: contactPublicKey,
text: text, text: text,

View File

@@ -47,7 +47,9 @@ class TileCacheService {
FMTCTileProvider getTileProvider(MapLayer layer) { FMTCTileProvider getTileProvider(MapLayer layer) {
if (!_isInitialized) { if (!_isInitialized) {
throw StateError('TileCacheService not initialized. Call initialize() first.'); throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
} }
return _store.getTileProvider( return _store.getTileProvider(
loadingStrategy: BrowseLoadingStrategy.cacheFirst, loadingStrategy: BrowseLoadingStrategy.cacheFirst,
@@ -63,7 +65,9 @@ class TileCacheService {
Function(double progress)? onProgress, Function(double progress)? onProgress,
}) async { }) async {
if (!_isInitialized) { if (!_isInitialized) {
throw StateError('TileCacheService not initialized. Call initialize() first.'); throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
} }
if (_isDownloading) { if (_isDownloading) {
@@ -78,21 +82,19 @@ class TileCacheService {
final downloadable = region.toDownloadable( final downloadable = region.toDownloadable(
minZoom: minZoom, minZoom: minZoom,
maxZoom: maxZoom, maxZoom: maxZoom,
options: TileLayer( options: TileLayer(urlTemplate: layer.urlTemplate),
urlTemplate: layer.urlTemplate,
),
); );
final download = _store.download.startForeground( final download = _store.download.startForeground(region: downloadable);
region: downloadable,
);
await for (final progress in download.downloadProgress) { await for (final progress in download.downloadProgress) {
if (onProgress != null && progress.maxTilesCount > 0) { if (onProgress != null && progress.maxTilesCount > 0) {
// Use attemptedTilesCount instead of successfulTilesCount // Use attemptedTilesCount instead of successfulTilesCount
// attemptedTilesCount includes successful + buffered + skipped tiles // attemptedTilesCount includes successful + buffered + skipped tiles
final percentage = progress.percentageProgress; final percentage = progress.percentageProgress;
print('Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})'); print(
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
);
onProgress(percentage); onProgress(percentage);
} }
} }
@@ -126,7 +128,9 @@ class TileCacheService {
Future<List<String>> getAvailableStores() async { Future<List<String>> getAvailableStores() async {
if (!_isInitialized) { if (!_isInitialized) {
throw StateError('TileCacheService not initialized. Call initialize() first.'); throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
} }
final stores = await FMTCRoot.stats.storesAvailable; final stores = await FMTCRoot.stats.storesAvailable;
@@ -137,11 +141,11 @@ class TileCacheService {
if (!_isInitialized) return {}; if (!_isInitialized) return {};
final length = await _store.stats.length; final length = await _store.stats.length;
final size = await _store.stats.size; final size = await _store.stats.all.then((a) => a.size);
return { return {
'tileCount': length, 'tileCount': length,
'sizeMB': size / (1024 * 1024), 'sizeMB': size / 1024,
'storeName': _storeName, 'storeName': _storeName,
}; };
} }

View File

@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
@@ -43,6 +44,66 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
}); });
} }
/// Insert current GPS location at cursor position
Future<void> _insertCurrentLocation() async {
try {
// Check location permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission denied');
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission permanently denied');
return;
}
// Get current position
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.best,
);
// Format location text
final locationText = '📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
// Check if adding location would exceed limit
final currentText = _textController.text;
if (currentText.length + locationText.length > _maxCharacters) {
if (!mounted) return;
ToastLogger.error(context, 'Adding location would exceed 160 character limit');
return;
}
// Insert at cursor position or append
final selection = _textController.selection;
final newText = currentText.replaceRange(
selection.start >= 0 ? selection.start : currentText.length,
selection.end >= 0 ? selection.end : currentText.length,
locationText,
);
_textController.text = newText;
// Move cursor to end of inserted text
final newCursorPosition = (selection.start >= 0 ? selection.start : currentText.length) + locationText.length;
_textController.selection = TextSelection.fromPosition(
TextPosition(offset: newCursorPosition),
);
if (!mounted) return;
ToastLogger.success(context, 'Location inserted');
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to get location: $e');
}
}
Future<void> _sendDirectMessage() async { Future<void> _sendDirectMessage() async {
final text = _textController.text.trim(); final text = _textController.text.trim();
if (text.isEmpty) return; if (text.isEmpty) return;
@@ -80,7 +141,8 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
); );
// Add to messages list with "sending" status // Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage); // Pass contact for retry logic
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
// Send direct message to contact (include contact for path logging) // Send direct message to contact (include contact for path logging)
final sentSuccessfully = await connectionProvider.sendTextMessage( final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -226,37 +288,64 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
borderSide: BorderSide(color: colorScheme.primary, width: 2), borderSide: BorderSide(color: colorScheme.primary, width: 2),
), ),
contentPadding: const EdgeInsets.all(16), contentPadding: const EdgeInsets.all(16),
counterText: _characterCount >= 150 counterText: '', // Hide default counter
? '$_characterCount/$_maxCharacters'
: '',
counterStyle: TextStyle(
fontSize: 11,
color: _characterCount > _maxCharacters * 0.9
? Colors.orange
: colorScheme.onSurfaceVariant,
),
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendDirectMessage(), onSubmitted: (_) => _sendDirectMessage(),
), ),
const SizedBox(height: 12), // Always-visible character counter
SizedBox( Padding(
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: ElevatedButton.icon( child: Row(
onPressed: _textController.text.trim().isEmpty mainAxisAlignment: MainAxisAlignment.end,
? null children: [
: _sendDirectMessage, Text(
icon: const Icon(Icons.send), '$_characterCount / $_maxCharacters',
label: Text(AppLocalizations.of(context)!.sendDirectMessage), style: TextStyle(
style: ElevatedButton.styleFrom( fontSize: 12,
padding: const EdgeInsets.symmetric(vertical: 14), color: _characterCount > 155
backgroundColor: colorScheme.primary, ? Colors.red
foregroundColor: colorScheme.onPrimary, : (_characterCount > 140
disabledBackgroundColor: colorScheme.surfaceContainerHighest, ? Colors.orange
disabledForegroundColor: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant),
), fontWeight: _characterCount > 140 ? FontWeight.bold : FontWeight.normal,
),
),
],
), ),
), ),
const SizedBox(height: 8),
// Location and Send buttons
Row(
children: [
OutlinedButton.icon(
onPressed: _insertCurrentLocation,
icon: const Icon(Icons.my_location, size: 18),
label: const Text('Location'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
side: BorderSide(color: colorScheme.outline),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _textController.text.trim().isEmpty
? null
: _sendDirectMessage,
icon: const Icon(Icons.send),
label: Text(AppLocalizations.of(context)!.sendDirectMessage),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
),
),
),
],
),
], ],
), ),
), ),

View File

@@ -294,6 +294,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.1" version: "0.8.1"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -373,6 +378,11 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
geoclue: geoclue:
dependency: transitive dependency: transitive
description: description:
@@ -477,6 +487,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.5.4" version: "4.5.4"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -773,6 +788,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.3" version: "6.0.3"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
proj4dart: proj4dart:
dependency: transitive dependency: transitive
description: description:
@@ -938,6 +961,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -1107,6 +1138,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:

View File

@@ -93,6 +93,8 @@ dependencies:
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is

248
scripts/take_screenshots.sh Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/bash
# MeshCore SAR App Screenshot Script
# Captures screenshots on multiple devices for App Store submission
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
OUTPUT_DIR="screenshots"
INTEGRATION_TEST="integration_test/app_screenshots_test.dart"
# Device configurations for App Store screenshots
# iOS devices (required sizes: 6.7", 6.5", 5.5")
IOS_DEVICES=(
"iPhone Air" # 6.3" - 1206x2622 (newer large format)
)
# Android devices (phone + tablet recommended)
ANDROID_DEVICES=(
"pixel_7_pro" # Phone - 1440x3120
"pixel_tablet" # Tablet - 2560x1600
)
echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ MeshCore SAR Screenshot Generator ║${NC}"
echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}"
echo ""
# Check if integration test exists
if [ ! -f "$INTEGRATION_TEST" ]; then
echo -e "${RED}❌ Error: Integration test not found at $INTEGRATION_TEST${NC}"
exit 1
fi
# Create output directory
mkdir -p "$OUTPUT_DIR"
# Function to list available devices
list_devices() {
echo -e "${YELLOW}📱 Available iOS Simulators:${NC}"
xcrun simctl list devices available | grep "iPhone" | grep -v "unavailable"
echo ""
echo -e "${YELLOW}🤖 Available Android Emulators:${NC}"
emulator -list-avds
echo ""
}
# Function to take screenshots on iOS
take_ios_screenshots() {
local device_name="$1"
echo -e "${GREEN}📸 Taking screenshots on iOS: $device_name${NC}"
# Get device ID (UUID is the first parenthesized value)
local device_line=$(xcrun simctl list devices available | grep "$device_name" | grep -v "unavailable" | head -1)
local device_id=$(echo "$device_line" | sed -n 's/.*(\([0-9A-F-]*\)).*/\1/p')
if [ -z "$device_id" ]; then
echo -e "${RED}❌ Device not found: $device_name${NC}"
echo -e "${YELLOW}💡 Creating simulator: $device_name${NC}"
# Try to create the device (this might fail if device type doesn't exist)
device_id=$(xcrun simctl create "$device_name" "$device_name" 2>&1)
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Failed to create simulator. Skipping...${NC}"
return 1
fi
fi
echo -e "${BLUE} Device ID: $device_id${NC}"
# Boot the simulator if not already booted
xcrun simctl boot "$device_id" 2>/dev/null || true
sleep 3
# Create device-specific output directory
local device_dir="$OUTPUT_DIR/ios/${device_name// /_}"
mkdir -p "$device_dir"
# Run the integration test
flutter drive \
--driver=test_driver/integration_test.dart \
--target="$INTEGRATION_TEST" \
-d "$device_id" \
--screenshot="$device_dir" || {
echo -e "${YELLOW}⚠️ Warning: Screenshot capture had issues on $device_name${NC}"
}
echo -e "${GREEN}✅ Completed: $device_name${NC}"
echo ""
}
# Function to take screenshots on Android
take_android_screenshots() {
local device_name="$1"
echo -e "${GREEN}📸 Taking screenshots on Android: $device_name${NC}"
# Check if emulator exists
if ! emulator -list-avds | grep -q "^$device_name$"; then
echo -e "${RED}❌ Emulator not found: $device_name${NC}"
echo -e "${YELLOW}💡 Please create the emulator first using Android Studio${NC}"
return 1
fi
# Start emulator in background
echo -e "${BLUE} Starting emulator...${NC}"
emulator -avd "$device_name" -no-audio -no-boot-anim &
EMULATOR_PID=$!
# Wait for emulator to boot
echo -e "${BLUE} Waiting for emulator to boot...${NC}"
adb wait-for-device
sleep 10
# Wait for boot to complete
while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do
echo -e "${BLUE} Still booting...${NC}"
sleep 3
done
echo -e "${GREEN} Emulator booted${NC}"
# Create device-specific output directory
local device_dir="$OUTPUT_DIR/android/${device_name}"
mkdir -p "$device_dir"
# Run the integration test
flutter drive \
--driver=test_driver/integration_test.dart \
--target="$INTEGRATION_TEST" \
-d emulator-5554 \
--screenshot="$device_dir" || {
echo -e "${YELLOW}⚠️ Warning: Screenshot capture had issues on $device_name${NC}"
}
# Kill emulator
kill $EMULATOR_PID 2>/dev/null || true
echo -e "${GREEN}✅ Completed: $device_name${NC}"
echo ""
}
# Parse command line arguments
PLATFORM="all"
DEVICE_FILTER=""
while [[ $# -gt 0 ]]; do
case $1 in
--ios)
PLATFORM="ios"
shift
;;
--android)
PLATFORM="android"
shift
;;
--device)
DEVICE_FILTER="$2"
shift 2
;;
--list)
list_devices
exit 0
;;
--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --ios Take screenshots on iOS devices only"
echo " --android Take screenshots on Android devices only"
echo " --device <name> Take screenshots on specific device only"
echo " --list List available devices"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # All devices"
echo " $0 --ios # iOS only"
echo " $0 --android # Android only"
echo " $0 --device 'iPhone 15 Pro Max' # Specific device"
echo " $0 --list # List available devices"
exit 0
;;
*)
echo -e "${RED}❌ Unknown option: $1${NC}"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Create test driver if it doesn't exist
DRIVER_FILE="test_driver/integration_test.dart"
mkdir -p test_driver
if [ ! -f "$DRIVER_FILE" ]; then
echo -e "${YELLOW}📝 Creating integration test driver...${NC}"
cat > "$DRIVER_FILE" << 'EOF'
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();
EOF
fi
# Take screenshots
if [ -n "$DEVICE_FILTER" ]; then
# Specific device
echo -e "${BLUE}🎯 Taking screenshots on: $DEVICE_FILTER${NC}"
echo ""
# Determine if iOS or Android based on device name
if [[ "$DEVICE_FILTER" == *"iPhone"* ]] || [[ "$DEVICE_FILTER" == *"iPad"* ]]; then
take_ios_screenshots "$DEVICE_FILTER"
else
take_android_screenshots "$DEVICE_FILTER"
fi
else
# Multiple devices based on platform
if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "ios" ]; then
echo -e "${BLUE}🍎 Taking iOS screenshots...${NC}"
echo ""
for device in "${IOS_DEVICES[@]}"; do
take_ios_screenshots "$device"
done
fi
if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "android" ]; then
echo -e "${BLUE}🤖 Taking Android screenshots...${NC}"
echo ""
for device in "${ANDROID_DEVICES[@]}"; do
take_android_screenshots "$device"
done
fi
fi
echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ Screenshot Capture Complete! ║${NC}"
echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}"
echo ""
echo -e "${BLUE}📁 Screenshots saved to: $OUTPUT_DIR${NC}"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo -e " 1. Review screenshots in $OUTPUT_DIR"
echo -e " 2. Organize by device size for App Store"
echo -e " 3. Add captions and localization if needed"
echo ""

View File

@@ -0,0 +1,3 @@
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();