diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 548f410..8d6af6f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -36,7 +36,12 @@ "Bash(git log:*)", "WebFetch(domain:meshcore-sar.dz0ny.dev)", "Bash(flutter pub get:*)", - "Read(//Users/dz0ny/meshcore-sar/**)" + "Read(//Users/dz0ny/meshcore-sar/**)", + "WebFetch(domain:docs.fleaflet.dev)", + "WebFetch(domain:github.com)", + "Bash(find:*)", + "WebFetch(domain:prostor.zgs.gov.si)", + "Bash(curl:*)" ], "deny": [], "ask": [] diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml index c1cdf7e..cb27a2e 100644 --- a/.github/workflows/build-multiplatform.yml +++ b/.github/workflows/build-multiplatform.yml @@ -97,7 +97,7 @@ jobs: - name: Setup Java uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'temurin' java-version: '17' cache: 'gradle' diff --git a/CLAUDE.md b/CLAUDE.md index d1bd7bc..09f8b1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,11 @@ TX (notify): 6E400003-B5A3-F393-E0A9-E50E24DCCA9E ### Key Dependencies ```yaml flutter_blue_plus: ^2.0.0 # BLE communication -flutter_map: ^8.2.2 # Mapping +flutter_map: ^8.2.2 # Mapping + WMS support provider: ^6.1.0 # State management geolocator: ^14.0.2 # GPS tracking +proj4dart: ^2.1.0 # Coordinate transformations (EPSG:3794) +flutter_map_tile_caching: ^10.1.1 # Offline tile caching ``` --- @@ -71,6 +73,7 @@ lib/ │ ├── ble/ # Connection, commands, responses │ ├── location_tracking_service.dart # GPS + broadcast │ ├── map_marker_service.dart # Marker generation +│ ├── tile_cache_service.dart # Offline tile caching (WMS + standard) │ └── validation_service.dart # Form validation ├── providers/ # State management │ ├── connection_provider.dart # BLE state @@ -86,7 +89,8 @@ lib/ │ └── map/ # Map-specific widgets └── utils/ # Utilities ├── sar_message_parser.dart - └── drawing_message_parser.dart + ├── drawing_message_parser.dart + └── slovenian_crs.dart # EPSG:3794 CRS for WMS ``` --- @@ -571,6 +575,226 @@ org.gradle.jvmargs=-Xmx4096m 2. **OpenTopoMap** - Max zoom 17, topographic 3. **ESRI World Imagery** - Max zoom 19, satellite +### WMS Support +**Purpose**: Integration with Web Map Service (WMS) providers for specialized mapping data + +**Slovenian CRS (EPSG:3794)**: +- Projection: Transverse Mercator (Slovenia 1996 / Slovene National Grid) +- Ellipsoid: GRS80 +- Usage: Slovenian government WMS/WMTS services (prostor.zgs.gov.si) +- Zoom levels: 0-15 (GeoWebCache tile matrix) +- Bounds: X: 373217.65-695777.65m, Y: 31118.30-246158.30m +- Origin: Top-left (373217.65, 246158.30) +- Resolutions: Calculated from scale denominators (420m/px at zoom 0 to 0.028m/px at zoom 15) + +**Tile Caching**: +- All WMS layers (base + overlays) use `flutter_map_tile_caching` +- Cache strategy: `cacheFirst` (offline-first with 30-day validity) +- Same caching infrastructure as standard tile layers + +**Files**: +- `lib/utils/slovenian_crs.dart` - EPSG:3794 CRS definition +- `lib/services/tile_cache_service.dart` - getTileProviderForWms() method + +**Example**: +```dart +import 'package:meshcore_sar_app/utils/slovenian_crs.dart'; + +// All WMS layers automatically use the cached tile provider +TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + layers: ['pregledovalnik:DOF_2024'], + format: 'image/jpeg', + crs: slovenianCrs, + ), + tileProvider: tileCacheService.getTileProviderForWms(layer), +) +``` + +### WMS Implementation Details + +#### Overview +The app integrates Slovenian government WMS (Web Map Service) layers using a custom EPSG:3794 coordinate reference system. This enables high-resolution aerial imagery and specialized overlays (cadastral parcels, forest roads) for SAR operations in Slovenia. + +#### Architecture + +**Layer Types**: +1. **Base Layer**: Slovenian Aerial Imagery 2024 (DOF_2024) + - Source: `https://prostor.zgs.gov.si/geowebcache/service/wms` + - Format: JPEG (better compression for aerial photos) + - Transparency: False (opaque base layer) + - Max zoom: 15 (GeoWebCache limit) + +2. **Overlay Layers**: Cadastral Parcels, Forest Roads + - Format: PNG (supports transparency) + - Transparency: True (overlays on base layer) + - Max zoom: 19 + +**Coordinate System (EPSG:3794)**: +- Official name: Slovenia 1996 / Slovene National Grid +- Projection: Transverse Mercator +- Parameters: `+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000 +ellps=GRS80` +- Why needed: Slovenian government services use this instead of standard Web Mercator (EPSG:3857) + +**Tile Grid Alignment**: +The critical challenge with WMS layers is aligning the client-side tile grid with the server's GeoWebCache configuration. Misalignment causes 400 Bad Request errors. + +**Correct Configuration** (`lib/utils/slovenian_crs.dart`): +```dart +// Origin MUST match WMTS TileMatrixSet TopLeftCorner +final origin = Point(373217.6542445397, 246158.298050262); + +// Bounds MUST match WMS capabilities extent +final bounds = Rect.fromLTRB( + 373217.65, // min X (west) + 31118.30, // min Y (south) + 695777.65, // max X (east) + 246158.30, // max Y (north) +); + +// Resolutions MUST be calculated from scale denominators +// Formula: resolution = scaleDenominator * 0.00028 (OGC standard) +final resolutions = [ + 420.0, // Zoom 0: 1,500,000 * 0.00028 + 280.0, // Zoom 1: 1,000,000 * 0.00028 + // ... through zoom 15 +]; +``` + +**How to Get Correct Values**: +1. Query WMTS GetCapabilities: + ```bash + curl "https://prostor.zgs.gov.si/geowebcache/service/wmts?REQUEST=GetCapabilities&SERVICE=WMTS" + ``` +2. Find `` for EPSG:3794 +3. Extract `` (origin) +4. Extract `` for each `` (convert to resolutions) +5. Query WMS GetCapabilities for bounds: + ```bash + curl "https://prostor.zgs.gov.si/geowebcache/service/wms?REQUEST=GetCapabilities&SERVICE=WMS" + ``` + +#### Caching Strategy + +**Implementation** (`lib/services/tile_cache_service.dart`): +```dart +FMTCTileProvider getTileProviderForWms(MapLayer layer) { + return _store.getTileProvider( + loadingStrategy: BrowseLoadingStrategy.cacheFirst, + cachedValidDuration: const Duration(days: 30), + ); +} +``` + +**Behavior**: +1. **Cache First**: Check local cache before network request +2. **30-Day Validity**: Tiles expire after 30 days (suitable for aerial imagery that updates infrequently) +3. **Automatic Caching**: All viewed tiles automatically saved to ObjectBox database +4. **Offline Support**: Cached tiles available when device offline + +**Storage Location**: +- Backend: ObjectBox (embedded database) +- Store name: 'meshcore_tiles' (shared with standard tile layers) +- Format: Binary tile data + metadata (URL, timestamp, headers) + +#### Usage in Map + +**Base Layer** (`lib/screens/map_tab.dart`): +```dart +if (_currentLayer.isWms && _currentLayer.crs != null) { + TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: _currentLayer.wmsBaseUrl!, + layers: _currentLayer.wmsLayers ?? [], + format: _currentLayer.wmsFormat ?? 'image/jpeg', + crs: _currentLayer.crs!, // EPSG:3794 + ), + tileProvider: _tileCache.getTileProviderForWms(_currentLayer), + maxZoom: _currentLayer.maxZoom, // 15 for Slovenian layers + ); +} +``` + +**Map Options**: +```dart +MapOptions( + // CRITICAL: Use layer's CRS, not default EPSG:3857 + crs: _currentLayer.crs ?? const Epsg3857(), + // ... other options +) +``` + +#### Troubleshooting + +**400 Bad Request Errors**: +- **Cause**: Tile grid misalignment (wrong origin, bounds, or resolutions) +- **Fix**: Verify values match WMTS GetCapabilities exactly +- **Debug**: Check WMS URL in error logs for out-of-bounds coordinates + +**Tiles Not Caching**: +- **Cause**: Using wrong tile provider (e.g., NetworkTileProvider instead of FMTC) +- **Fix**: Ensure `getTileProviderForWms()` is used, not `NetworkTileProvider()` or custom providers +- **Verify**: Check `tile_cache_service.dart:75` is being called + +**Layer Not Appearing**: +- **Cause 1**: Layer name mismatch (e.g., `DOF_2024` vs `pregledovalnik:DOF_2024`) +- **Cause 2**: Wrong CRS in MapOptions (using EPSG:3857 instead of EPSG:3794) +- **Fix**: Verify layer name in WMS GetCapabilities, ensure `crs: _currentLayer.crs` in MapOptions + +**Performance Issues**: +- **Issue**: Slow tile loading on first view +- **Expected**: WMS tile generation is slower than pre-rendered tiles (100-500ms per tile) +- **Mitigation**: Pre-download regions using Map Management screen + +#### Adding New WMS Layers + +1. **Find Layer in GetCapabilities**: + ```bash + curl "https://prostor.zgs.gov.si/geowebcache/service/wms?REQUEST=GetCapabilities" | grep "" + ``` + +2. **Add to MapLayer** (`lib/models/map_layer.dart`): + ```dart + static MapLayer getMyNewLayer(Crs slovenianCrs) { + return MapLayer( + type: MapLayerType.wmsBase, // or create new enum value + name: 'My New Layer', + urlTemplate: '', // Not used for WMS + attribution: '© Data Provider', + maxZoom: 15, // Match GeoWebCache capability + isWms: true, + wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + wmsLayers: const ['workspace:layername'], + wmsFormat: 'image/png', // or 'image/jpeg' + wmsTransparent: true, // true for overlays, false for base + crs: slovenianCrs, + ); + } + ``` + +3. **Verify CRS Support**: Ensure layer supports EPSG:3794 in GetCapabilities + +4. **Test**: Check for 400 errors, verify tiles load correctly + +#### Technical Notes + +**Why Not Use WMTS Instead of WMS?** +- `flutter_map` has excellent WMS support via `WMSTileLayerOptions` +- WMS and WMTS use same GeoWebCache backend (identical tiles) +- WMS is simpler to configure (no manual tile URL template) +- Caching abstracts the protocol difference + +**Proj4dart Integration**: +- Handles coordinate transformation from EPSG:4326 (GPS) to EPSG:3794 (map) +- Projection registered once at app startup: `proj4.Projection.add('EPSG:3794', ...)` +- Flutter Map uses it automatically when `crs: slovenianCrs` is set + +**Memory Considerations**: +- Each CRS instance stores transformation matrices and bounds +- Use singleton pattern: `final Crs slovenianCrs = getSlovenianCrs();` +- Shared across all WMS layers + ### Offline Caching - Backend: `flutter_map_tile_caching` + ObjectBox - Behavior: `CacheBehavior.cacheFirst`, 30-day validity @@ -604,6 +828,9 @@ MapProvider.clearNavigation() - [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md) - [Cayenne LPP Specification](https://developers.mydevices.com/cayenne/docs/lora/#lora-cayenne-low-power-payload) - [Provider Package](https://pub.dev/packages/provider) +- [EPSG:3794 Reference](https://epsg.io/3794) - Slovenian CRS definition +- [Proj4dart Package](https://pub.dev/packages/proj4dart) - Coordinate transformation library +- [OGC WMS Specification](https://www.ogc.org/standards/wms) - Web Map Service standard --- diff --git a/UNIMPLEMENTED_BLE_COMMANDS.md b/UNIMPLEMENTED_BLE_COMMANDS.md new file mode 100644 index 0000000..6f2005d --- /dev/null +++ b/UNIMPLEMENTED_BLE_COMMANDS.md @@ -0,0 +1,1725 @@ +# Unimplemented BLE Commands - MeshCore SAR + +This document catalogs all BLE commands from the MeshCore protocol that are **not yet implemented** in the Flutter application. Implementations are based on analysis of the C++ reference implementation at `/Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp`. + +**Total Commands**: 52 defined in protocol +**Implemented in Flutter**: 30 +**Not Implemented**: 22 (documented below) + +--- + +## Table of Contents + +1. [Commands Not Defined in Flutter](#commands-not-defined-in-flutter) (11 commands) +2. [Commands Defined But Not Implemented](#commands-defined-but-not-implemented) (11 commands) +3. [Implementation Priority Matrix](#implementation-priority-matrix) +4. [Quick Reference Table](#quick-reference-table) + +--- + +## Commands Not Defined in Flutter + +These commands don't exist in `lib/services/meshcore_constants.dart` at all. + +### 1. CMD_SHARE_CONTACT (16) + +**Status**: Not defined +**Priority**: Medium +**Use Case**: Share contact info with nearby mesh nodes + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (16) +- Offset 1-32: Public key (32 bytes) of contact to share + +**C++ Implementation** (`MyMesh.cpp:1059-1070`): +```cpp +else if (cmd_frame[0] == CMD_SHARE_CONTACT) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + if (shareContactZeroHop(*recipient)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } +} +``` + +**What It Does**: Broadcasts a zero-hop advertisement of a contact in the local network. Used to share another contact's information with nearby mesh nodes. + +**Error Codes**: +- `ERR_CODE_TABLE_FULL` (3): Packet pool exhausted +- `ERR_CODE_NOT_FOUND` (2): Contact not found + +**Implementation Notes**: +- Validates contact exists by public key +- Sends contact advertisement with zero hops (direct only) +- No parameters beyond public key required +- Min frame length: 33 bytes + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdShareContact = 16; + +// Frame Builder +static Uint8List buildShareContact(Uint8List contactPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdShareContact); + writer.writeBytes(contactPublicKey); // 32 bytes + return writer.toBytes(); +} + +// Service API +Future shareContact(Uint8List contactPublicKey) async { + final frame = FrameBuilder.buildShareContact(contactPublicKey); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 2. CMD_HAS_CONNECTION (28) + +**Status**: Not defined +**Priority**: High +**Use Case**: Check if radio has a path to a contact before sending + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (28) +- Offset 1-32: Public key (32 bytes) to check connection + +**C++ Implementation** (`MyMesh.cpp:1383-1389`): +```cpp +else if (cmd_frame[0] == CMD_HAS_CONNECTION && len >= 1 + PUB_KEY_SIZE) { + uint8_t *pub_key = &cmd_frame[1]; + if (hasConnectionTo(pub_key)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } +} +``` + +**What It Does**: Checks if the radio has a known path to a specific contact. Useful for app to determine reachability before sending messages. + +**Error Codes**: +- `ERR_CODE_NOT_FOUND` (2): No connection path known + +**Implementation Notes**: +- Validates minimum frame length: 33 bytes +- Returns OK if connection exists +- Does NOT verify contact is in local contact list +- Min frame length: 33 bytes + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdHasConnection = 28; + +// Frame Builder +static Uint8List buildHasConnection(Uint8List contactPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdHasConnection); + writer.writeBytes(contactPublicKey); // 32 bytes + return writer.toBytes(); +} + +// Service API +Future hasConnectionTo(Uint8List contactPublicKey) async { + final frame = FrameBuilder.buildHasConnection(contactPublicKey); + try { + await _commandSender.sendCommand(frame); + return true; // RESP_CODE_OK received + } catch (e) { + return false; // ERR_CODE_NOT_FOUND or timeout + } +} +``` + +--- + +### 3. CMD_LOGOUT (29) + +**Status**: Not defined +**Priority**: Medium +**Use Case**: Disconnect from room servers + +**Response**: `RESP_CODE_OK` (0) + +**Parameters**: +- Offset 0: Command code (29) +- Offset 1-32: Public key (32 bytes) of room/server to disconnect from + +**C++ Implementation** (`MyMesh.cpp:1390-1393`): +```cpp +else if (cmd_frame[0] == CMD_LOGOUT && len >= 1 + PUB_KEY_SIZE) { + uint8_t *pub_key = &cmd_frame[1]; + stopConnection(pub_key); + writeOKFrame(); +} +``` + +**What It Does**: Disconnects/logs out from a room server or chat service. Stops receiving automatic message pushes from the service. + +**Implementation Notes**: +- Also known as "Disconnect" per comment in header +- Always returns OK (success guaranteed) +- Calls internal `stopConnection()` to halt login/message polling +- Min frame length: 33 bytes +- Used with room-type contacts (ADV_TYPE_ROOM) + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdLogout = 29; + +// Frame Builder +static Uint8List buildLogout(Uint8List roomPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdLogout); + writer.writeBytes(roomPublicKey); // 32 bytes + return writer.toBytes(); +} + +// Service API +Future logoutFromRoom(Uint8List roomPublicKey) async { + final frame = FrameBuilder.buildLogout(roomPublicKey); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 4. CMD_GET_CONTACT_BY_KEY (30) + +**Status**: Not defined +**Priority**: Medium +**Use Case**: Retrieve full contact details by public key + +**Response**: `RESP_CODE_CONTACT` (3) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (30) +- Offset 1-32: Public key (32 bytes) to look up + +**C++ Implementation** (`MyMesh.cpp:1071-1078`): +```cpp +else if (cmd_frame[0] == CMD_GET_CONTACT_BY_KEY) { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (contact) { + writeContactRespFrame(RESP_CODE_CONTACT, *contact); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } +} +``` + +**What It Does**: Retrieves full contact information by public key. Returns all stored contact details including GPS location, path, name, etc. + +**Response Format** (`RESP_CODE_CONTACT` - 3): +- Byte 0: `RESP_CODE_CONTACT` (3) +- Bytes 1-32: Public key (32 bytes) +- Byte 33: Contact type (ADV_TYPE_*) +- Byte 34: Flags +- Byte 35: Out path length +- Bytes 36-77: Out path (MAX_PATH_SIZE = 64) +- Bytes 78-109: Contact name (32 bytes, null-padded) +- Bytes 110-113: Last advertisement timestamp (uint32_t LE) +- Bytes 114-117: GPS latitude (int32_t LE, 1E-6 scale) +- Bytes 118-121: GPS longitude (int32_t LE, 1E-6 scale) + +**Error Codes**: +- `ERR_CODE_NOT_FOUND` (2): Contact not found + +**Implementation Notes**: +- Simple lookup-only operation, no side effects +- Returns complete contact information +- Min frame length: 33 bytes +- Useful when you have a public key but need full contact details + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdGetContactByKey = 30; + +// Frame Builder +static Uint8List buildGetContactByKey(Uint8List contactPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetContactByKey); + writer.writeBytes(contactPublicKey); // 32 bytes + return writer.toBytes(); +} + +// Service API (use existing parseContact from FrameParser) +Future getContactByKey(Uint8List contactPublicKey) async { + final frame = FrameBuilder.buildGetContactByKey(contactPublicKey); + final completer = Completer(); + + // Wait for RESP_CODE_CONTACT (3) + _responseHandler.onContactReceived = (contact) { + completer.complete(contact); + }; + + await _commandSender.sendCommand(frame); + return completer.future.timeout(Duration(seconds: 5)); +} +``` + +--- + +### 5. CMD_SET_DEVICE_PIN (37) + +**Status**: Not defined +**Priority**: Low +**Use Case**: Secure BLE pairing with PIN + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (37) +- Offset 1-4: BLE PIN (uint32_t LE) - either 0 (disable) or 100000-999999 (6-digit PIN) + +**C++ Implementation** (`MyMesh.cpp:1475-1488`): +```cpp +else if (cmd_frame[0] == CMD_SET_DEVICE_PIN && len >= 5) { + uint32_t pin; + memcpy(&pin, &cmd_frame[1], 4); + if (pin == 0 || (pin >= 100000 && pin <= 999999)) { + _prefs.ble_pin = pin; + savePrefs(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } +} +``` + +**What It Does**: Sets or disables the BLE pairing PIN code for the radio device. Used to require a PIN for BLE connections. + +**Error Codes**: +- `ERR_CODE_ILLEGAL_ARG` (6): Invalid PIN (not 0 or 100000-999999) + +**Implementation Notes**: +- Min frame length: 5 bytes +- Validates PIN: must be 0 (disabled) or 6-digit number (100000-999999) +- Persisted to device preferences/EEPROM +- Requires `savePrefs()` to persist to storage + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdSetDevicePin = 37; + +// Frame Builder +static Uint8List buildSetDevicePin(int pin) { + if (pin != 0 && (pin < 100000 || pin > 999999)) { + throw ArgumentError('PIN must be 0 (disabled) or 6-digit (100000-999999)'); + } + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetDevicePin); + writer.writeUInt32LE(pin); + return writer.toBytes(); +} + +// Service API +Future setDevicePin(int pin) async { + final frame = FrameBuilder.buildSetDevicePin(pin); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 6. CMD_GET_CUSTOM_VARS (40) + +**Status**: Not defined +**Priority**: Low +**Use Case**: Read device-specific sensor settings + +**Response**: `RESP_CODE_CUSTOM_VARS` (21) + +**Parameters**: +- Offset 0: Command code (40) +- No additional parameters + +**C++ Implementation** (`MyMesh.cpp:1489-1502`): +```cpp +else if (cmd_frame[0] == CMD_GET_CUSTOM_VARS) { + out_frame[0] = RESP_CODE_CUSTOM_VARS; + char *dp = (char *)&out_frame[1]; + for (int i = 0; i < sensors.getNumSettings() && dp - (char *)&out_frame[1] < 140; i++) { + if (i > 0) { + *dp++ = ','; + } + strcpy(dp, sensors.getSettingName(i)); + dp = strchr(dp, 0); + *dp++ = ':'; + strcpy(dp, sensors.getSettingValue(i)); + dp = strchr(dp, 0); + } + _serial->writeFrame(out_frame, dp - (char *)out_frame); +} +``` + +**What It Does**: Returns all custom sensor/device configuration variables and their current values. Used to expose device-specific settings. + +**Response Format**: +- Byte 0: `RESP_CODE_CUSTOM_VARS` (21) +- Bytes 1+: Comma-separated key:value pairs (variable length, max ~140 chars) + - Format: `name1:value1,name2:value2,name3:value3` + - Each pair separated by comma + - Key and value separated by colon + - Max buffer: 141 bytes total + +**Implementation Notes**: +- Calls `sensors.getNumSettings()` to enumerate available settings +- Stops building if buffer reaches 140 bytes +- No input validation needed (no parameters) +- Response is variable length + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdGetCustomVars = 40; +static const int respCustomVars = 21; + +// Frame Builder +static Uint8List buildGetCustomVars() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetCustomVars); + return writer.toBytes(); +} + +// Frame Parser +static Map parseCustomVars(BufferReader reader) { + final csvData = reader.readRemainingBytes(); + final csvString = utf8.decode(csvData); + final vars = {}; + + for (final pair in csvString.split(',')) { + final parts = pair.split(':'); + if (parts.length == 2) { + vars[parts[0]] = parts[1]; + } + } + return vars; +} + +// Service API +Future> getCustomVars() async { + final frame = FrameBuilder.buildGetCustomVars(); + // TODO: Implement response handler for RESP_CODE_CUSTOM_VARS + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 7. CMD_SET_CUSTOM_VAR (41) + +**Status**: Not defined +**Priority**: Low +**Use Case**: Configure device-specific sensor settings + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (41) +- Offset 1+: Setting as "name:value" string (null-terminated) + +**C++ Implementation** (`MyMesh.cpp:1503-1517`): +```cpp +else if (cmd_frame[0] == CMD_SET_CUSTOM_VAR && len >= 4) { + cmd_frame[len] = 0; // null terminate + char *sp = (char *)&cmd_frame[1]; + char *np = strchr(sp, ':'); + if (np) { + *np++ = 0; + bool success = sensors.setSettingValue(sp, np); + if (success) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } +} +``` + +**What It Does**: Sets a custom sensor/device configuration variable to a new value. Modifies device-specific settings. + +**Error Codes**: +- `ERR_CODE_ILLEGAL_ARG` (6): No ':' separator found or `setSettingValue()` failed + +**Implementation Notes**: +- Min frame length: 4 bytes +- Format: "name:value" (colon-separated) +- Parses by looking for ':' separator character +- No persistence guarantee - depends on `sensors` implementation + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdSetCustomVar = 41; + +// Frame Builder +static Uint8List buildSetCustomVar(String name, String value) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetCustomVar); + writer.writeString('$name:$value'); + return writer.toBytes(); +} + +// Service API +Future setCustomVar(String name, String value) async { + final frame = FrameBuilder.buildSetCustomVar(name, value); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 8. CMD_GET_ADVERT_PATH (42) + +**Status**: Not defined +**Priority**: Medium +**Use Case**: Network topology analysis and debugging + +**Response**: `RESP_CODE_ADVERT_PATH` (22) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (42) +- Offset 1: Reserved (for future use) +- Offset 2-8: Public key prefix (7 bytes) of advertised node + +**C++ Implementation** (`MyMesh.cpp:1518-1537`): +```cpp +else if (cmd_frame[0] == CMD_GET_ADVERT_PATH && len >= PUB_KEY_SIZE+2) { + uint8_t *pub_key = &cmd_frame[2]; + AdvertPath* found = NULL; + for (int i = 0; i < ADVERT_PATH_TABLE_SIZE; i++) { + auto p = &advert_paths[i]; + if (memcmp(p->pubkey_prefix, pub_key, sizeof(p->pubkey_prefix)) == 0) { + found = p; + break; + } + } + if (found) { + out_frame[0] = RESP_CODE_ADVERT_PATH; + memcpy(&out_frame[1], &found->recv_timestamp, 4); + out_frame[5] = found->path_len; + memcpy(&out_frame[6], found->path, found->path_len); + _serial->writeFrame(out_frame, 6 + found->path_len); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } +} +``` + +**What It Does**: Returns the wireless path from which a node's advertisement was last received. Used to analyze network topology and signal paths. + +**Response Format**: +- Byte 0: `RESP_CODE_ADVERT_PATH` (22) +- Bytes 1-4: Reception timestamp (uint32_t LE) +- Byte 5: Path length (number of hops) +- Bytes 6+: Path data (variable length, max MAX_PATH_SIZE) + +**Error Codes**: +- `ERR_CODE_NOT_FOUND` (2): Node not in recent advertisements + +**Implementation Notes**: +- Min frame length: 35 bytes (32 for pubkey + 2 for cmd + reserved) +- Searches circular table of size ADVERT_PATH_TABLE_SIZE (16 entries) +- Matches only first 7 bytes of public key (pubkey_prefix) +- Timestamp is when advertisement was received +- Table is circular and overwrites oldest entries + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdGetAdvertPath = 42; +static const int respAdvertPath = 22; + +// Frame Builder +static Uint8List buildGetAdvertPath(Uint8List publicKeyPrefix) { + if (publicKeyPrefix.length < 7) { + throw ArgumentError('Public key prefix must be at least 7 bytes'); + } + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetAdvertPath); + writer.writeByte(0); // Reserved + writer.writeBytes(publicKeyPrefix.sublist(0, 7)); + return writer.toBytes(); +} + +// Frame Parser +static Map parseAdvertPath(BufferReader reader) { + final timestamp = reader.readUInt32LE(); + final pathLen = reader.readByte(); + final path = reader.readBytes(pathLen); + return { + 'timestamp': timestamp, + 'pathLen': pathLen, + 'path': path, + }; +} +``` + +--- + +### 9. CMD_GET_TUNING_PARAMS (43) + +**Status**: Not defined +**Priority**: Medium +**Use Case**: Read mesh network timing configuration + +**Response**: `RESP_CODE_TUNING_PARAMS` (23) + +**Parameters**: +- Offset 0: Command code (43) +- No additional parameters + +**C++ Implementation** (`MyMesh.cpp:1175-1181`): +```cpp +else if (cmd_frame[0] == CMD_GET_TUNING_PARAMS) { + uint32_t rx = _prefs.rx_delay_base * 1000, af = _prefs.airtime_factor * 1000; + int i = 0; + out_frame[i++] = RESP_CODE_TUNING_PARAMS; + memcpy(&out_frame[i], &rx, 4); i += 4; + memcpy(&out_frame[i], &af, 4); i += 4; + _serial->writeFrame(out_frame, i); +} +``` + +**What It Does**: Returns mesh network tuning parameters - base RX delay and airtime factor. These control message retransmission timing. + +**Response Format**: +- Byte 0: `RESP_CODE_TUNING_PARAMS` (23) +- Bytes 1-4: RX delay base (uint32_t LE, in milliseconds) +- Bytes 5-8: Airtime factor (uint32_t LE, scaled by 1000) + +**Implementation Notes**: +- No input parameters +- Converts internal floats (milliseconds/factor) to uint32_t by multiplying by 1000 +- Values allow app to understand current mesh timing constraints +- Related to `CMD_SET_TUNING_PARAMS` for configuration +- Pair command with code 21 + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdGetTuningParams = 43; +static const int respTuningParams = 23; + +// Frame Builder +static Uint8List buildGetTuningParams() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdGetTuningParams); + return writer.toBytes(); +} + +// Frame Parser +static Map parseTuningParams(BufferReader reader) { + final rxDelayMs = reader.readUInt32LE(); + final airtimeFactor = reader.readUInt32LE(); + return { + 'rxDelayBase': rxDelayMs / 1000.0, // Convert back to seconds + 'airtimeFactor': airtimeFactor / 1000.0, + }; +} +``` + +--- + +### 10. CMD_FACTORY_RESET (51) + +**Status**: Not defined +**Priority**: Low (destructive) +**Use Case**: Complete device reset + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1), then device reboots + +**Parameters**: +- Offset 0: Command code (51) +- Offset 1-5: Magic string "reset" (required for safety) + +**C++ Implementation** (`MyMesh.cpp:1538-1546`): +```cpp +else if (cmd_frame[0] == CMD_FACTORY_RESET && memcmp(&cmd_frame[1], "reset", 5) == 0) { + bool success = _store->formatFileSystem(); + if (success) { + writeOKFrame(); + delay(1000); + board.reboot(); // doesn't return + } else { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } +} +``` + +**What It Does**: Performs complete factory reset - erases all file system data (contacts, messages, settings) and reboots device. **DESTRUCTIVE** operation. + +**Error Codes**: +- `ERR_CODE_FILE_IO_ERROR` (5): Erase failed + +**Implementation Notes**: +- Safety check: requires magic string "reset" at offset 1-5 +- Min frame length: 6 bytes +- Erases entire file system via `_store->formatFileSystem()` +- Does not preserve identity/private key - full reset +- Device reboots after 1-second delay (doesn't return from function) +- **CRITICAL**: No recovery possible after execution + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdFactoryReset = 51; + +// Frame Builder +static Uint8List buildFactoryReset() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdFactoryReset); + writer.writeString('reset'); // Magic string + return writer.toBytes(); +} + +// Service API with confirmation dialog +Future factoryReset() async { + // IMPORTANT: Show user confirmation dialog first! + final confirmed = await showConfirmationDialog( + title: 'Factory Reset', + message: 'This will erase ALL data and reboot the device. Continue?', + destructive: true, + ); + + if (!confirmed) return; + + final frame = FrameBuilder.buildFactoryReset(); + await _commandSender.sendCommand(frame); + // Device will reboot, connection will be lost +} +``` + +--- + +### 11. CMD_SEND_PATH_DISCOVERY_REQ (52) + +**Status**: Not defined +**Priority**: Medium +**Use Case**: Network topology discovery + +**Response**: `RESP_CODE_SENT` (6) + +**Parameters**: +- Offset 0: Command code (52) +- Offset 1: Flags byte (currently only 0 is supported) +- Offset 2-33: Public key (32 bytes) of target node + +**C++ Implementation** (`MyMesh.cpp:1298-1326`): +```cpp +else if (cmd_frame[0] == CMD_SEND_PATH_DISCOVERY_REQ && cmd_frame[1] == 0 && len >= 2 + PUB_KEY_SIZE) { + uint8_t *pub_key = &cmd_frame[2]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (recipient) { + uint32_t tag, est_timeout; + uint8_t req_data[9]; + req_data[0] = REQ_TYPE_GET_TELEMETRY_DATA; + req_data[1] = ~(TELEM_PERM_BASE); + memset(&req_data[2], 0, 3); + getRNG()->random(&req_data[5], 4); + auto save = recipient->out_path_len; + recipient->out_path_len = -1; // force flood + int result = sendRequest(*recipient, req_data, sizeof(req_data), tag, est_timeout); + recipient->out_path_len = save; + if (result == MSG_SEND_FAILED) { + writeErrFrame(ERR_CODE_TABLE_FULL); + } else { + clearPendingReqs(); + pending_discovery = tag; + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = (result == MSG_SEND_SENT_FLOOD) ? 1 : 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); + } + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } +} +``` + +**What It Does**: Sends a special telemetry request to discover paths to a target node. Forces flood routing to explore network topology and find all available paths. + +**Response Format** (`RESP_CODE_SENT` - 6): +- Byte 0: `RESP_CODE_SENT` (6) +- Byte 1: Flood flag (1 = flooded, 0 = direct) +- Bytes 2-5: Request tag (uint32_t LE) - used to match responses +- Bytes 6-9: Estimated timeout (uint32_t LE, milliseconds) + +**Error Codes**: +- `ERR_CODE_NOT_FOUND` (2): Contact not found +- `ERR_CODE_TABLE_FULL` (3): Packet pool exhausted + +**Implementation Notes**: +- Min frame length: 35 bytes +- Flags byte must be 0 (only current valid value) +- Temporarily forces contact's path to -1 (flood mode) +- Includes telemetry request type with inverted BASE permission mask +- Adds 4 random bytes to make packet unique +- Clears any pending requests before sending +- Stores tag in `pending_discovery` for response matching + +**Flutter Implementation Guide**: +```dart +// Constants +static const int cmdSendPathDiscoveryReq = 52; + +// Frame Builder +static Uint8List buildSendPathDiscoveryReq(Uint8List contactPublicKey) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendPathDiscoveryReq); + writer.writeByte(0); // Flags (must be 0) + writer.writeBytes(contactPublicKey); // 32 bytes + return writer.toBytes(); +} + +// Service API (reuses existing parseSentConfirmation) +Future> discoverPathsTo(Uint8List contactPublicKey) async { + final frame = FrameBuilder.buildSendPathDiscoveryReq(contactPublicKey); + // Wait for RESP_CODE_SENT with tag + await _commandSender.sendCommand(frame); + // Returns: {expectedAckTag, suggestedTimeout, isFloodMode} +} +``` + +--- + +## Commands Defined But Not Implemented + +These commands are defined in `lib/services/meshcore_constants.dart` but have no FrameBuilder method or service API. + +### 12. CMD_EXPORT_CONTACT (17) + +**Status**: Defined (`meshcore_constants.dart:31`) +**Priority**: Medium +**Use Case**: Backup/share contacts in portable format + +**Response**: `RESP_CODE_EXPORT_CONTACT` (11) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (17) +- Offset 1-32: Public key (32 bytes) - optional; if missing, exports SELF + +**C++ Implementation** (`MyMesh.cpp:1079-1108`): +```cpp +else if (cmd_frame[0] == CMD_EXPORT_CONTACT) { + if (len < 1 + PUB_KEY_SIZE) { + // export SELF + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt) { + pkt->header |= ROUTE_TYPE_FLOOD; + out_frame[0] = RESP_CODE_EXPORT_CONTACT; + uint8_t out_len = pkt->writeTo(&out_frame[1]); + releasePacket(pkt); + _serial->writeFrame(out_frame, out_len + 1); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + } else { + uint8_t *pub_key = &cmd_frame[1]; + ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + uint8_t out_len; + if (recipient && (out_len = exportContact(*recipient, &out_frame[1])) > 0) { + out_frame[0] = RESP_CODE_EXPORT_CONTACT; + _serial->writeFrame(out_frame, out_len + 1); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); + } + } +} +``` + +**What It Does**: Exports a contact (or self) in mesh packet format. Used to share contact information in a portable, encrypted format. + +**Response Format**: +- Byte 0: `RESP_CODE_EXPORT_CONTACT` (11) +- Bytes 1+: Serialized mesh packet (variable length) + +**Error Codes**: +- `ERR_CODE_TABLE_FULL` (3): Packet pool exhausted (self export) +- `ERR_CODE_NOT_FOUND` (2): Contact not found + +**Implementation Notes**: +- Two modes: with/without pubkey parameter +- If no pubkey (len < 33): exports self advertisement + - Respects `advert_loc_policy` (include GPS or not) + - Sets ROUTE_TYPE_FLOOD flag in packet header +- If pubkey provided: exports stored contact via `exportContact()` +- Variable response length based on packet data +- Packet is serialized and ready to transmit + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdExportContact = 17; +static const int respExportContact = 11; + +// Frame Builder +static Uint8List buildExportContact({Uint8List? contactPublicKey}) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdExportContact); + if (contactPublicKey != null) { + writer.writeBytes(contactPublicKey); // 32 bytes + } + // If no pubkey, exports SELF + return writer.toBytes(); +} + +// Frame Parser +static Uint8List parseExportContact(BufferReader reader) { + // Returns serialized packet data + return reader.readRemainingBytes(); +} + +// Service API +Future exportContact({Uint8List? contactPublicKey}) async { + final frame = FrameBuilder.buildExportContact(contactPublicKey: contactPublicKey); + // TODO: Wait for RESP_CODE_EXPORT_CONTACT and return packet data + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 13. CMD_IMPORT_CONTACT (18) + +**Status**: Defined (`meshcore_constants.dart:32`) +**Priority**: Medium +**Use Case**: Restore/import contacts from portable format + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (18) +- Offset 1+: Serialized contact packet (min 97 bytes: 1 cmd + 32 pubkey + 64 signature) + +**C++ Implementation** (`MyMesh.cpp:1109-1114`): +```cpp +else if (cmd_frame[0] == CMD_IMPORT_CONTACT && len > 2 + 32 + 64) { + if (importContact(&cmd_frame[1], len - 1)) { + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } +} +``` + +**What It Does**: Imports a contact from a serialized mesh packet. Parses and validates contact data, then adds to local contact list. + +**Error Codes**: +- `ERR_CODE_ILLEGAL_ARG` (6): Packet is invalid/malformed + +**Implementation Notes**: +- Min frame length: 98 bytes (1 cmd + 97 packet minimum) +- Validates packet format (32-byte pubkey, 64-byte signature minimum) +- Calls internal `importContact()` to parse and store +- Contact is added to local storage if successful +- Opposite of `CMD_EXPORT_CONTACT` + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdImportContact = 18; + +// Frame Builder +static Uint8List buildImportContact(Uint8List packetData) { + if (packetData.length < 96) { // 32 pubkey + 64 signature minimum + throw ArgumentError('Contact packet too small (min 96 bytes)'); + } + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdImportContact); + writer.writeBytes(packetData); + return writer.toBytes(); +} + +// Service API +Future importContact(Uint8List packetData) async { + final frame = FrameBuilder.buildImportContact(packetData); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 14. CMD_REBOOT (19) + +**Status**: Defined (`meshcore_constants.dart:33`) +**Priority**: Low +**Use Case**: Restart radio device + +**Response**: None (device reboots) + +**Parameters**: +- Offset 0: Command code (19) +- Offset 1-6: Magic string "reboot" (required for safety) + +**C++ Implementation** (`MyMesh.cpp:1198-1202`): +```cpp +else if (cmd_frame[0] == CMD_REBOOT && memcmp(&cmd_frame[1], "reboot", 6) == 0) { + if (dirty_contacts_expiry) { + saveContacts(); + } + board.reboot(); +} +``` + +**What It Does**: Reboots the radio device. Gracefully saves any pending contact changes before restart. + +**Implementation Notes**: +- Safety check: requires magic string "reboot" at offset 1-6 +- Min frame length: 7 bytes +- Checks for pending contact writes (dirty_contacts_expiry) +- Saves contacts if needed before rebooting +- Calls `board.reboot()` which doesn't return +- Device goes offline immediately (no response sent) + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdReboot = 19; + +// Frame Builder +static Uint8List buildReboot() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdReboot); + writer.writeString('reboot'); // Magic string + return writer.toBytes(); +} + +// Service API +Future rebootDevice() async { + final frame = FrameBuilder.buildReboot(); + await _commandSender.sendCommand(frame); + // Device will reboot, connection will be lost + // App should handle disconnection gracefully +} +``` + +--- + +### 15. CMD_SET_TUNING_PARAMS (21) + +**Status**: Defined (`meshcore_constants.dart:35`) +**Priority**: Medium +**Use Case**: Configure mesh network timing + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (21) +- Offset 1-4: RX delay base (uint32_t LE, milliseconds, scaled by 1000) +- Offset 5-8: Airtime factor (uint32_t LE, scaled by 1000) + +**C++ Implementation** (`MyMesh.cpp:1164-1174`): +```cpp +else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) { + int i = 1; + uint32_t rx, af; + memcpy(&rx, &cmd_frame[i], 4); i += 4; + memcpy(&af, &cmd_frame[i], 4); i += 4; + _prefs.rx_delay_base = ((float)rx) / 1000.0f; + _prefs.airtime_factor = ((float)af) / 1000.0f; + savePrefs(); + writeOKFrame(); +} +``` + +**What It Does**: Configures mesh network tuning parameters - RX delay and airtime factor. Controls message retransmission behavior and timeout calculations. + +**Implementation Notes**: +- Min frame length: 9 bytes +- RX delay base: milliseconds, stored as float by dividing by 1000 +- Airtime factor: stored as float by dividing by 1000 +- Values control flooding and direct message timeout calculations +- Always persists to preferences via `savePrefs()` +- No validation of ranges (accepts any uint32_t values) +- Pair command: `CMD_GET_TUNING_PARAMS` to read current values + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdSetTuningParams = 21; + +// Frame Builder +static Uint8List buildSetTuningParams({ + required double rxDelayBase, // seconds + required double airtimeFactor, +}) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSetTuningParams); + writer.writeUInt32LE((rxDelayBase * 1000).round()); // Convert to ms + writer.writeUInt32LE((airtimeFactor * 1000).round()); + return writer.toBytes(); +} + +// Service API +Future setTuningParams({ + required double rxDelayBase, + required double airtimeFactor, +}) async { + final frame = FrameBuilder.buildSetTuningParams( + rxDelayBase: rxDelayBase, + airtimeFactor: airtimeFactor, + ); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 16. CMD_EXPORT_PRIVATE_KEY (23) + +**Status**: Defined (`meshcore_constants.dart:37`) +**Priority**: Low (security risk) +**Use Case**: Device migration/backup + +**Response**: `RESP_CODE_PRIVATE_KEY` (14) or `RESP_CODE_DISABLED` (15) + +**Parameters**: +- Offset 0: Command code (23) +- No additional parameters + +**C++ Implementation** (`MyMesh.cpp:1214-1222`): +```cpp +else if (cmd_frame[0] == CMD_EXPORT_PRIVATE_KEY) { +#if ENABLE_PRIVATE_KEY_EXPORT + uint8_t reply[65]; + reply[0] = RESP_CODE_PRIVATE_KEY; + self_id.writeTo(&reply[1], 64); + _serial->writeFrame(reply, 65); +#else + writeDisabledFrame(); +#endif +} +``` + +**What It Does**: Exports the device's private key/identity. Used for backup or device migration. Can be disabled at compile-time for security. + +**Response Format**: +- Byte 0: `RESP_CODE_PRIVATE_KEY` (14) +- Bytes 1-64: Serialized identity (64 bytes from self_id.writeTo()) + +**Implementation Notes**: +- No input parameters +- Guarded by compile-time flag `ENABLE_PRIVATE_KEY_EXPORT` +- If disabled, returns `RESP_CODE_DISABLED` (15) instead +- Exports complete private identity +- **SECURITY RISK**: Exposes private key over BLE +- Response always exactly 65 bytes when enabled + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdExportPrivateKey = 23; +// static const int respPrivateKey = 14; +// static const int respDisabled = 15; + +// Frame Builder +static Uint8List buildExportPrivateKey() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdExportPrivateKey); + return writer.toBytes(); +} + +// Frame Parser +static Uint8List parsePrivateKey(BufferReader reader) { + return reader.readBytes(64); // 64-byte identity +} + +// Service API +Future exportPrivateKey() async { + final frame = FrameBuilder.buildExportPrivateKey(); + // TODO: Handle RESP_CODE_PRIVATE_KEY or RESP_CODE_DISABLED + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 17. CMD_IMPORT_PRIVATE_KEY (24) + +**Status**: Defined (`meshcore_constants.dart:38`) +**Priority**: Low (security risk) +**Use Case**: Device migration/restore + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) or `RESP_CODE_DISABLED` (15) + +**Parameters**: +- Offset 0: Command code (24) +- Offset 1-64: Serialized identity (64 bytes) + +**C++ Implementation** (`MyMesh.cpp:1223-1238`): +```cpp +else if (cmd_frame[0] == CMD_IMPORT_PRIVATE_KEY && len >= 65) { +#if ENABLE_PRIVATE_KEY_IMPORT + mesh::LocalIdentity identity; + identity.readFrom(&cmd_frame[1], 64); + if (_store->saveMainIdentity(identity)) { + self_id = identity; + writeOKFrame(); + resetContacts(); + _store->loadContacts(this); + } else { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } +#else + writeDisabledFrame(); +#endif +} +``` + +**What It Does**: Imports a private key/identity from backup or migration. Replaces device identity and reloads all contacts. + +**Error Codes**: +- `ERR_CODE_FILE_IO_ERROR` (5): Save failed + +**Implementation Notes**: +- Min frame length: 65 bytes +- Guarded by compile-time flag `ENABLE_PRIVATE_KEY_IMPORT` +- If disabled, returns `RESP_CODE_DISABLED` (15) +- Parses 64-byte identity via `identity.readFrom()` +- Persists to storage via `_store->saveMainIdentity()` +- Updates internal `self_id` object +- Calls `resetContacts()` to clear existing contacts +- Reloads contacts from storage (recalculates shared secrets) +- **SECURITY RISK**: Changes device identity +- **SIDE EFFECT**: Clears and reloads contact list + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdImportPrivateKey = 24; + +// Frame Builder +static Uint8List buildImportPrivateKey(Uint8List identity) { + if (identity.length != 64) { + throw ArgumentError('Identity must be exactly 64 bytes'); + } + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdImportPrivateKey); + writer.writeBytes(identity); + return writer.toBytes(); +} + +// Service API +Future importPrivateKey(Uint8List identity) async { + final frame = FrameBuilder.buildImportPrivateKey(identity); + await _commandSender.sendCommand(frame); + // Device identity changed, contacts will reload +} +``` + +--- + +### 18. CMD_SEND_RAW_DATA (25) + +**Status**: Defined (`meshcore_constants.dart:39`) +**Priority**: Low (advanced use) +**Use Case**: Custom protocol development + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (25) +- Offset 1: Path length (-1 for flood, 0+ for direct path) +- Offset 2 to 2+pathlen: Path data (if path_len >= 0) +- Offset 2+pathlen+: Raw payload (min 4 bytes) + +**C++ Implementation** (`MyMesh.cpp:1239-1254`): +```cpp +else if (cmd_frame[0] == CMD_SEND_RAW_DATA && len >= 6) { + int i = 1; + int8_t path_len = cmd_frame[i++]; + if (path_len >= 0 && i + path_len + 4 <= len) { + uint8_t *path = &cmd_frame[i]; + i += path_len; + auto pkt = createRawData(&cmd_frame[i], len - i); + if (pkt) { + sendDirect(pkt, path, path_len); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } + } else { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); + } +} +``` + +**What It Does**: Sends raw binary data directly to a contact via specific path. Low-level packet transmission for custom protocols. + +**Error Codes**: +- `ERR_CODE_TABLE_FULL` (3): Packet pool exhausted +- `ERR_CODE_UNSUPPORTED_CMD` (1): Flood mode not supported (path_len == -1) + +**Implementation Notes**: +- Min frame length: 6 bytes +- Path length validation: must be >= 0 (flood not supported yet) +- Validates sufficient payload: min 4 bytes after path +- Validates frame length: i + path_len + 4 <= len +- Creates raw data packet via `createRawData()` +- Sends directly (not flood) via `sendDirect()` +- Currently **ONLY** supports direct path sending (path_len >= 0) + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdSendRawData = 25; + +// Frame Builder +static Uint8List buildSendRawData({ + required Uint8List path, + required Uint8List payload, +}) { + if (payload.length < 4) { + throw ArgumentError('Payload must be at least 4 bytes'); + } + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendRawData); + writer.writeByte(path.length); // Path length (must be >= 0) + writer.writeBytes(path); + writer.writeBytes(payload); + return writer.toBytes(); +} + +// Service API +Future sendRawData({ + required Uint8List path, + required Uint8List payload, +}) async { + final frame = FrameBuilder.buildSendRawData(path: path, payload: payload); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 19. CMD_SIGN_START (33) + +**Status**: Defined (`meshcore_constants.dart:43`) +**Priority**: Low (advanced use) +**Use Case**: Digital signatures for large data + +**Response**: `RESP_CODE_SIGN_START` (19) + +**Parameters**: +- Offset 0: Command code (33) +- No additional parameters + +**C++ Implementation** (`MyMesh.cpp:1423-1434`): +```cpp +else if (cmd_frame[0] == CMD_SIGN_START) { + out_frame[0] = RESP_CODE_SIGN_START; + out_frame[1] = 0; // reserved + uint32_t len = MAX_SIGN_DATA_LEN; + memcpy(&out_frame[2], &len, 4); + _serial->writeFrame(out_frame, 6); + + if (sign_data) { + free(sign_data); + } + sign_data = (uint8_t *)malloc(MAX_SIGN_DATA_LEN); + sign_data_len = 0; +} +``` + +**What It Does**: Initiates a multi-packet digital signature operation. Allocates buffer and resets state for accumulating data to sign. + +**Response Format**: +- Byte 0: `RESP_CODE_SIGN_START` (19) +- Byte 1: Reserved (0) +- Bytes 2-5: Maximum data length (uint32_t LE) + +**Implementation Notes**: +- No input parameters +- Always allocates MAX_SIGN_DATA_LEN bytes (8K per #define) +- Frees any previous sign_data buffer +- Initializes sign_data_len to 0 +- Response always 6 bytes +- Max signature data: 8192 bytes +- Must be followed by `CMD_SIGN_DATA` calls, then `CMD_SIGN_FINISH` +- Overwrites any previous signing session + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdSignStart = 33; +// static const int respSignStart = 19; + +// Frame Builder +static Uint8List buildSignStart() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSignStart); + return writer.toBytes(); +} + +// Frame Parser +static int parseSignStart(BufferReader reader) { + reader.readByte(); // Skip reserved + return reader.readUInt32LE(); // Max data length +} + +// Service API +Future signStart() async { + final frame = FrameBuilder.buildSignStart(); + // TODO: Wait for RESP_CODE_SIGN_START and return max length + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 20. CMD_SIGN_DATA (34) + +**Status**: Defined (`meshcore_constants.dart:44`) +**Priority**: Low (advanced use) +**Use Case**: Accumulate data for digital signature + +**Response**: `RESP_CODE_OK` (0) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (34) +- Offset 1+: Data chunk to accumulate for signing + +**C++ Implementation** (`MyMesh.cpp:1435-1442`): +```cpp +else if (cmd_frame[0] == CMD_SIGN_DATA && len > 1) { + if (sign_data == NULL || sign_data_len + (len - 1) > MAX_SIGN_DATA_LEN) { + writeErrFrame(sign_data == NULL ? ERR_CODE_BAD_STATE : ERR_CODE_TABLE_FULL); + } else { + memcpy(&sign_data[sign_data_len], &cmd_frame[1], len - 1); + sign_data_len += (len - 1); + writeOKFrame(); + } +} +``` + +**What It Does**: Accumulates data chunks to be digitally signed. Can be called multiple times to build up large data blocks. + +**Error Codes**: +- `ERR_CODE_BAD_STATE` (4): Not initialized (sign_data == NULL) +- `ERR_CODE_TABLE_FULL` (3): Accumulated data exceeds MAX_SIGN_DATA_LEN (8K) + +**Implementation Notes**: +- Min frame length: 2 bytes +- Requires `CMD_SIGN_START` to be called first +- Appends data_chunk to sign_data buffer +- Data size: len - 1 bytes (excluding command byte) +- Can be called multiple times to accumulate full message +- No response data, just success/error code + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdSignData = 34; + +// Frame Builder +static Uint8List buildSignData(Uint8List dataChunk) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSignData); + writer.writeBytes(dataChunk); + return writer.toBytes(); +} + +// Service API +Future signData(Uint8List dataChunk) async { + final frame = FrameBuilder.buildSignData(dataChunk); + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 21. CMD_SIGN_FINISH (35) + +**Status**: Defined (`meshcore_constants.dart:45`) +**Priority**: Low (advanced use) +**Use Case**: Complete digital signature operation + +**Response**: `RESP_CODE_SIGNATURE` (20) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (35) +- No additional parameters + +**C++ Implementation** (`MyMesh.cpp:1443-1454`): +```cpp +else if (cmd_frame[0] == CMD_SIGN_FINISH) { + if (sign_data) { + self_id.sign(&out_frame[1], sign_data, sign_data_len); + + free(sign_data); + sign_data = NULL; + + out_frame[0] = RESP_CODE_SIGNATURE; + _serial->writeFrame(out_frame, 1 + SIGNATURE_SIZE); + } else { + writeErrFrame(ERR_CODE_BAD_STATE); + } +} +``` + +**What It Does**: Completes the signing operation. Generates digital signature over accumulated data and returns result. + +**Response Format**: +- Byte 0: `RESP_CODE_SIGNATURE` (20) +- Bytes 1+: Digital signature (SIGNATURE_SIZE bytes) + +**Error Codes**: +- `ERR_CODE_BAD_STATE` (4): Not initialized (sign_data == NULL) + +**Implementation Notes**: +- No input parameters +- Requires `CMD_SIGN_START` and one or more `CMD_SIGN_DATA` calls +- Signs accumulated data via `self_id.sign()` +- Frees sign_data buffer after signing +- Response length: 1 + SIGNATURE_SIZE bytes +- Signs all accumulated bytes from CMD_SIGN_DATA calls +- Signature uses device's private key (self_id) + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdSignFinish = 35; +// static const int respSignature = 20; + +// Frame Builder +static Uint8List buildSignFinish() { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSignFinish); + return writer.toBytes(); +} + +// Frame Parser +static Uint8List parseSignature(BufferReader reader) { + // Returns signature bytes (SIGNATURE_SIZE) + return reader.readRemainingBytes(); +} + +// Service API +Future signFinish() async { + final frame = FrameBuilder.buildSignFinish(); + // TODO: Wait for RESP_CODE_SIGNATURE and return signature + await _commandSender.sendCommand(frame); +} +``` + +--- + +### 22. CMD_SEND_TRACE_PATH (36) + +**Status**: Defined (`meshcore_constants.dart:47`) +**Priority**: Medium +**Use Case**: Network diagnostics and topology analysis + +**Response**: `RESP_CODE_SENT` (6) or `RESP_CODE_ERR` (1) + +**Parameters**: +- Offset 0: Command code (36) +- Offset 1-4: Tag (uint32_t LE) - request identifier +- Offset 5-8: Auth code (uint32_t LE) - authentication/validation +- Offset 9: Flags byte +- Offset 10+: Path data (variable length, < MAX_PATH_SIZE) + +**C++ Implementation** (`MyMesh.cpp:1455-1474`): +```cpp +else if (cmd_frame[0] == CMD_SEND_TRACE_PATH && len > 10 && len - 10 < MAX_PATH_SIZE) { + uint32_t tag, auth; + memcpy(&tag, &cmd_frame[1], 4); + memcpy(&auth, &cmd_frame[5], 4); + auto pkt = createTrace(tag, auth, cmd_frame[9]); + if (pkt) { + uint8_t path_len = len - 10; + sendDirect(pkt, &cmd_frame[10], path_len); + + uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); + uint32_t est_timeout = calcDirectTimeoutMillisFor(t, path_len); + + out_frame[0] = RESP_CODE_SENT; + out_frame[1] = 0; + memcpy(&out_frame[2], &tag, 4); + memcpy(&out_frame[6], &est_timeout, 4); + _serial->writeFrame(out_frame, 10); + } else { + writeErrFrame(ERR_CODE_TABLE_FULL); + } +} +``` + +**What It Does**: Sends a trace/path report packet to track network topology. Used for network path discovery and diagnostics. + +**Response Format** (`RESP_CODE_SENT`): +- Byte 0: `RESP_CODE_SENT` (6) +- Byte 1: 0 (reserved/flags) +- Bytes 2-5: Tag (uint32_t LE, echoed from request) +- Bytes 6-9: Estimated timeout (uint32_t LE, milliseconds) + +**Error Codes**: +- `ERR_CODE_TABLE_FULL` (3): Packet pool exhausted + +**Implementation Notes**: +- Min frame length: 11 bytes (1 cmd + 4 tag + 4 auth + 1 flags + min 1 path) +- Max frame length: 10 + MAX_PATH_SIZE +- Path length: len - 10 bytes +- Creates trace packet via `createTrace(tag, auth, flags)` +- Sends directly to specified path via `sendDirect()` +- Calculates estimated airtime based on payload/path +- Response always 10 bytes if successful +- Tag parameter allows matching response to request +- Auth code included in packet for validation/replay protection +- Flags byte passed to createTrace (purpose depends on implementation) + +**Flutter Implementation Guide**: +```dart +// Already defined in constants +// static const int cmdSendTracePath = 36; + +// Frame Builder +static Uint8List buildSendTracePath({ + required int tag, + required int authCode, + required int flags, + required Uint8List path, +}) { + final writer = BufferWriter(); + writer.writeByte(MeshCoreConstants.cmdSendTracePath); + writer.writeUInt32LE(tag); + writer.writeUInt32LE(authCode); + writer.writeByte(flags); + writer.writeBytes(path); + return writer.toBytes(); +} + +// Service API (reuses existing parseSentConfirmation) +Future> sendTracePath({ + required int tag, + required int authCode, + required int flags, + required Uint8List path, +}) async { + final frame = FrameBuilder.buildSendTracePath( + tag: tag, + authCode: authCode, + flags: flags, + path: path, + ); + // Wait for RESP_CODE_SENT + await _commandSender.sendCommand(frame); + // Returns: {expectedAckTag, suggestedTimeout, isFloodMode} +} +``` + +--- + +## Implementation Priority Matrix + +### High Priority (Essential Features) +1. **CMD_HAS_CONNECTION (28)** - Check connectivity before sending +2. **CMD_GET_CONTACT_BY_KEY (30)** - Essential for contact lookup + +### Medium Priority (Useful Features) +3. **CMD_LOGOUT (29)** - Room server management +4. **CMD_SHARE_CONTACT (16)** - Contact distribution +5. **CMD_EXPORT_CONTACT (17)** - Contact backup +6. **CMD_IMPORT_CONTACT (18)** - Contact restore +7. **CMD_SET_TUNING_PARAMS (21)** - Network optimization +8. **CMD_GET_TUNING_PARAMS (43)** - Read current settings +9. **CMD_GET_ADVERT_PATH (42)** - Network diagnostics +10. **CMD_SEND_PATH_DISCOVERY_REQ (52)** - Topology discovery +11. **CMD_SEND_TRACE_PATH (36)** - Path tracking + +### Low Priority (Advanced/Specialized) +12. **CMD_REBOOT (19)** - Device management +13. **CMD_SET_DEVICE_PIN (37)** - BLE security +14. **CMD_GET_CUSTOM_VARS (40)** - Sensor config +15. **CMD_SET_CUSTOM_VAR (41)** - Sensor config +16. **CMD_SEND_RAW_DATA (25)** - Custom protocols +17. **CMD_SIGN_START (33)** - Digital signatures +18. **CMD_SIGN_DATA (34)** - Digital signatures +19. **CMD_SIGN_FINISH (35)** - Digital signatures + +### Very Low Priority (Security Risks / Destructive) +20. **CMD_EXPORT_PRIVATE_KEY (23)** - May be disabled +21. **CMD_IMPORT_PRIVATE_KEY (24)** - May be disabled +22. **CMD_FACTORY_RESET (51)** - Destructive operation + +--- + +## Quick Reference Table + +| Code | Name | Status | Priority | Response | Min Len | Key Feature | +|------|------|--------|----------|----------|---------|-------------| +| 16 | SHARE_CONTACT | Not Defined | Medium | OK/ERR | 33 | Broadcasts contact zero-hop | +| 17 | EXPORT_CONTACT | Defined | Medium | RESP_11/ERR | 1-33 | Exports packet format | +| 18 | IMPORT_CONTACT | Defined | Medium | OK/ERR | 98 | Imports packet format | +| 19 | REBOOT | Defined | Low | None | 7 | Graceful restart | +| 21 | SET_TUNING_PARAMS | Defined | Medium | OK/ERR | 9 | Mesh timing config | +| 23 | EXPORT_PRIVATE_KEY | Defined | Very Low | RESP_14/DIS | 1 | 64B identity (risky) | +| 24 | IMPORT_PRIVATE_KEY | Defined | Very Low | OK/ERR/DIS | 65 | Changes identity (risky) | +| 25 | SEND_RAW_DATA | Defined | Low | OK/ERR | 6 | Custom protocol send | +| 28 | HAS_CONNECTION | Not Defined | High | OK/ERR | 33 | Check path exists | +| 29 | LOGOUT | Not Defined | Medium | OK | 33 | Disconnect from room | +| 30 | GET_CONTACT_BY_KEY | Not Defined | High | RESP_3/ERR | 33 | Full contact lookup | +| 33 | SIGN_START | Defined | Low | RESP_19 | 1 | Init signature (8K) | +| 34 | SIGN_DATA | Defined | Low | OK/ERR | 2 | Accumulate data | +| 35 | SIGN_FINISH | Defined | Low | RESP_20/ERR | 1 | Generate signature | +| 36 | SEND_TRACE_PATH | Defined | Medium | RESP_6/ERR | 11 | Network trace | +| 37 | SET_DEVICE_PIN | Not Defined | Low | OK/ERR | 5 | BLE pairing PIN | +| 40 | GET_CUSTOM_VARS | Not Defined | Low | RESP_21 | 1 | Sensor settings (CSV) | +| 41 | SET_CUSTOM_VAR | Not Defined | Low | OK/ERR | 4 | Set sensor value | +| 42 | GET_ADVERT_PATH | Not Defined | Medium | RESP_22/ERR | 9 | Advert path history | +| 43 | GET_TUNING_PARAMS | Not Defined | Medium | RESP_23 | 1 | Read mesh timing | +| 51 | FACTORY_RESET | Not Defined | Very Low | OK/ERR | 6 | Erase all (risky) | +| 52 | SEND_PATH_DISCOVERY | Not Defined | Medium | RESP_6 | 35 | Flood for paths | + +--- + +## Notes + +- All implementations based on analysis of `/Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp` +- Commands marked "Not Defined" need to be added to `lib/services/meshcore_constants.dart` first +- Commands marked "Defined" need FrameBuilder methods and service APIs +- Response codes not yet defined in Flutter: + - `RESP_CODE_EXPORT_CONTACT` (11) + - `RESP_CODE_SIGN_START` (19) + - `RESP_CODE_SIGNATURE` (20) + - `RESP_CODE_CUSTOM_VARS` (21) + - `RESP_CODE_ADVERT_PATH` (22) + - `RESP_CODE_TUNING_PARAMS` (23) +- Magic strings for safety: "reboot" (6 chars), "reset" (5 chars) +- Compile-time flags may disable private key import/export +- Some commands are destructive (factory reset, reboot) +- Digital signing commands (33-35) work as a sequence +- Network diagnostics commands (42, 52, 36) useful for mesh analysis + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-01-26 +**Reference**: MeshCore Companion Radio Protocol v1 +**Flutter App**: MeshCore SAR Application diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 86d67e3..f2134ff 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -649,6 +649,44 @@ "description": "Beschreibung für Rechteckzeichnungsmodus" }, + "measureDistance": "Entfernung messen", + "@measureDistance": { + "description": "Kartenzeichnungsmodus: Entfernung messen" + }, + + "measureDistanceDesc": "Zwei Punkte lang drücken zum Messen", + "@measureDistanceDesc": { + "description": "Beschreibung für Entfernungsmessungsmodus" + }, + + "clearMeasurement": "Messung löschen", + "@clearMeasurement": { + "description": "Tooltip zum Löschen der Messung" + }, + + "distanceLabel": "Entfernung: {distance}", + "@distanceLabel": { + "description": "Beschriftung mit gemessener Entfernung", + "placeholders": { + "distance": {"type": "String"} + } + }, + + "longPressForSecondPoint": "Langer Druck für zweiten Punkt", + "@longPressForSecondPoint": { + "description": "Anweisung wenn erster Messpunkt gesetzt ist" + }, + + "longPressToStartMeasurement": "Langer Druck für ersten Punkt", + "@longPressToStartMeasurement": { + "description": "Anweisung zum Starten der Messung" + }, + + "longPressToStartNewMeasurement": "Langer Druck für neue Messung", + "@longPressToStartNewMeasurement": { + "description": "Anweisung zum Neustarten der Messung nach Abschluss" + }, + "shareDrawings": "Zeichnungen teilen", "@shareDrawings": { "description": "Aktion zum Teilen von Zeichnungen im Netzwerk" @@ -2551,5 +2589,11 @@ "currentVersion": "Aktuell", "latestVersion": "Neueste", "downloadUpdate": "Herunterladen", - "updateLater": "Später" + "updateLater": "Später", + + "cadastralParcels": "Katasterparzellen", + "forestRoads": "Waldwege", + "showCadastralParcels": "Katasterparzellen anzeigen", + "showForestRoads": "Waldwege anzeigen", + "wmsOverlays": "WMS Überlagerungen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 67f48a2..d7f5cec 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -649,6 +649,44 @@ "description": "Description for rectangle drawing mode" }, + "measureDistance": "Measure Distance", + "@measureDistance": { + "description": "Map drawing mode: measure distance" + }, + + "measureDistanceDesc": "Long press two points to measure", + "@measureDistanceDesc": { + "description": "Description for distance measurement mode" + }, + + "clearMeasurement": "Clear Measurement", + "@clearMeasurement": { + "description": "Tooltip to clear measurement" + }, + + "distanceLabel": "Distance: {distance}", + "@distanceLabel": { + "description": "Label showing measured distance", + "placeholders": { + "distance": {"type": "String"} + } + }, + + "longPressForSecondPoint": "Long press for second point", + "@longPressForSecondPoint": { + "description": "Instruction when first measurement point is set" + }, + + "longPressToStartMeasurement": "Long press to set first point", + "@longPressToStartMeasurement": { + "description": "Instruction to start measurement" + }, + + "longPressToStartNewMeasurement": "Long press to start new measurement", + "@longPressToStartNewMeasurement": { + "description": "Instruction to restart measurement after completion" + }, + "shareDrawings": "Share Drawings", "@shareDrawings": { "description": "Action to share drawings to network" @@ -3163,5 +3201,30 @@ "updateLater": "Later", "@updateLater": { "description": "Button to dismiss update dialog" + }, + + "cadastralParcels": "Cadastral Parcels", + "@cadastralParcels": { + "description": "Label for cadastral parcels WMS overlay layer" + }, + + "forestRoads": "Forest Roads", + "@forestRoads": { + "description": "Label for forest roads WMS overlay layer" + }, + + "showCadastralParcels": "Show Cadastral Parcels", + "@showCadastralParcels": { + "description": "Tooltip for cadastral parcels overlay toggle button" + }, + + "showForestRoads": "Show Forest Roads", + "@showForestRoads": { + "description": "Tooltip for forest roads overlay toggle button" + }, + + "wmsOverlays": "WMS Overlays", + "@wmsOverlays": { + "description": "Section header for WMS overlay layers in layer selector" } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 236d49c..6ebdcdf 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -649,6 +649,44 @@ "description": "Descripción del modo de dibujo de rectángulo" }, + "measureDistance": "Medir distancia", + "@measureDistance": { + "description": "Modo de dibujo del mapa: medir distancia" + }, + + "measureDistanceDesc": "Presión prolongada en dos puntos para medir", + "@measureDistanceDesc": { + "description": "Descripción del modo de medición de distancia" + }, + + "clearMeasurement": "Borrar medición", + "@clearMeasurement": { + "description": "Tooltip para borrar la medición" + }, + + "distanceLabel": "Distancia: {distance}", + "@distanceLabel": { + "description": "Etiqueta que muestra la distancia medida", + "placeholders": { + "distance": {"type": "String"} + } + }, + + "longPressForSecondPoint": "Presión prolongada para el segundo punto", + "@longPressForSecondPoint": { + "description": "Instrucción cuando se ha establecido el primer punto de medición" + }, + + "longPressToStartMeasurement": "Presión prolongada para establecer el primer punto", + "@longPressToStartMeasurement": { + "description": "Instrucción para comenzar la medición" + }, + + "longPressToStartNewMeasurement": "Presión prolongada para nueva medición", + "@longPressToStartNewMeasurement": { + "description": "Instrucción para reiniciar la medición después de completarla" + }, + "shareDrawings": "Compartir dibujos", "@shareDrawings": { "description": "Acción para compartir dibujos a la red" @@ -2546,5 +2584,11 @@ "currentVersion": "Actual", "latestVersion": "Última", "downloadUpdate": "Descargar", - "updateLater": "Más Tarde" + "updateLater": "Más Tarde", + + "cadastralParcels": "Parcelas Catastrales", + "forestRoads": "Caminos Forestales", + "showCadastralParcels": "Mostrar parcelas catastrales", + "showForestRoads": "Mostrar caminos forestales", + "wmsOverlays": "Superposiciones WMS" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2edd7e4..d494c7b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -649,6 +649,44 @@ "description": "Description du mode de dessin de rectangle" }, + "measureDistance": "Mesurer la distance", + "@measureDistance": { + "description": "Mode de dessin de carte : mesurer la distance" + }, + + "measureDistanceDesc": "Appui long sur deux points pour mesurer", + "@measureDistanceDesc": { + "description": "Description du mode de mesure de distance" + }, + + "clearMeasurement": "Effacer la mesure", + "@clearMeasurement": { + "description": "Infobulle pour effacer la mesure" + }, + + "distanceLabel": "Distance : {distance}", + "@distanceLabel": { + "description": "Étiquette affichant la distance mesurée", + "placeholders": { + "distance": {"type": "String"} + } + }, + + "longPressForSecondPoint": "Appui long pour le deuxième point", + "@longPressForSecondPoint": { + "description": "Instruction lorsque le premier point de mesure est défini" + }, + + "longPressToStartMeasurement": "Appui long pour définir le premier point", + "@longPressToStartMeasurement": { + "description": "Instruction pour commencer la mesure" + }, + + "longPressToStartNewMeasurement": "Appui long pour nouvelle mesure", + "@longPressToStartNewMeasurement": { + "description": "Instruction pour redémarrer la mesure après achèvement" + }, + "shareDrawings": "Partager les dessins", "@shareDrawings": { "description": "Action pour partager les dessins sur le réseau" @@ -2551,5 +2589,11 @@ "currentVersion": "Actuelle", "latestVersion": "Dernière", "downloadUpdate": "Télécharger", - "updateLater": "Plus Tard" + "updateLater": "Plus Tard", + + "cadastralParcels": "Parcelles Cadastrales", + "forestRoads": "Chemins Forestiers", + "showCadastralParcels": "Afficher les parcelles cadastrales", + "showForestRoads": "Afficher les chemins forestiers", + "wmsOverlays": "Superpositions WMS" } diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index b01f02e..223dac0 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -227,6 +227,20 @@ "drawRectangleDesc": "Nacrtaj pravokutno područje na karti", + "measureDistance": "Izmjeri udaljenost", + + "measureDistanceDesc": "Dugi pritisak na dvije točke za mjerenje", + + "clearMeasurement": "Očisti mjerenje", + + "distanceLabel": "Udaljenost: {distance}", + + "longPressForSecondPoint": "Dugi pritisak za drugu točku", + + "longPressToStartMeasurement": "Dugi pritisak za prvu točku", + + "longPressToStartNewMeasurement": "Dugi pritisak za novo mjerenje", + "shareDrawings": "Podijeli crteže", "clearAllDrawings": "Očisti sve crteže", @@ -955,5 +969,11 @@ "currentVersion": "Trenutna", "latestVersion": "Najnovija", "downloadUpdate": "Preuzmi", - "updateLater": "Kasnije" + "updateLater": "Kasnije", + + "cadastralParcels": "Katastarske čestice", + "forestRoads": "Šumske ceste", + "showCadastralParcels": "Prikaži katastarske čestice", + "showForestRoads": "Prikaži šumske ceste", + "wmsOverlays": "WMS Prekrivanja" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6d3e588..518e0de 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -649,6 +649,44 @@ "description": "Descrizione per la modalità disegno rettangolo" }, + "measureDistance": "Misura Distanza", + "@measureDistance": { + "description": "Modalità disegno mappa: misura distanza" + }, + + "measureDistanceDesc": "Premi a lungo su due punti per misurare", + "@measureDistanceDesc": { + "description": "Descrizione per la modalità misurazione distanza" + }, + + "clearMeasurement": "Cancella Misurazione", + "@clearMeasurement": { + "description": "Tooltip per cancellare la misurazione" + }, + + "distanceLabel": "Distanza: {distance}", + "@distanceLabel": { + "description": "Etichetta che mostra la distanza misurata", + "placeholders": { + "distance": {"type": "String"} + } + }, + + "longPressForSecondPoint": "Premi a lungo per il secondo punto", + "@longPressForSecondPoint": { + "description": "Istruzione quando è impostato il primo punto di misurazione" + }, + + "longPressToStartMeasurement": "Premi a lungo per impostare il primo punto", + "@longPressToStartMeasurement": { + "description": "Istruzione per avviare la misurazione" + }, + + "longPressToStartNewMeasurement": "Premi a lungo per nuova misurazione", + "@longPressToStartNewMeasurement": { + "description": "Istruzione per riavviare la misurazione dopo il completamento" + }, + "shareDrawings": "Condividi Disegni", "@shareDrawings": { "description": "Azione per condividere disegni sulla rete" @@ -2551,5 +2589,11 @@ "currentVersion": "Attuale", "latestVersion": "Ultima", "downloadUpdate": "Scarica", - "updateLater": "Più Tardi" + "updateLater": "Più Tardi", + + "cadastralParcels": "Particelle Catastali", + "forestRoads": "Strade Forestali", + "showCadastralParcels": "Mostra particelle catastali", + "showForestRoads": "Mostra strade forestali", + "wmsOverlays": "Sovrapposizioni WMS" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 655d514..e6608ab 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -791,6 +791,48 @@ abstract class AppLocalizations { /// **'Draw a rectangular area on the map'** String get drawRectangleDesc; + /// Map drawing mode: measure distance + /// + /// In en, this message translates to: + /// **'Measure Distance'** + String get measureDistance; + + /// Description for distance measurement mode + /// + /// In en, this message translates to: + /// **'Long press two points to measure'** + String get measureDistanceDesc; + + /// Tooltip to clear measurement + /// + /// In en, this message translates to: + /// **'Clear Measurement'** + String get clearMeasurement; + + /// Label showing measured distance + /// + /// In en, this message translates to: + /// **'Distance: {distance}'** + String distanceLabel(String distance); + + /// Instruction when first measurement point is set + /// + /// In en, this message translates to: + /// **'Long press for second point'** + String get longPressForSecondPoint; + + /// Instruction to start measurement + /// + /// In en, this message translates to: + /// **'Long press to set first point'** + String get longPressToStartMeasurement; + + /// Instruction to restart measurement after completion + /// + /// In en, this message translates to: + /// **'Long press to start new measurement'** + String get longPressToStartNewMeasurement; + /// Action to share drawings to network /// /// In en, this message translates to: @@ -3374,6 +3416,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Later'** String get updateLater; + + /// Label for cadastral parcels WMS overlay layer + /// + /// In en, this message translates to: + /// **'Cadastral Parcels'** + String get cadastralParcels; + + /// Label for forest roads WMS overlay layer + /// + /// In en, this message translates to: + /// **'Forest Roads'** + String get forestRoads; + + /// Tooltip for cadastral parcels overlay toggle button + /// + /// In en, this message translates to: + /// **'Show Cadastral Parcels'** + String get showCadastralParcels; + + /// Tooltip for forest roads overlay toggle button + /// + /// In en, this message translates to: + /// **'Show Forest Roads'** + String get showForestRoads; + + /// Section header for WMS overlay layers in layer selector + /// + /// In en, this message translates to: + /// **'WMS Overlays'** + String get wmsOverlays; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6e54e1a..065fc65 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -395,6 +395,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String get drawRectangleDesc => 'Rechteckigen Bereich auf der Karte zeichnen'; + @override + String get measureDistance => 'Entfernung messen'; + + @override + String get measureDistanceDesc => 'Zwei Punkte lang drücken zum Messen'; + + @override + String get clearMeasurement => 'Messung löschen'; + + @override + String distanceLabel(String distance) { + return 'Entfernung: $distance'; + } + + @override + String get longPressForSecondPoint => 'Langer Druck für zweiten Punkt'; + + @override + String get longPressToStartMeasurement => 'Langer Druck für ersten Punkt'; + + @override + String get longPressToStartNewMeasurement => 'Langer Druck für neue Messung'; + @override String get shareDrawings => 'Zeichnungen teilen'; @@ -1885,4 +1908,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get updateLater => 'Später'; + + @override + String get cadastralParcels => 'Katasterparzellen'; + + @override + String get forestRoads => 'Waldwege'; + + @override + String get showCadastralParcels => 'Katasterparzellen anzeigen'; + + @override + String get showForestRoads => 'Waldwege anzeigen'; + + @override + String get wmsOverlays => 'WMS Überlagerungen'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dc4d49a..f40caaf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -392,6 +392,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get drawRectangleDesc => 'Draw a rectangular area on the map'; + @override + String get measureDistance => 'Measure Distance'; + + @override + String get measureDistanceDesc => 'Long press two points to measure'; + + @override + String get clearMeasurement => 'Clear Measurement'; + + @override + String distanceLabel(String distance) { + return 'Distance: $distance'; + } + + @override + String get longPressForSecondPoint => 'Long press for second point'; + + @override + String get longPressToStartMeasurement => 'Long press to set first point'; + + @override + String get longPressToStartNewMeasurement => + 'Long press to start new measurement'; + @override String get shareDrawings => 'Share Drawings'; @@ -1864,4 +1888,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get updateLater => 'Later'; + + @override + String get cadastralParcels => 'Cadastral Parcels'; + + @override + String get forestRoads => 'Forest Roads'; + + @override + String get showCadastralParcels => 'Show Cadastral Parcels'; + + @override + String get showForestRoads => 'Show Forest Roads'; + + @override + String get wmsOverlays => 'WMS Overlays'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a140cd6..67f8420 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -393,6 +393,33 @@ class AppLocalizationsEs extends AppLocalizations { @override String get drawRectangleDesc => 'Dibujar un área rectangular en el mapa'; + @override + String get measureDistance => 'Medir distancia'; + + @override + String get measureDistanceDesc => + 'Presión prolongada en dos puntos para medir'; + + @override + String get clearMeasurement => 'Borrar medición'; + + @override + String distanceLabel(String distance) { + return 'Distancia: $distance'; + } + + @override + String get longPressForSecondPoint => + 'Presión prolongada para el segundo punto'; + + @override + String get longPressToStartMeasurement => + 'Presión prolongada para establecer el primer punto'; + + @override + String get longPressToStartNewMeasurement => + 'Presión prolongada para nueva medición'; + @override String get shareDrawings => 'Compartir dibujos'; @@ -1886,4 +1913,19 @@ class AppLocalizationsEs extends AppLocalizations { @override String get updateLater => 'Más Tarde'; + + @override + String get cadastralParcels => 'Parcelas Catastrales'; + + @override + String get forestRoads => 'Caminos Forestales'; + + @override + String get showCadastralParcels => 'Mostrar parcelas catastrales'; + + @override + String get showForestRoads => 'Mostrar caminos forestales'; + + @override + String get wmsOverlays => 'Superposiciones WMS'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index cc0a281..651913d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -395,6 +395,31 @@ class AppLocalizationsFr extends AppLocalizations { @override String get drawRectangleDesc => 'Tracer une zone rectangulaire sur la carte'; + @override + String get measureDistance => 'Mesurer la distance'; + + @override + String get measureDistanceDesc => 'Appui long sur deux points pour mesurer'; + + @override + String get clearMeasurement => 'Effacer la mesure'; + + @override + String distanceLabel(String distance) { + return 'Distance : $distance'; + } + + @override + String get longPressForSecondPoint => 'Appui long pour le deuxième point'; + + @override + String get longPressToStartMeasurement => + 'Appui long pour définir le premier point'; + + @override + String get longPressToStartNewMeasurement => + 'Appui long pour nouvelle mesure'; + @override String get shareDrawings => 'Partager les dessins'; @@ -1892,4 +1917,19 @@ class AppLocalizationsFr extends AppLocalizations { @override String get updateLater => 'Plus Tard'; + + @override + String get cadastralParcels => 'Parcelles Cadastrales'; + + @override + String get forestRoads => 'Chemins Forestiers'; + + @override + String get showCadastralParcels => 'Afficher les parcelles cadastrales'; + + @override + String get showForestRoads => 'Afficher les chemins forestiers'; + + @override + String get wmsOverlays => 'Superpositions WMS'; } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index bb46822..3f7c924 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -392,6 +392,29 @@ class AppLocalizationsHr extends AppLocalizations { @override String get drawRectangleDesc => 'Nacrtaj pravokutno područje na karti'; + @override + String get measureDistance => 'Izmjeri udaljenost'; + + @override + String get measureDistanceDesc => 'Dugi pritisak na dvije točke za mjerenje'; + + @override + String get clearMeasurement => 'Očisti mjerenje'; + + @override + String distanceLabel(String distance) { + return 'Udaljenost: $distance'; + } + + @override + String get longPressForSecondPoint => 'Dugi pritisak za drugu točku'; + + @override + String get longPressToStartMeasurement => 'Dugi pritisak za prvu točku'; + + @override + String get longPressToStartNewMeasurement => 'Dugi pritisak za novo mjerenje'; + @override String get shareDrawings => 'Podijeli crteže'; @@ -1874,4 +1897,19 @@ class AppLocalizationsHr extends AppLocalizations { @override String get updateLater => 'Kasnije'; + + @override + String get cadastralParcels => 'Katastarske čestice'; + + @override + String get forestRoads => 'Šumske ceste'; + + @override + String get showCadastralParcels => 'Prikaži katastarske čestice'; + + @override + String get showForestRoads => 'Prikaži šumske ceste'; + + @override + String get wmsOverlays => 'WMS Prekrivanja'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 005c412..5104caa 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -394,6 +394,31 @@ class AppLocalizationsIt extends AppLocalizations { @override String get drawRectangleDesc => 'Disegna un\'area rettangolare sulla mappa'; + @override + String get measureDistance => 'Misura Distanza'; + + @override + String get measureDistanceDesc => 'Premi a lungo su due punti per misurare'; + + @override + String get clearMeasurement => 'Cancella Misurazione'; + + @override + String distanceLabel(String distance) { + return 'Distanza: $distance'; + } + + @override + String get longPressForSecondPoint => 'Premi a lungo per il secondo punto'; + + @override + String get longPressToStartMeasurement => + 'Premi a lungo per impostare il primo punto'; + + @override + String get longPressToStartNewMeasurement => + 'Premi a lungo per nuova misurazione'; + @override String get shareDrawings => 'Condividi Disegni'; @@ -1883,4 +1908,19 @@ class AppLocalizationsIt extends AppLocalizations { @override String get updateLater => 'Più Tardi'; + + @override + String get cadastralParcels => 'Particelle Catastali'; + + @override + String get forestRoads => 'Strade Forestali'; + + @override + String get showCadastralParcels => 'Mostra particelle catastali'; + + @override + String get showForestRoads => 'Mostra strade forestali'; + + @override + String get wmsOverlays => 'Sovrapposizioni WMS'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 76e0abe..adfe261 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -392,6 +392,29 @@ class AppLocalizationsSl extends AppLocalizations { @override String get drawRectangleDesc => 'Nariši pravokotno področje na zemljevidu'; + @override + String get measureDistance => 'Meri razdaljo'; + + @override + String get measureDistanceDesc => 'Dolg pritisk na dve točki za merjenje'; + + @override + String get clearMeasurement => 'Počisti meritev'; + + @override + String distanceLabel(String distance) { + return 'Razdalja: $distance'; + } + + @override + String get longPressForSecondPoint => 'Dolg pritisk za drugo točko'; + + @override + String get longPressToStartMeasurement => 'Dolg pritisk za prvo točko'; + + @override + String get longPressToStartNewMeasurement => 'Dolg pritisk za novo meritev'; + @override String get shareDrawings => 'Deli risbe'; @@ -1875,4 +1898,19 @@ class AppLocalizationsSl extends AppLocalizations { @override String get updateLater => 'Kasneje'; + + @override + String get cadastralParcels => 'Katastrske parcele'; + + @override + String get forestRoads => 'Gozdne ceste'; + + @override + String get showCadastralParcels => 'Prikaži katastrske parcele'; + + @override + String get showForestRoads => 'Prikaži gozdne ceste'; + + @override + String get wmsOverlays => 'WMS Prekrivanja'; } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 99585f5..0121d67 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -227,6 +227,20 @@ "drawRectangleDesc": "Nariši pravokotno področje na zemljevidu", + "measureDistance": "Meri razdaljo", + + "measureDistanceDesc": "Dolg pritisk na dve točki za merjenje", + + "clearMeasurement": "Počisti meritev", + + "distanceLabel": "Razdalja: {distance}", + + "longPressForSecondPoint": "Dolg pritisk za drugo točko", + + "longPressToStartMeasurement": "Dolg pritisk za prvo točko", + + "longPressToStartNewMeasurement": "Dolg pritisk za novo meritev", + "shareDrawings": "Deli risbe", "clearAllDrawings": "Počisti vse risbe", @@ -955,5 +969,11 @@ "currentVersion": "Trenutna", "latestVersion": "Najnovejša", "downloadUpdate": "Prenesi", - "updateLater": "Kasneje" + "updateLater": "Kasneje", + + "cadastralParcels": "Katastrske parcele", + "forestRoads": "Gozdne ceste", + "showCadastralParcels": "Prikaži katastrske parcele", + "showForestRoads": "Prikaži gozdne ceste", + "wmsOverlays": "WMS Prekrivanja" } diff --git a/lib/models/map_layer.dart b/lib/models/map_layer.dart index 0f2a7db..75e2e25 100644 --- a/lib/models/map_layer.dart +++ b/lib/models/map_layer.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; import '../l10n/app_localizations.dart'; enum MapLayerType { @@ -10,6 +11,7 @@ enum MapLayerType { googleRoadmap, googleTerrain, vectorMbtiles, + wmsBase, } class MapLayer { @@ -26,6 +28,15 @@ class MapLayer { final String? sourceName; final bool? isGzipped; + // WMS specific properties + final bool isWms; + final String? wmsBaseUrl; + final List? wmsLayers; + final String? wmsFormat; + final bool? wmsTransparent; + final List? wmsStyles; + final Crs? crs; + const MapLayer({ required this.type, required this.name, @@ -37,6 +48,13 @@ class MapLayer { this.styleUrl, this.sourceName, this.isGzipped, + this.isWms = false, + this.wmsBaseUrl, + this.wmsLayers, + this.wmsFormat, + this.wmsTransparent, + this.wmsStyles, + this.crs, }); /// Get localized name for the layer @@ -58,6 +76,9 @@ class MapLayer { case MapLayerType.vectorMbtiles: // For vector tiles, use the name from metadata return name; + case MapLayerType.wmsBase: + // For WMS layers, use the name (will be localized separately) + return name; } } @@ -110,6 +131,25 @@ class MapLayer { maxZoom: 20, // Google Maps maximum ); + /// Slovenian Aerial Imagery 2024 (Ortofoto) - WMS Base Layer + /// Uses EPSG:3794 coordinate system (GeoWebCache tile matrix zoom 0-15) + /// Note: CRS is initialized at runtime in getSlovenianAerial2024() + static MapLayer getSlovenianAerial2024(Crs slovenianCrs) { + return MapLayer( + type: MapLayerType.wmsBase, + name: 'Ortofoto 2024 (Slovenija)', + urlTemplate: '', // Not used for WMS + attribution: '© GURS (Geodetska uprava Republike Slovenije)', + maxZoom: 15, // GeoWebCache tile matrix maximum + isWms: true, + wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + wmsLayers: const ['pregledovalnik:DOF_2024'], + wmsFormat: 'image/jpeg', + wmsTransparent: false, + crs: slovenianCrs, + ); + } + static const List allLayers = [ openStreetMap, openTopoMap, @@ -117,6 +157,7 @@ class MapLayer { googleHybrid, googleRoadmap, googleTerrain, + // Note: Slovenian aerial layer is added dynamically via getSlovenianAerial2024() ]; static MapLayer fromType(MapLayerType type) { diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 236b3ed..46aed16 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -6,7 +6,7 @@ import '../models/map_drawing.dart'; import '../utils/drawing_message_parser.dart'; /// Drawing mode state -enum DrawingMode { none, line, rectangle } +enum DrawingMode { none, line, rectangle, measure } /// Provider for managing map drawings class DrawingProvider with ChangeNotifier { @@ -26,6 +26,11 @@ class DrawingProvider with ChangeNotifier { List _currentLinePoints = []; LatLng? _rectangleStartPoint; + // Distance measurement state + LatLng? _measurementPoint1; + LatLng? _measurementPoint2; + double? _measuredDistance; // in meters + // Getters DrawingMode get drawingMode => _drawingMode; Color get selectedColor => _selectedColor; @@ -46,6 +51,9 @@ class DrawingProvider with ChangeNotifier { List get currentLinePoints => List.unmodifiable(_currentLinePoints); LatLng? get rectangleStartPoint => _rectangleStartPoint; bool get isDrawing => _drawingMode != DrawingMode.none; + LatLng? get measurementPoint1 => _measurementPoint1; + LatLng? get measurementPoint2 => _measurementPoint2; + double? get measuredDistance => _measuredDistance; /// Initialize and load saved drawings Future initialize() async { @@ -126,8 +134,9 @@ class DrawingProvider with ChangeNotifier { /// Update rectangle end point (for preview) void updateRectangleEndPoint(LatLng endPoint) { - if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) + if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) { return; + } // Create preview rectangle _currentDrawing = RectangleDrawing( @@ -195,11 +204,47 @@ class DrawingProvider with ChangeNotifier { notifyListeners(); } + /// Set first measurement point + void setMeasurementPoint1(LatLng point) { + if (_drawingMode != DrawingMode.measure) return; + + _measurementPoint1 = point; + _measurementPoint2 = null; + _measuredDistance = null; + notifyListeners(); + } + + /// Set second measurement point and calculate distance + void setMeasurementPoint2(LatLng point) { + if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return; + + _measurementPoint2 = point; + _measuredDistance = _calculateDistance(_measurementPoint1!, point); + notifyListeners(); + } + + /// Calculate distance between two points using Haversine formula + double _calculateDistance(LatLng point1, LatLng point2) { + const Distance distance = Distance(); + return distance.as(LengthUnit.Meter, point1, point2); + } + + /// Clear measurement points + void clearMeasurement() { + _measurementPoint1 = null; + _measurementPoint2 = null; + _measuredDistance = null; + notifyListeners(); + } + /// Cancel current drawing in progress void _cancelCurrentDrawing() { _currentLinePoints = []; _rectangleStartPoint = null; _currentDrawing = null; + _measurementPoint1 = null; + _measurementPoint2 = null; + _measuredDistance = null; } /// Clear current drawing (public method) diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index 3e40505..ab4dd09 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../models/location_trail.dart'; import '../models/map_drawing.dart'; @@ -16,6 +17,10 @@ class MapProvider with ChangeNotifier { bool _isTrailVisible = true; final List _trailHistory = []; + // WMS overlay toggles + bool _showCadastralOverlay = false; + bool _showForestRoadsOverlay = false; + LatLng? get targetLocation => _targetLocation; double? get targetZoom => _targetZoom; bool get shouldAnimate => _shouldAnimate; @@ -27,6 +32,10 @@ class MapProvider with ChangeNotifier { List get trailHistory => List.unmodifiable(_trailHistory); bool get isTrailActive => _currentTrail?.isActive ?? false; + // WMS overlay getters + bool get showCadastralOverlay => _showCadastralOverlay; + bool get showForestRoadsOverlay => _showForestRoadsOverlay; + void navigateToLocation({ required LatLng location, double zoom = 15.0, @@ -207,4 +216,33 @@ class MapProvider with ChangeNotifier { if (_currentTrail == null) return Duration.zero; return _currentTrail!.duration; } + + /// Toggle cadastral parcels overlay + Future toggleCadastralOverlay() async { + _showCadastralOverlay = !_showCadastralOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + /// Toggle forest roads overlay + Future toggleForestRoadsOverlay() async { + _showForestRoadsOverlay = !_showForestRoadsOverlay; + notifyListeners(); + await _saveOverlayState(); + } + + /// Load overlay state from SharedPreferences + Future loadOverlayState() async { + final prefs = await SharedPreferences.getInstance(); + _showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false; + _showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false; + notifyListeners(); + } + + /// Save overlay state to SharedPreferences + Future _saveOverlayState() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay); + await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay); + } } diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 724a95b..7d3677d 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:vector_map_tiles/vector_map_tiles.dart'; import 'package:vector_tile_renderer/vector_tile_renderer.dart' as vtr; import 'package:http/http.dart' as http; +import '../utils/slovenian_crs.dart'; import '../providers/contacts_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/map_provider.dart'; @@ -75,6 +76,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // MBTiles layers List _mbtilesLayers = []; + // WMS layers (Slovenian) + late final MapLayer _slovenianAerialLayer; + // Vector tile theme vtr.Theme? _vectorTheme; bool _isLoadingTheme = false; @@ -110,6 +114,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); + // Initialize Slovenian aerial layer with CRS + _slovenianAerialLayer = MapLayer.getSlovenianAerial2024(slovenianCrs); _loadSettings(); _loadMbtilesLayers(); _initializeTileCache(); @@ -120,6 +126,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { WidgetsBinding.instance.addPostFrameCallback((_) { final mapProvider = context.read(); mapProvider.addListener(_handleMapNavigation); + // Load WMS overlay state + mapProvider.loadOverlayState(); // Initialize background location service with BLE service final appProvider = context.read(); @@ -239,8 +247,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } - /// Get all available layers (default + MBTiles) - List get _allLayers => [...MapLayer.allLayers, ..._mbtilesLayers]; + /// Get all available layers (default + WMS + MBTiles) + List get _allLayers => [...MapLayer.allLayers, _slovenianAerialLayer, ..._mbtilesLayers]; Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); @@ -537,6 +545,21 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { Navigator.pop(context); }, )), + // Slovenian WMS base layer + ListTile( + leading: _currentLayer == _slovenianAerialLayer + ? const Icon(Icons.check_circle, color: Colors.green) + : const Icon(Icons.radio_button_unchecked), + title: Text(_slovenianAerialLayer.name), + subtitle: Text(_slovenianAerialLayer.attribution), + onTap: () async { + setState(() { + _currentLayer = _slovenianAerialLayer; + }); + _saveSettings(); + Navigator.pop(context); + }, + ), // Offline MBTiles layers section if (_mbtilesLayers.isNotEmpty) ...[ const Divider(), @@ -576,6 +599,43 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }, )), ], + // WMS Overlays section + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + AppLocalizations.of(context)!.wmsOverlays, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.grey[600], + ), + ), + ), + Consumer( + builder: (context, mapProvider, _) { + return Column( + children: [ + CheckboxListTile( + secondary: const Icon(Icons.grid_on, color: Colors.blue), + title: Text(AppLocalizations.of(context)!.cadastralParcels), + subtitle: const Text('© GURS'), + value: mapProvider.showCadastralOverlay, + onChanged: (value) { + mapProvider.toggleCadastralOverlay(); + }, + ), + CheckboxListTile( + secondary: const Icon(Icons.route, color: Colors.green), + title: Text(AppLocalizations.of(context)!.forestRoads), + subtitle: const Text('© GURS'), + value: mapProvider.showForestRoadsOverlay, + onChanged: (value) { + mapProvider.toggleForestRoadsOverlay(); + }, + ), + ], + ); + }, + ), ], ), ), @@ -855,6 +915,15 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ); } + /// Format distance for display + String _formatDistance(double meters) { + if (meters < 1000) { + return '${meters.toStringAsFixed(1)} m'; + } else { + return '${(meters / 1000).toStringAsFixed(2)} km'; + } + } + /// Show SAR dialog with pre-populated location from map long press void _showSarDialogWithLocation(LatLng location) { // Create a Position object from the LatLng coordinates @@ -1065,6 +1134,8 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: FlutterMap( mapController: _mapController, options: MapOptions( + // Use the layer's CRS if it has one (for WMS layers), otherwise default to EPSG:3857 + crs: _currentLayer.crs ?? const Epsg3857(), // Use saved position if available, otherwise use calculated center initialCenter: _savedMapCenter ?? center, initialZoom: _savedMapZoom ?? _defaultZoom, @@ -1082,11 +1153,29 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } }, onLongPress: (tapPosition, point) { - // Skip if in drawing mode - if (drawingProvider.isDrawing) return; + // Handle measurement mode - set measurement points + if (drawingProvider.drawingMode == DrawingMode.measure) { + if (drawingProvider.measurementPoint1 == null) { + // Set first measurement point + drawingProvider.setMeasurementPoint1(point); + } else if (drawingProvider.measurementPoint2 == null) { + // Set second measurement point + drawingProvider.setMeasurementPoint2(point); + } else { + // Clear and start new measurement + drawingProvider.clearMeasurement(); + drawingProvider.setMeasurementPoint1(point); + } + // Continue to also drop SAR marker pin + } - // Drop a pin at long press location (if no pin exists) - if (_droppedPinLocation == null) { + // Skip if in other drawing modes (but not measure mode) + if (drawingProvider.isDrawing && drawingProvider.drawingMode != DrawingMode.measure) return; + + // Drop a pin at long press location + // In measurement mode, this allows creating SAR markers at measurement points + // The pin moves to the latest long press location + if (_droppedPinLocation == null || drawingProvider.drawingMode == DrawingMode.measure) { setState(() { _droppedPinLocation = point; }); @@ -1183,13 +1272,116 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }), maximumZoom: _currentLayer.maxZoom, ) - else if (!_currentLayer.isVector) + else if (_currentLayer.isWms && _currentLayer.wmsBaseUrl != null && _currentLayer.crs != null) + // WMS Base Layer (e.g., Slovenian Aerial Imagery) + flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: _currentLayer.wmsBaseUrl!, + layers: _currentLayer.wmsLayers ?? [], + styles: _currentLayer.wmsStyles ?? [], + format: _currentLayer.wmsFormat ?? 'image/jpeg', + transparent: _currentLayer.wmsTransparent ?? false, + crs: _currentLayer.crs!, + ), + // Use cached tile provider for offline support + tileProvider: _tileCache.getTileProviderForWms(_currentLayer), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: _currentLayer.maxZoom, + errorTileCallback: (tile, error, stackTrace) { + debugPrint('🔴 WMS Base Layer tile error at ${tile.coordinates}: $error'); + }, + ) + else if (!_currentLayer.isVector && !_currentLayer.isWms) flutter_map.TileLayer( urlTemplate: _currentLayer.urlTemplate, tileProvider: _tileCache.getTileProvider(_currentLayer), userAgentPackageName: 'com.meshcore.sar', maxZoom: _currentLayer.maxZoom, ), + // WMS Overlays (rendered after base layer, before polylines) + // Note: These overlays only work with EPSG:3794 CRS (Slovenian coordinate system) + // Cadastral parcels overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showCadastralOverlay || _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + layers: const ['pregledovalnik:kn_parcele'], + styles: const ['parcele'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Cadastral Parcels', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: 'https://prostor.zgs.gov.si/geowebcache/service/wms?', + wmsLayers: const ['pregledovalnik:kn_parcele'], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint('🔴 Cadastral overlay tile error at ${tile.coordinates}: $error'); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), + // Forest roads overlay + Consumer( + builder: (context, mapProvider, _) { + // Only show if enabled and map is using Slovenian CRS + if (!mapProvider.showForestRoadsOverlay || _currentLayer.crs == null) { + return const SizedBox.shrink(); + } + return flutter_map.TileLayer( + wmsOptions: WMSTileLayerOptions( + baseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', + layers: const ['pregledovalnik:gozdne_ceste'], + styles: const ['gozdne_ceste'], + format: 'image/png', + transparent: true, + crs: slovenianCrs, + ), + tileProvider: _tileCache.getTileProviderForWms( + MapLayer( + type: MapLayerType.wmsBase, + name: 'Forest Roads', + urlTemplate: '', + attribution: '© GURS', + maxZoom: 19, + isWms: true, + wmsBaseUrl: 'https://prostor.zgs.gov.si/geoserver/wms?', + wmsLayers: const ['pregledovalnik:gozdne_ceste'], + wmsFormat: 'image/png', + crs: slovenianCrs, + ), + ), + userAgentPackageName: 'com.meshcore.sar', + maxZoom: 19, + errorTileCallback: (tile, error, stackTrace) { + debugPrint('🔴 Forest roads overlay tile error at ${tile.coordinates}: $error'); + if (stackTrace != null) { + debugPrint(' StackTrace: $stackTrace'); + } + }, + ); + }, + ), // Advertisement path polylines (rendered before markers) Consumer( builder: (context, mapProvider, _) { @@ -1214,6 +1406,23 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { ), // Location trail layer (rendered after paths, before drawings) const LocationTrailLayer(), + // Measurement line layer (rendered before drawings) + if (drawingProvider.measurementPoint1 != null && drawingProvider.measurementPoint2 != null) + PolylineLayer( + polylines: [ + Polyline( + points: [ + drawingProvider.measurementPoint1!, + drawingProvider.measurementPoint2!, + ], + color: Colors.yellow.withValues(alpha: 0.8), + strokeWidth: 3.0, + borderColor: Colors.black.withValues(alpha: 0.5), + borderStrokeWidth: 1.0, + pattern: StrokePattern.dashed(segments: [10, 5]), + ), + ], + ), // Drawing layer (rendered after paths, before markers) DrawingLayer( drawings: drawingProvider.drawings, @@ -1257,6 +1466,104 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { position: _locationService.currentPosition, context: context, )!, + // Measurement point 1 marker + if (drawingProvider.measurementPoint1 != null) + Marker( + point: drawingProvider.measurementPoint1!, + width: 60, + height: 80, + rotate: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.yellow.shade700, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Start', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 18, + ), + ), + ], + ), + ), + // Measurement point 2 marker + if (drawingProvider.measurementPoint2 != null) + Marker( + point: drawingProvider.measurementPoint2!, + width: 60, + height: 80, + rotate: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.yellow.shade700, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'End', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 2), + Container( + decoration: BoxDecoration( + color: Colors.yellow, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: const Icon( + Icons.location_on, + color: Colors.white, + size: 18, + ), + ), + ], + ), + ), // Dropped pin marker with label if (_droppedPinLocation != null) Marker( @@ -1410,6 +1717,90 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { objectCount: messagesProvider.objectMarkers.length, ), ), + // Measurement distance overlay + if (drawingProvider.drawingMode == DrawingMode.measure) + Positioned( + top: 16, + left: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.yellow.shade700.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.straighten, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context)!.measureDistance, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + if (drawingProvider.measuredDistance != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.distanceLabel(_formatDistance(drawingProvider.measuredDistance!)), + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context)!.longPressToStartNewMeasurement, + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + ), + ), + ], + ) + else if (drawingProvider.measurementPoint1 != null) + Text( + AppLocalizations.of(context)!.longPressForSecondPoint, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ) + else + Text( + AppLocalizations.of(context)!.longPressToStartMeasurement, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ), + ), // Map controls - right side (hidden in fullscreen mode) if (!_isFullscreen) Positioned( diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index bd2062f..1023555 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -57,6 +57,26 @@ class TileCacheService { ); } + /// Get tile provider for WMS layers with caching support + /// WMS layers require special handling because they use WMSTileLayerOptions + FMTCTileProvider getTileProviderForWms(MapLayer layer) { + if (!_isInitialized) { + throw StateError( + 'TileCacheService not initialized. Call initialize() first.', + ); + } + if (!layer.isWms) { + throw ArgumentError('Layer must be a WMS layer'); + } + + // Return the same cached tile provider + // The WMS URL construction is handled by flutter_map's WMSTileLayerOptions + return _store.getTileProvider( + loadingStrategy: BrowseLoadingStrategy.cacheFirst, + cachedValidDuration: const Duration(days: 30), + ); + } + Future downloadRegion({ required MapLayer layer, required LatLngBounds bounds, diff --git a/lib/services/wms_tile_provider.dart b/lib/services/wms_tile_provider.dart new file mode 100644 index 0000000..47b5ac5 --- /dev/null +++ b/lib/services/wms_tile_provider.dart @@ -0,0 +1,73 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:http/http.dart' as http; + +/// Custom tile provider that logs WMS URLs for debugging +class DebugWmsTileProvider extends TileProvider { + final http.Client httpClient; + + DebugWmsTileProvider() : httpClient = http.Client(); + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) { + return DebugNetworkTileProvider( + coordinates: coordinates, + options: options, + httpClient: httpClient, + ); + } + + @override + void dispose() { + httpClient.close(); + super.dispose(); + } +} + +class DebugNetworkTileProvider extends ImageProvider { + final TileCoordinates coordinates; + final TileLayer options; + final http.Client httpClient; + + const DebugNetworkTileProvider({ + required this.coordinates, + required this.options, + required this.httpClient, + }); + + @override + ImageStreamCompleter loadImage(DebugNetworkTileProvider key, ImageDecoderCallback decode) { + // Get the WMS URL from the tile layer options + final wmsOptions = options.wmsOptions; + if (wmsOptions == null) { + throw Exception('WMSTileLayerOptions is required for DebugWmsTileProvider'); + } + + // Build the WMS URL + final url = wmsOptions.getUrl(coordinates, 256, false); + + // Log the URL for debugging + debugPrint('🌐 WMS Request URL: $url'); + + // Use NetworkImage to load the tile + return NetworkImage(url, headers: {'User-Agent': 'MeshCore SAR'}) + .loadImage(NetworkImage(url), decode); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is DebugNetworkTileProvider && + other.coordinates == coordinates && + other.options == options; + } + + @override + int get hashCode => Object.hash(coordinates, options); +} diff --git a/lib/utils/slovenian_crs.dart b/lib/utils/slovenian_crs.dart new file mode 100644 index 0000000..03f146a --- /dev/null +++ b/lib/utils/slovenian_crs.dart @@ -0,0 +1,86 @@ +import 'dart:math' show Point; +import 'dart:ui' show Rect; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:proj4dart/proj4dart.dart' as proj4; + +/// EPSG:3794 - Slovenia 1996 / Slovene National Grid +/// Transverse Mercator projection for Slovenia +/// +/// Official definition from https://epsg.io/3794: +/// +proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 +x_0=500000 +y_0=-5000000 +/// +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs +/// +/// This CRS is used by Slovenian government WMS services (prostor.zgs.gov.si) + +/// Register and get EPSG:3794 projection +proj4.Projection getEpsg3794Projection() { + const epsg3794Def = '+proj=tmerc +lat_0=0 +lon_0=15 +k=0.9999 ' + '+x_0=500000 +y_0=-5000000 +ellps=GRS80 ' + '+towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs'; + + // Register projection if not already registered + try { + return proj4.Projection.get('EPSG:3794') ?? proj4.Projection.add('EPSG:3794', epsg3794Def); + } catch (e) { + // If already registered, get it + return proj4.Projection.get('EPSG:3794')!; + } +} + +/// Create Proj4Crs for EPSG:3794 +/// +/// Configuration matches the GeoWebCache tile grid used by prostor.zgs.gov.si +/// +/// Official tile grid from WMTS GetCapabilities: +/// - TopLeftCorner: 373217.6542445397, 246158.298050262 +/// - Bounds: X: 373217.65 to 695777.65, Y: 31118.30 to 246158.30 +/// - Tile size: 256x256 pixels +/// - Scale denominators converted to resolutions using: resolution = scaleDenom * 0.00028 +Crs getSlovenianCrs() { + final projection = getEpsg3794Projection(); + + // Resolutions calculated from GeoWebCache scale denominators + // Formula: resolution (m/px) = scaleDenominator * 0.00028 (OGC standard) + final resolutions = [ + 420.0, // Zoom 0 - ScaleDenom: 1500000 + 280.0, // Zoom 1 - ScaleDenom: 1000000 + 210.0, // Zoom 2 - ScaleDenom: 750000 + 140.0, // Zoom 3 - ScaleDenom: 500000 + 70.0, // Zoom 4 - ScaleDenom: 250000 + 28.0, // Zoom 5 - ScaleDenom: 100000 + 14.0, // Zoom 6 - ScaleDenom: 50000 + 7.0, // Zoom 7 - ScaleDenom: 25000 + 4.2, // Zoom 8 - ScaleDenom: 15000 + 2.8, // Zoom 9 - ScaleDenom: 10000 + 1.4, // Zoom 10 - ScaleDenom: 5000 + 0.56, // Zoom 11 - ScaleDenom: 2000 + 0.28, // Zoom 12 - ScaleDenom: 1000 + 0.14, // Zoom 13 - ScaleDenom: 500 + 0.07, // Zoom 14 - ScaleDenom: 250 + 0.028, // Zoom 15 - ScaleDenom: 100 + ]; + + // Bounds from WMS capabilities (actual data extent in Slovenia) + final bounds = Rect.fromLTRB( + 373217.65, // min X (west) + 31118.30, // min Y (south) - top in Rect coordinates + 695777.65, // max X (east) + 246158.30, // max Y (north) - bottom in Rect coordinates + ); + + // Origin from WMTS TileMatrixSet TopLeftCorner + // This is the top-left corner of the tile pyramid (min X, max Y) + final origin = Point(373217.6542445397, 246158.298050262); + + return Proj4Crs.fromFactory( + code: 'EPSG:3794', + proj4Projection: projection, + resolutions: resolutions, + bounds: bounds, + origins: [origin], + ); +} + +/// Singleton instance of Slovenian CRS for reuse +final Crs slovenianCrs = getSlovenianCrs(); diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index 5a86d4c..153bcea 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -127,6 +127,18 @@ class DrawingToolbar extends StatelessWidget { padding: const EdgeInsets.all(4), constraints: const BoxConstraints(), ), + // Clear measurement + if (drawingProvider.drawingMode == DrawingMode.measure && + drawingProvider.measurementPoint1 != null) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () => drawingProvider.clearMeasurement(), + tooltip: AppLocalizations.of(context)!.clearMeasurement, + color: Colors.orange, + iconSize: 20, + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints(), + ), // Complete line drawing if (drawingProvider.drawingMode == DrawingMode.line && drawingProvider.currentLinePoints.length >= 2) @@ -207,6 +219,15 @@ class DrawingToolbar extends StatelessWidget { drawingProvider.setDrawingMode(DrawingMode.rectangle); }, ), + ListTile( + leading: const Icon(Icons.straighten), + title: Text(AppLocalizations.of(sheetContext)!.measureDistance), + subtitle: Text(AppLocalizations.of(sheetContext)!.measureDistanceDesc), + onTap: () { + Navigator.pop(sheetContext); + drawingProvider.setDrawingMode(DrawingMode.measure); + }, + ), const Divider(), // Toggle received drawings visibility SwitchListTile( @@ -465,6 +486,8 @@ class DrawingToolbar extends StatelessWidget { return Icons.show_chart; case DrawingMode.rectangle: return Icons.crop_square; + case DrawingMode.measure: + return Icons.straighten; case DrawingMode.none: return Icons.edit; } @@ -477,6 +500,8 @@ class DrawingToolbar extends StatelessWidget { return AppLocalizations.of(context)!.drawLine; case DrawingMode.rectangle: return AppLocalizations.of(context)!.drawRectangle; + case DrawingMode.measure: + return AppLocalizations.of(context)!.measureDistance; case DrawingMode.none: return AppLocalizations.of(context)!.drawing; } @@ -489,6 +514,8 @@ class DrawingToolbar extends StatelessWidget { return 'Tap map to add points\nTap ✓ to finish'; case DrawingMode.rectangle: return 'Tap start point, then end point'; + case DrawingMode.measure: + return 'Long press two points to measure'; case DrawingMode.none: return ''; } diff --git a/pubspec.lock b/pubspec.lock index b1285f1..49b64df 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -782,7 +782,7 @@ packages: source: hosted version: "6.0.3" proj4dart: - dependency: transitive + dependency: "direct main" description: name: proj4dart sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e diff --git a/pubspec.yaml b/pubspec.yaml index a8d76e2..d4e91af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,9 @@ dependencies: mbtiles: ^0.4.0 http: ^1.2.0 + # Coordinate system projections for WMS (EPSG:3794) + proj4dart: ^2.1.0 + # Permissions permission_handler: ^12.0.1