mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
4474
lib/providers/app_provider.dart
Normal file
4474
lib/providers/app_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
128
lib/providers/channels_provider.dart
Normal file
128
lib/providers/channels_provider.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/channel.dart';
|
||||
|
||||
/// Manages channel information from the MeshCore device
|
||||
class ChannelsProvider with ChangeNotifier {
|
||||
final Map<int, Channel> _channels = {};
|
||||
int _selectedChannelIndex = 0; // Default to public channel
|
||||
|
||||
/// Get all channels
|
||||
List<Channel> get channels =>
|
||||
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
|
||||
/// Get a specific channel by index
|
||||
Channel? getChannel(int index) => _channels[index];
|
||||
|
||||
/// Get the currently selected channel
|
||||
Channel? get selectedChannel => _channels[_selectedChannelIndex];
|
||||
|
||||
/// Get the selected channel index
|
||||
int get selectedChannelIndex => _selectedChannelIndex;
|
||||
|
||||
/// Get the display name for a channel
|
||||
String getChannelDisplayName(int index) {
|
||||
final channel = _channels[index];
|
||||
if (channel != null) {
|
||||
return channel.displayName;
|
||||
}
|
||||
// Fallback if channel hasn't been synced yet
|
||||
return index == 0 ? 'Public' : 'Channel $index';
|
||||
}
|
||||
|
||||
List<Channel> getChannelsByHashByte(int hashByte) {
|
||||
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
|
||||
..sort((a, b) => a.index.compareTo(b.index));
|
||||
}
|
||||
|
||||
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
|
||||
final matches = getChannelsByHashByte(hashByte);
|
||||
return matches.length == 1 ? matches.single.displayName : null;
|
||||
}
|
||||
|
||||
/// Add or update a channel
|
||||
void addOrUpdateChannel({
|
||||
required int index,
|
||||
required String name,
|
||||
required Uint8List secret,
|
||||
int? flags,
|
||||
}) {
|
||||
_channels[index] = Channel(
|
||||
index: index,
|
||||
name: name,
|
||||
secret: secret,
|
||||
flags: flags,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add or update a channel using Channel object
|
||||
void addOrUpdateChannelObject(Channel channel) {
|
||||
_channels[channel.index] = channel;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove a channel by index
|
||||
void removeChannel(int index) {
|
||||
if (_channels.containsKey(index)) {
|
||||
_channels.remove(index);
|
||||
|
||||
// If the deleted channel was selected, switch to public channel
|
||||
if (_selectedChannelIndex == index) {
|
||||
_selectedChannelIndex = 0;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select a channel for sending messages
|
||||
void selectChannel(int index) {
|
||||
if (_channels.containsKey(index) || index == 0) {
|
||||
_selectedChannelIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get channels by type (hash-based vs normal)
|
||||
List<Channel> getHashChannels() {
|
||||
return channels.where((c) => c.isHashChannel).toList();
|
||||
}
|
||||
|
||||
List<Channel> getNormalChannels() {
|
||||
return channels.where((c) => !c.isHashChannel).toList();
|
||||
}
|
||||
|
||||
/// Initialize default public channel
|
||||
void initializePublicChannel() {
|
||||
if (!_channels.containsKey(0)) {
|
||||
_channels[0] = Channel.publicChannel();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all channels
|
||||
void clear() {
|
||||
_channels.clear();
|
||||
_selectedChannelIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear runtime channel state before a live device sync begins.
|
||||
void prepareForDeviceSync() {
|
||||
_channels.clear();
|
||||
_selectedChannelIndex = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if channels have been loaded
|
||||
bool get hasChannels => _channels.isNotEmpty;
|
||||
|
||||
/// Get the number of channels
|
||||
int get channelCount => _channels.length;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channels.clear();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
3068
lib/providers/connection_provider.dart
Normal file
3068
lib/providers/connection_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
1914
lib/providers/contacts_provider.dart
Normal file
1914
lib/providers/contacts_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
628
lib/providers/drawing_provider.dart
Normal file
628
lib/providers/drawing_provider.dart
Normal file
@@ -0,0 +1,628 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/drawing_message_parser.dart';
|
||||
|
||||
/// Drawing mode state
|
||||
enum DrawingMode { none, line, rectangle, measure }
|
||||
|
||||
/// Provider for managing map drawings
|
||||
class DrawingProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'map_drawings';
|
||||
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
Color _selectedColor = DrawingColors.palette[0];
|
||||
bool _showReceivedDrawings = true;
|
||||
bool _showSarMarkers = true;
|
||||
|
||||
// Completed drawings
|
||||
final List<MapDrawing> _drawings = [];
|
||||
bool _isInitialized = false;
|
||||
|
||||
// In-progress drawing
|
||||
MapDrawing? _currentDrawing;
|
||||
List<LatLng> _currentLinePoints = [];
|
||||
LatLng? _rectangleStartPoint;
|
||||
|
||||
// Distance measurement state
|
||||
LatLng? _measurementPoint1;
|
||||
LatLng? _measurementPoint2;
|
||||
double? _measuredDistance; // in meters
|
||||
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _activeMapId;
|
||||
double? _activeMetersPerPixel;
|
||||
|
||||
// Getters
|
||||
DrawingMode get drawingMode => _drawingMode;
|
||||
Color get selectedColor => _selectedColor;
|
||||
bool get showReceivedDrawings => _showReceivedDrawings;
|
||||
bool get showSarMarkers => _showSarMarkers;
|
||||
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
|
||||
String? get activeMapId => _activeMapId;
|
||||
double? get activeMetersPerPixel => _activeMetersPerPixel;
|
||||
List<MapDrawing> get drawings {
|
||||
// Filter out hidden drawings first
|
||||
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
||||
|
||||
// Then filter by received status if needed
|
||||
if (!_showReceivedDrawings) {
|
||||
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
|
||||
}
|
||||
|
||||
visibleDrawings = visibleDrawings.where((drawing) {
|
||||
if (drawing.coordinateSpace != _activeCoordinateSpace) {
|
||||
return false;
|
||||
}
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return drawing.mapId != null && drawing.mapId == _activeMapId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return List.unmodifiable(visibleDrawings.toList());
|
||||
}
|
||||
|
||||
MapDrawing? get currentDrawing => _currentDrawing;
|
||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||
bool get isInitialized => _isInitialized;
|
||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||
double? get measuredDistance => _measuredDistance;
|
||||
|
||||
/// Initialize and load saved drawings
|
||||
Future<void> initialize() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMapContext({
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double? metersPerPixel,
|
||||
}) {
|
||||
final changed =
|
||||
coordinateSpace != _activeCoordinateSpace ||
|
||||
mapId != _activeMapId ||
|
||||
metersPerPixel != _activeMetersPerPixel;
|
||||
_activeCoordinateSpace = coordinateSpace;
|
||||
_activeMapId = mapId;
|
||||
_activeMetersPerPixel = metersPerPixel;
|
||||
if (_measurementPoint1 != null && _measurementPoint2 != null) {
|
||||
_measuredDistance = _calculateDistance(
|
||||
_measurementPoint1!,
|
||||
_measurementPoint2!,
|
||||
);
|
||||
}
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set drawing mode
|
||||
void setDrawingMode(DrawingMode mode) {
|
||||
if (_drawingMode != mode) {
|
||||
// Cancel any in-progress drawing when switching modes
|
||||
_cancelCurrentDrawing();
|
||||
_drawingMode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set selected color
|
||||
void setColor(Color color) {
|
||||
_selectedColor = color;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Toggle visibility of received drawings
|
||||
Future<void> toggleReceivedDrawings() async {
|
||||
_showReceivedDrawings = !_showReceivedDrawings;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
/// Toggle visibility of SAR markers
|
||||
Future<void> toggleSarMarkers() async {
|
||||
_showSarMarkers = !_showSarMarkers;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showReceivedDrawings =
|
||||
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey(_showReceivedDrawingsKey),
|
||||
_showReceivedDrawings,
|
||||
);
|
||||
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
|
||||
}
|
||||
|
||||
/// Start drawing a line
|
||||
void startLine(LatLng point) {
|
||||
if (_drawingMode != DrawingMode.line) return;
|
||||
|
||||
_currentLinePoints = [point];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add point to current line
|
||||
void addLinePoint(LatLng point) {
|
||||
if (_drawingMode != DrawingMode.line || _currentLinePoints.isEmpty) return;
|
||||
|
||||
_currentLinePoints.add(point);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Complete current line drawing
|
||||
void completeLine() {
|
||||
if (_drawingMode != DrawingMode.line || _currentLinePoints.length < 2) {
|
||||
_cancelCurrentDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
final drawing = LineDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
points: List.from(_currentLinePoints),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_drawings.add(drawing);
|
||||
_currentLinePoints = [];
|
||||
_saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start drawing a rectangle
|
||||
void startRectangle(LatLng point) {
|
||||
if (_drawingMode != DrawingMode.rectangle) return;
|
||||
|
||||
_rectangleStartPoint = point;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Update rectangle end point (for preview)
|
||||
void updateRectangleEndPoint(LatLng endPoint) {
|
||||
if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview rectangle
|
||||
_currentDrawing = RectangleDrawing(
|
||||
id: 'preview',
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: LatLng(
|
||||
_rectangleStartPoint!.latitude > endPoint.latitude
|
||||
? endPoint.latitude
|
||||
: _rectangleStartPoint!.latitude,
|
||||
_rectangleStartPoint!.longitude < endPoint.longitude
|
||||
? _rectangleStartPoint!.longitude
|
||||
: endPoint.longitude,
|
||||
),
|
||||
bottomRight: LatLng(
|
||||
_rectangleStartPoint!.latitude < endPoint.latitude
|
||||
? endPoint.latitude
|
||||
: _rectangleStartPoint!.latitude,
|
||||
_rectangleStartPoint!.longitude > endPoint.longitude
|
||||
? _rectangleStartPoint!.longitude
|
||||
: endPoint.longitude,
|
||||
),
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Complete current rectangle drawing
|
||||
void completeRectangle(LatLng endPoint) {
|
||||
if (_drawingMode != DrawingMode.rectangle || _rectangleStartPoint == null) {
|
||||
_cancelCurrentDrawing();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate top-left and bottom-right corners
|
||||
final topLeft = LatLng(
|
||||
_rectangleStartPoint!.latitude > endPoint.latitude
|
||||
? endPoint.latitude
|
||||
: _rectangleStartPoint!.latitude,
|
||||
_rectangleStartPoint!.longitude < endPoint.longitude
|
||||
? _rectangleStartPoint!.longitude
|
||||
: endPoint.longitude,
|
||||
);
|
||||
|
||||
final bottomRight = LatLng(
|
||||
_rectangleStartPoint!.latitude < endPoint.latitude
|
||||
? endPoint.latitude
|
||||
: _rectangleStartPoint!.latitude,
|
||||
_rectangleStartPoint!.longitude > endPoint.longitude
|
||||
? _rectangleStartPoint!.longitude
|
||||
: endPoint.longitude,
|
||||
);
|
||||
|
||||
final drawing = RectangleDrawing(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
topLeft: topLeft,
|
||||
bottomRight: bottomRight,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
|
||||
_drawings.add(drawing);
|
||||
_rectangleStartPoint = null;
|
||||
_currentDrawing = null;
|
||||
_saveDrawings();
|
||||
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) {
|
||||
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
|
||||
_activeMetersPerPixel != null) {
|
||||
final dy = point2.latitude - point1.latitude;
|
||||
final dx = point2.longitude - point1.longitude;
|
||||
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
|
||||
return pixelDistance * _activeMetersPerPixel!;
|
||||
}
|
||||
const Distance distance = Distance();
|
||||
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)
|
||||
void cancelCurrentDrawing() {
|
||||
_cancelCurrentDrawing();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove a specific drawing
|
||||
void removeDrawing(String id) {
|
||||
_drawings.removeWhere((d) => d.id == id);
|
||||
_saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Clear all drawings
|
||||
void clearAllDrawings() {
|
||||
_drawings.clear();
|
||||
_cancelCurrentDrawing();
|
||||
_saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Exit drawing mode
|
||||
void exitDrawingMode() {
|
||||
_cancelCurrentDrawing();
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Save drawings to persistent storage
|
||||
Future<void> _saveDrawings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
||||
final jsonString = jsonEncode(jsonList);
|
||||
await prefs.setString(_scopedKey(_storageKey), jsonString);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving drawings: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load drawings from persistent storage
|
||||
Future<void> _loadDrawings() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_scopedKey(_storageKey));
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_drawings.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
_drawings.clear();
|
||||
|
||||
for (final json in jsonList) {
|
||||
final drawing = MapDrawing.fromJson(json as Map<String, dynamic>);
|
||||
if (drawing != null) {
|
||||
_drawings.add(drawing);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('Error loading drawings: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current preview drawing for rendering
|
||||
MapDrawing? getPreviewDrawing() {
|
||||
if (_drawingMode == DrawingMode.line && _currentLinePoints.length >= 2) {
|
||||
return LineDrawing(
|
||||
id: 'preview',
|
||||
color: _selectedColor,
|
||||
createdAt: DateTime.now(),
|
||||
points: _currentLinePoints,
|
||||
coordinateSpace: _activeCoordinateSpace,
|
||||
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||
? _activeMapId
|
||||
: null,
|
||||
);
|
||||
} else if (_drawingMode == DrawingMode.rectangle &&
|
||||
_currentDrawing != null) {
|
||||
return _currentDrawing;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Add received drawing from another node
|
||||
void addReceivedDrawing(MapDrawing drawing) {
|
||||
// Check if drawing with this ID already exists
|
||||
if (_drawings.any((d) => d.id == drawing.id)) {
|
||||
debugPrint('Drawing ${drawing.id} already exists, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as received when adding
|
||||
final receivedDrawing = _createReceivedCopy(drawing);
|
||||
_drawings.add(receivedDrawing);
|
||||
_saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Create a copy of a drawing marked as received
|
||||
MapDrawing _createReceivedCopy(MapDrawing drawing) {
|
||||
if (drawing is LineDrawing) {
|
||||
return LineDrawing(
|
||||
id: drawing.id,
|
||||
color: drawing.color,
|
||||
createdAt: drawing.createdAt,
|
||||
points: drawing.points,
|
||||
senderName: drawing.senderName,
|
||||
isReceived: true,
|
||||
messageId: drawing.messageId,
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
return RectangleDrawing(
|
||||
id: drawing.id,
|
||||
color: drawing.color,
|
||||
createdAt: drawing.createdAt,
|
||||
topLeft: drawing.topLeft,
|
||||
bottomRight: drawing.bottomRight,
|
||||
senderName: drawing.senderName,
|
||||
isReceived: true,
|
||||
messageId: drawing.messageId,
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
return drawing;
|
||||
}
|
||||
|
||||
/// Get a drawing by its ID
|
||||
MapDrawing? getDrawingById(String id) {
|
||||
try {
|
||||
return _drawings.firstWhere((d) => d.id == id);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> exportDrawingsJson() {
|
||||
return _drawings.map((drawing) => drawing.toJson()).toList();
|
||||
}
|
||||
|
||||
Future<void> replaceDrawingsFromJson(
|
||||
List<Map<String, dynamic>> jsonList,
|
||||
) async {
|
||||
_drawings
|
||||
..clear()
|
||||
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
|
||||
await _saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
|
||||
/// Get all unshared drawings (local drawings not yet sent)
|
||||
List<MapDrawing> getUnsharedDrawings() {
|
||||
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||
}
|
||||
|
||||
/// Mark a drawing as shared
|
||||
void markDrawingAsShared(String id) {
|
||||
final index = _drawings.indexWhere((d) => d.id == id);
|
||||
if (index != -1) {
|
||||
final drawing = _drawings[index];
|
||||
|
||||
// Create a copy with isShared = true
|
||||
if (drawing is LineDrawing) {
|
||||
_drawings[index] = LineDrawing(
|
||||
id: drawing.id,
|
||||
color: drawing.color,
|
||||
createdAt: drawing.createdAt,
|
||||
points: drawing.points,
|
||||
senderName: drawing.senderName,
|
||||
isReceived: drawing.isReceived,
|
||||
messageId: drawing.messageId,
|
||||
isShared: true,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
_drawings[index] = RectangleDrawing(
|
||||
id: drawing.id,
|
||||
color: drawing.color,
|
||||
createdAt: drawing.createdAt,
|
||||
topLeft: drawing.topLeft,
|
||||
bottomRight: drawing.bottomRight,
|
||||
senderName: drawing.senderName,
|
||||
isReceived: drawing.isReceived,
|
||||
messageId: drawing.messageId,
|
||||
isShared: true,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
_saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle visibility of a drawing (doesn't save to storage)
|
||||
void toggleDrawingVisibility(String id) {
|
||||
final index = _drawings.indexWhere((d) => d.id == id);
|
||||
if (index != -1) {
|
||||
final drawing = _drawings[index];
|
||||
|
||||
// Create a copy with toggled isHidden flag
|
||||
if (drawing is LineDrawing) {
|
||||
_drawings[index] = LineDrawing(
|
||||
id: drawing.id,
|
||||
color: drawing.color,
|
||||
createdAt: drawing.createdAt,
|
||||
points: drawing.points,
|
||||
senderName: drawing.senderName,
|
||||
isReceived: drawing.isReceived,
|
||||
messageId: drawing.messageId,
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: !drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
} else if (drawing is RectangleDrawing) {
|
||||
_drawings[index] = RectangleDrawing(
|
||||
id: drawing.id,
|
||||
color: drawing.color,
|
||||
createdAt: drawing.createdAt,
|
||||
topLeft: drawing.topLeft,
|
||||
bottomRight: drawing.bottomRight,
|
||||
senderName: drawing.senderName,
|
||||
isReceived: drawing.isReceived,
|
||||
messageId: drawing.messageId,
|
||||
isShared: drawing.isShared,
|
||||
isSent: drawing.isSent,
|
||||
isHidden: !drawing.isHidden,
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
// Don't save to storage - visibility toggle is temporary
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a drawing and its linked message
|
||||
void removeDrawingAndMessage(String drawingId, dynamic messagesProvider) {
|
||||
final drawing = getDrawingById(drawingId);
|
||||
if (drawing == null) return;
|
||||
|
||||
// Remove the drawing
|
||||
_drawings.removeWhere((d) => d.id == drawingId);
|
||||
|
||||
// If the drawing has a linked message, remove it too
|
||||
if (drawing.messageId != null && messagesProvider != null) {
|
||||
messagesProvider.deleteMessage(drawing.messageId!);
|
||||
}
|
||||
|
||||
_saveDrawings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Broadcast a drawing to contacts
|
||||
/// Returns the formatted message string ready to send
|
||||
/// Sender will be determined from packet metadata on receiving end
|
||||
String createDrawingBroadcastMessage(MapDrawing drawing) {
|
||||
return DrawingMessageParser.createDrawingMessage(drawing);
|
||||
}
|
||||
}
|
||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Tracks one or more in-flight waiters for the same fragment ACK key.
|
||||
///
|
||||
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
|
||||
/// Completing all registered waiters avoids losing the earlier completer when a
|
||||
/// later request registers for the same key.
|
||||
class FragmentAckWaitRegistry {
|
||||
final Map<String, List<Completer<void>>> _waiters = {};
|
||||
|
||||
Future<bool> waitFor(
|
||||
String key, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final completer = Completer<void>();
|
||||
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
|
||||
waiters.add(completer);
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
final pending = _waiters[key];
|
||||
pending?.remove(completer);
|
||||
if (pending != null && pending.isEmpty) {
|
||||
_waiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int complete(String key) {
|
||||
final waiters = _waiters.remove(key);
|
||||
if (waiters == null || waiters.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
for (final completer in waiters) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
return waiters.length;
|
||||
}
|
||||
}
|
||||
234
lib/providers/helpers/message_delivery_tracker.dart
Normal file
234
lib/providers/helpers/message_delivery_tracker.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Message delivery tracking helper
|
||||
///
|
||||
/// Manages message delivery tracking for sent messages, including:
|
||||
/// - FIFO queue for matching RESP_CODE_SENT with message IDs
|
||||
/// - ACK tag to message ID mapping
|
||||
/// - Timeout tracking for stale ACK mappings
|
||||
/// - Message sent/delivered coordination
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware tracks max 8 pending ACKs in circular buffer
|
||||
/// - ACK entries overwritten after 8 messages → need rate limiting
|
||||
/// - Duplicate ACKs suppressed after first match
|
||||
/// - No automatic retry → app must implement
|
||||
class MessageDeliveryTracker {
|
||||
/// FIFO queue of pending message IDs
|
||||
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||
final List<String> _pendingMessageIds = [];
|
||||
|
||||
/// Contact-scoped FIFOs for matching direct-message SENT responses.
|
||||
final Map<String, List<String>> _pendingMessageIdsByContact = {};
|
||||
|
||||
/// Map of ACK tag to message ID for delivery confirmation
|
||||
final Map<int, String> _ackTagToMessageId = {};
|
||||
|
||||
/// Map of message ID to ACK tag (reverse mapping for cleanup)
|
||||
final Map<String, int> _messageIdToAckTag = {};
|
||||
|
||||
/// Map of ACK tag to timestamp for timeout cleanup
|
||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||
|
||||
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||
Completer<void>? _slotFreedCompleter;
|
||||
|
||||
/// Track a pending message ID before sending
|
||||
///
|
||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||
/// arrives, we pop from this FIFO queue to match with the ACK tag.
|
||||
void trackPendingMessage(String messageId) {
|
||||
_pendingMessageIds.add(messageId);
|
||||
}
|
||||
|
||||
/// Track a pending direct message ID for a specific contact.
|
||||
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
|
||||
trackPendingMessage(messageId);
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
_pendingMessageIdsByContact
|
||||
.putIfAbsent(contactKey, () => [])
|
||||
.add(messageId);
|
||||
}
|
||||
|
||||
/// Pop the next pending message ID from FIFO queue
|
||||
///
|
||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||
String? popPendingMessageId() {
|
||||
if (_pendingMessageIds.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _pendingMessageIds.removeAt(0);
|
||||
}
|
||||
|
||||
/// Pop the next pending direct message ID for a specific contact.
|
||||
///
|
||||
/// Falls back to the legacy global FIFO if the contact queue is empty.
|
||||
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
final queue = _pendingMessageIdsByContact[contactKey];
|
||||
if (queue == null || queue.isEmpty) {
|
||||
return popPendingMessageId();
|
||||
}
|
||||
|
||||
final messageId = queue.removeAt(0);
|
||||
if (queue.isEmpty) {
|
||||
_pendingMessageIdsByContact.remove(contactKey);
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||
///
|
||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||
///
|
||||
/// WARNING: Firmware only tracks 8 pending ACKs! Caller should
|
||||
/// enforce rate limiting before calling this.
|
||||
void mapAckTagToMessageId(int ackTag, String messageId) {
|
||||
// Store bidirectional mapping
|
||||
_ackTagToMessageId[ackTag] = messageId;
|
||||
_messageIdToAckTag[messageId] = ackTag;
|
||||
_ackTagTimestamps[ackTag] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Get message ID for ACK code
|
||||
///
|
||||
/// Called when SEND_CONFIRMED arrives. Returns the message ID
|
||||
/// that corresponds to this ACK code.
|
||||
///
|
||||
/// Returns null if ACK tag not found.
|
||||
String? getMessageIdForAck(int ackCode) {
|
||||
return _ackTagToMessageId[ackCode];
|
||||
}
|
||||
|
||||
/// Returns true once a message has been matched to a concrete ACK tag.
|
||||
bool hasAckForMessage(String messageId) {
|
||||
return _messageIdToAckTag.containsKey(messageId);
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping after delivery confirmed or timeout
|
||||
///
|
||||
/// Cleans up both forward and reverse mappings.
|
||||
void removeAckTag(int ackCode) {
|
||||
final messageId = _ackTagToMessageId.remove(ackCode);
|
||||
if (messageId != null) {
|
||||
_messageIdToAckTag.remove(messageId);
|
||||
}
|
||||
_ackTagTimestamps.remove(ackCode);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping by message ID
|
||||
///
|
||||
/// Used when message times out or is cancelled.
|
||||
void removeByMessageId(String messageId) {
|
||||
final ackTag = _messageIdToAckTag.remove(messageId);
|
||||
if (ackTag != null) {
|
||||
_ackTagToMessageId.remove(ackTag);
|
||||
_ackTagTimestamps.remove(ackTag);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
final emptyKeys = <String>[];
|
||||
for (final entry in _pendingMessageIdsByContact.entries) {
|
||||
entry.value.remove(messageId);
|
||||
if (entry.value.isEmpty) {
|
||||
emptyKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final key in emptyKeys) {
|
||||
_pendingMessageIdsByContact.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale ACK mappings
|
||||
///
|
||||
/// Removes ACK tags that haven't received delivery confirmation
|
||||
/// within the specified timeout (default: 5 minutes).
|
||||
///
|
||||
/// Returns count of cleaned up entries.
|
||||
int cleanupStaleAcks({Duration timeout = const Duration(minutes: 5)}) {
|
||||
final now = DateTime.now();
|
||||
final staleAcks = <int>[];
|
||||
|
||||
for (final entry in _ackTagTimestamps.entries) {
|
||||
if (now.difference(entry.value) > timeout) {
|
||||
staleAcks.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final ackTag in staleAcks) {
|
||||
removeAckTag(ackTag);
|
||||
}
|
||||
|
||||
return staleAcks.length;
|
||||
}
|
||||
|
||||
/// Clear all tracking state
|
||||
void clearTracking() {
|
||||
_pendingMessageIds.clear();
|
||||
_pendingMessageIdsByContact.clear();
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Get count of pending ACK tags
|
||||
///
|
||||
/// WARNING: Firmware only tracks 8 pending ACKs in circular buffer.
|
||||
/// If this exceeds 7, message sending should be rate limited.
|
||||
int get pendingCount => _ackTagToMessageId.length;
|
||||
|
||||
/// Check if should rate limit message sending
|
||||
///
|
||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||
bool get shouldRateLimit => pendingCount >= 7;
|
||||
|
||||
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||
///
|
||||
/// Returns immediately if not at the rate limit.
|
||||
Future<void> waitForSlot({
|
||||
Duration timeout = const Duration(milliseconds: 500),
|
||||
}) async {
|
||||
if (!shouldRateLimit) return;
|
||||
_slotFreedCompleter ??= Completer<void>();
|
||||
await _slotFreedCompleter!.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void _notifySlotFreed() {
|
||||
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||
_slotFreedCompleter!.complete();
|
||||
}
|
||||
_slotFreedCompleter = null;
|
||||
}
|
||||
|
||||
/// Get oldest pending ACK timestamp (for debugging)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
/// Get diagnostic info for debugging
|
||||
Map<String, dynamic> getDiagnostics() {
|
||||
return {
|
||||
'pendingCount': pendingCount,
|
||||
'shouldRateLimit': shouldRateLimit,
|
||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
||||
'pendingByContact': _pendingMessageIdsByContact.map(
|
||||
(key, value) => MapEntry(key, value.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _contactKey(Uint8List contactPublicKey) {
|
||||
return contactPublicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
}
|
||||
178
lib/providers/helpers/message_retry_manager.dart
Normal file
178
lib/providers/helpers/message_retry_manager.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages.
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware calculates timeout based on path length and airtime
|
||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||
/// - Firmware does NOT automatically retry - app must implement
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
final Map<String, int> _retryAttempts = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
/// Max retry attempts when the contact has a known path.
|
||||
/// Sequence: 2 direct attempts, flood, then last successful route.
|
||||
static const int maxRetryAttemptsWithPath = 3;
|
||||
|
||||
/// No retries for flood-only contacts (no known path).
|
||||
/// Value 0 means: don't retry at all, go straight to fallback/fail.
|
||||
static const int maxRetryAttemptsFloodOnly = 0;
|
||||
|
||||
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||
|
||||
// Retry backoff values in milliseconds.
|
||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
static const int _defaultLoRaPreambleSymbols = 8;
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get backoff delay for the next retry attempt.
|
||||
int getDelayForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||
return _retryDelays.last;
|
||||
}
|
||||
return _retryDelays[attempt];
|
||||
}
|
||||
|
||||
static final math.Random _rng = math.Random();
|
||||
|
||||
/// Calculate a delivery-ACK timeout with random jitter.
|
||||
///
|
||||
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||
/// The jitter prevents collision when multiple messages are in flight.
|
||||
int calculateAckTimeoutMs({
|
||||
required String text,
|
||||
required Contact? contact,
|
||||
int? suggestedTimeoutMs,
|
||||
}) {
|
||||
int baseTimeout;
|
||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||
baseTimeout = suggestedTimeoutMs;
|
||||
} else {
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||
} else {
|
||||
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
}
|
||||
|
||||
// Add random jitter: 500-2000ms to avoid collision
|
||||
final jitterMs = 500 + _rng.nextInt(1501);
|
||||
return baseTimeout + jitterMs;
|
||||
}
|
||||
|
||||
/// Max attempts for a given contact based on whether it has a known path.
|
||||
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||
final hasPath = contact?.routeHasPath ?? false;
|
||||
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||
}
|
||||
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||
}
|
||||
|
||||
/// Whether this is the last retry attempt.
|
||||
/// When true, the caller should reset the path to force flood mode.
|
||||
///
|
||||
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||
/// directly against maxAttempts (not +1).
|
||||
bool isLastAttempt(Message message, Contact contact) {
|
||||
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
void trackRetry(String messageId, int attempt) {
|
||||
_retryAttempts[messageId] = attempt;
|
||||
_lastRetryTimes[messageId] = DateTime.now();
|
||||
}
|
||||
|
||||
/// Clear retry tracking for a message (on success or permanent failure)
|
||||
void clearRetry(String messageId) {
|
||||
_retryAttempts.remove(messageId);
|
||||
_lastRetryTimes.remove(messageId);
|
||||
}
|
||||
|
||||
/// Clear all retry tracking (on disconnect)
|
||||
void clearAll() {
|
||||
_retryAttempts.clear();
|
||||
_lastRetryTimes.clear();
|
||||
_pathFailureStreaks.clear();
|
||||
}
|
||||
|
||||
/// Get current retry attempt for a message (for debugging)
|
||||
int? getRetryAttempt(String messageId) {
|
||||
return _retryAttempts[messageId];
|
||||
}
|
||||
|
||||
/// Get last retry time for a message (for debugging)
|
||||
DateTime? getLastRetryTime(String messageId) {
|
||||
return _lastRetryTimes[messageId];
|
||||
}
|
||||
|
||||
/// Record a successful delivery for a contact and clear any accumulated
|
||||
/// route failure streak for future sends.
|
||||
void recordDeliverySuccess(Contact contact) {
|
||||
_pathFailureStreaks.remove(contact.publicKeyHex);
|
||||
}
|
||||
|
||||
/// Record a permanent route failure for a contact.
|
||||
///
|
||||
/// Returns the updated failure streak so callers can decide when to reset
|
||||
/// the learned path on the radio and in local state.
|
||||
int recordPathFailure(Contact contact) {
|
||||
final contactKey = contact.publicKeyHex;
|
||||
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
|
||||
_pathFailureStreaks[contactKey] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
int? getPathFailureStreak(Contact contact) {
|
||||
return _pathFailureStreaks[contact.publicKeyHex];
|
||||
}
|
||||
|
||||
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
|
||||
final sf = _defaultLoRaSf;
|
||||
final bw = _defaultLoRaBwHz.toDouble();
|
||||
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return (preambleMs + payloadMs).ceil();
|
||||
}
|
||||
}
|
||||
120
lib/providers/helpers/ping_tracker.dart
Normal file
120
lib/providers/helpers/ping_tracker.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Helper class to track pending ping (telemetry) requests
|
||||
/// and implement automatic fallback to flooding if no response received
|
||||
class PingTracker {
|
||||
// Map of public key hex string to ping request state
|
||||
final Map<String, _PingRequest> _pendingPings = {};
|
||||
|
||||
// Timeout duration for ping responses (seconds)
|
||||
static const int _pingTimeoutSeconds = 5;
|
||||
|
||||
/// Track a new ping request
|
||||
/// Returns a Future that completes when either:
|
||||
/// - A response is received (completes with true)
|
||||
/// - Timeout occurs (completes with false)
|
||||
Future<bool> trackPing({
|
||||
required Uint8List publicKey,
|
||||
required bool wasDirectAttempt,
|
||||
}) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
|
||||
// Cancel any existing pending ping for this contact
|
||||
_pendingPings[keyHex]?.cancel();
|
||||
|
||||
// Create new ping request tracker
|
||||
final completer = Completer<bool>();
|
||||
final timer = Timer(const Duration(seconds: _pingTimeoutSeconds), () {
|
||||
// Timeout occurred - mark as failed
|
||||
_pendingPings.remove(keyHex);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
});
|
||||
|
||||
_pendingPings[keyHex] = _PingRequest(
|
||||
publicKey: publicKey,
|
||||
wasDirectAttempt: wasDirectAttempt,
|
||||
timer: timer,
|
||||
completer: completer,
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Mark a ping as successful (response received)
|
||||
/// Should be called when telemetry response arrives
|
||||
void markPingSuccessful(Uint8List publicKey) {
|
||||
final requestKey = _findMatchingPendingPingKey(publicKey);
|
||||
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
|
||||
|
||||
if (request != null) {
|
||||
request.cancel();
|
||||
if (!request.completer.isCompleted) {
|
||||
request.completer.complete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if there's a pending ping for this contact
|
||||
bool hasPendingPing(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
return _pendingPings.containsKey(keyHex);
|
||||
}
|
||||
|
||||
/// Get pending ping info (was it a direct attempt?)
|
||||
bool? wasPingDirect(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
return _pendingPings[keyHex]?.wasDirectAttempt;
|
||||
}
|
||||
|
||||
/// Clear all pending pings (useful on disconnect)
|
||||
void clearAll() {
|
||||
for (final request in _pendingPings.values) {
|
||||
request.cancel();
|
||||
}
|
||||
_pendingPings.clear();
|
||||
}
|
||||
|
||||
/// Convert public key to hex string for map key
|
||||
String _publicKeyToHex(Uint8List publicKey) {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
String? _findMatchingPendingPingKey(Uint8List responseKey) {
|
||||
final responseHex = _publicKeyToHex(responseKey);
|
||||
|
||||
if (_pendingPings.containsKey(responseHex)) {
|
||||
return responseHex;
|
||||
}
|
||||
|
||||
for (final entry in _pendingPings.entries) {
|
||||
final pendingHex = entry.key;
|
||||
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
|
||||
return pendingHex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to track a single ping request
|
||||
class _PingRequest {
|
||||
final Uint8List publicKey;
|
||||
final bool wasDirectAttempt;
|
||||
final Timer timer;
|
||||
final Completer<bool> completer;
|
||||
|
||||
_PingRequest({
|
||||
required this.publicKey,
|
||||
required this.wasDirectAttempt,
|
||||
required this.timer,
|
||||
required this.completer,
|
||||
});
|
||||
|
||||
void cancel() {
|
||||
timer.cancel();
|
||||
}
|
||||
}
|
||||
90
lib/providers/helpers/raw_session_retransmit.dart
Normal file
90
lib/providers/helpers/raw_session_retransmit.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
|
||||
typedef RawPacketSender =
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
});
|
||||
|
||||
/// Pacing between consecutive raw fragments. Firmware v1.15 rejects bursts of
|
||||
/// raw packets with `ERROR: Table full` when fragments are blasted too quickly,
|
||||
/// so we space them out to keep the radio's raw transmit queue from overflowing.
|
||||
const Duration _interFragmentDelay = Duration(milliseconds: 350);
|
||||
|
||||
Future<bool> serveCachedSessionFragments<T>({
|
||||
required String providerLabel,
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
required List<T> fragments,
|
||||
required int maxDirectPayloadHops,
|
||||
required int Function(T fragment) indexOf,
|
||||
required Uint8List Function(T fragment) encodeBinary,
|
||||
required RawPacketSender? sendRawPacket,
|
||||
Set<int>? requestedIndices,
|
||||
Duration interFragmentDelay = _interFragmentDelay,
|
||||
}) async {
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||
return false;
|
||||
}
|
||||
if (sendRawPacket == null) {
|
||||
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (!requester.routeHasPath) {
|
||||
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||
return false;
|
||||
}
|
||||
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (requester.outPath.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
var servedCount = 0;
|
||||
for (final fragment in fragments) {
|
||||
final index = indexOf(fragment);
|
||||
if (index < 0) {
|
||||
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
|
||||
continue;
|
||||
}
|
||||
if (requestedIndices != null && !requestedIndices.contains(index)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (servedCount > 0 && interFragmentDelay > Duration.zero) {
|
||||
await Future<void>.delayed(interFragmentDelay);
|
||||
}
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (servedCount == 0) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
|
||||
return true;
|
||||
}
|
||||
84
lib/providers/helpers/room_login_manager.dart
Normal file
84
lib/providers/helpers/room_login_manager.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../models/room_login_state.dart';
|
||||
|
||||
/// Room login state management helper
|
||||
///
|
||||
/// Manages login state tracking for room contacts, including:
|
||||
/// - Room login state per contact (Map of String to RoomLoginState)
|
||||
/// - Password checking logic
|
||||
/// - Login success/fail state updates
|
||||
class RoomLoginManager {
|
||||
/// Map of room public key prefix (hex string) to login state
|
||||
final Map<String, RoomLoginState> _roomLoginStates = {};
|
||||
|
||||
/// Get all room login states (unmodifiable view)
|
||||
Map<String, RoomLoginState> get roomLoginStates => Map.unmodifiable(_roomLoginStates);
|
||||
|
||||
/// Get login state for a room by public key prefix
|
||||
RoomLoginState? getRoomLoginState(Uint8List publicKeyPrefix) {
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
return _roomLoginStates[prefixHex];
|
||||
}
|
||||
|
||||
/// Check if logged into a specific room
|
||||
bool isLoggedIntoRoom(Uint8List publicKeyPrefix) {
|
||||
final state = getRoomLoginState(publicKeyPrefix);
|
||||
return state?.isLoggedIn ?? false;
|
||||
}
|
||||
|
||||
/// Update room login state after successful login
|
||||
Future<void> handleLoginSuccess({
|
||||
required Uint8List publicKeyPrefix,
|
||||
required int permissions,
|
||||
required bool isAdmin,
|
||||
required int tag,
|
||||
}) async {
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
final hasPassword = await _hasPasswordForRoom(publicKeyPrefix);
|
||||
|
||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedIn(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
permissions: permissions,
|
||||
isAdmin: isAdmin,
|
||||
tag: tag,
|
||||
hasPassword: hasPassword,
|
||||
);
|
||||
}
|
||||
|
||||
/// Update room login state after failed login
|
||||
void handleLoginFail({
|
||||
required Uint8List publicKeyPrefix,
|
||||
}) {
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
|
||||
_roomLoginStates[prefixHex] = RoomLoginState.loggedOut(
|
||||
publicKeyPrefix: publicKeyPrefix,
|
||||
hasPassword: false, // Password was incorrect
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all room login states (call on disconnect)
|
||||
void clearRoomLoginStates() {
|
||||
_roomLoginStates.clear();
|
||||
}
|
||||
|
||||
/// Check if a password exists for a room (by public key prefix)
|
||||
Future<bool> _hasPasswordForRoom(Uint8List publicKeyPrefix) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Convert prefix to hex string for storage key
|
||||
final prefixHex = _publicKeyPrefixToHex(publicKeyPrefix);
|
||||
final roomKey = 'room_password_$prefixHex';
|
||||
return prefs.getString(roomKey) != null;
|
||||
} catch (e) {
|
||||
debugPrint('Error checking password for room: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert public key prefix to hex string (colon-separated)
|
||||
String _publicKeyPrefixToHex(Uint8List publicKeyPrefix) {
|
||||
return publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
}
|
||||
}
|
||||
57
lib/providers/helpers/session_metadata_restore.dart
Normal file
57
lib/providers/helpers/session_metadata_restore.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
|
||||
class RestoredSessionMetadata {
|
||||
final Map<String, String> voiceSenderKeyBySession;
|
||||
final Map<String, String> imageSenderKeyBySession;
|
||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||
|
||||
const RestoredSessionMetadata({
|
||||
required this.voiceSenderKeyBySession,
|
||||
required this.imageSenderKeyBySession,
|
||||
required this.imageEnvelopeBySession,
|
||||
});
|
||||
}
|
||||
|
||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||
Iterable<Message> messages,
|
||||
) {
|
||||
final voiceSenderKeyBySession = <String, String>{};
|
||||
final imageSenderKeyBySession = <String, String>{};
|
||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||
|
||||
for (final message in messages) {
|
||||
final text = message.text;
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||
if (voiceEnvelope != null) {
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||
if (imageEnvelope != null) {
|
||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RestoredSessionMetadata(
|
||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||
imageSenderKeyBySession: imageSenderKeyBySession,
|
||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||
);
|
||||
}
|
||||
458
lib/providers/image_provider.dart
Normal file
458
lib/providers/image_provider.dart
Normal file
@@ -0,0 +1,458 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import 'helpers/raw_session_retransmit.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
|
||||
/// Reassembly state for one incoming image session.
|
||||
class ImageSession {
|
||||
final String sessionId;
|
||||
final ImageFormat format;
|
||||
final int total;
|
||||
final int width;
|
||||
final int height;
|
||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||
DateTime? firstFragmentAt;
|
||||
DateTime? lastFragmentAt;
|
||||
|
||||
ImageSession({
|
||||
required this.sessionId,
|
||||
required this.format,
|
||||
required this.total,
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : fragments = List.filled(total, null);
|
||||
|
||||
int get receivedCount => fragments.where((f) => f != null).length;
|
||||
bool get isComplete => receivedCount == total;
|
||||
|
||||
Duration? estimateRemaining() {
|
||||
if (isComplete) return Duration.zero;
|
||||
if (firstFragmentAt == null || lastFragmentAt == null) return null;
|
||||
if (receivedCount < 2) return null;
|
||||
|
||||
final elapsedMs = lastFragmentAt!
|
||||
.difference(firstFragmentAt!)
|
||||
.inMilliseconds;
|
||||
if (elapsedMs <= 0) return null;
|
||||
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
|
||||
final remaining = total - receivedCount;
|
||||
if (remaining <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
|
||||
}
|
||||
|
||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||
}
|
||||
|
||||
/// Manages incoming image sessions and outgoing image caches.
|
||||
///
|
||||
/// Mirrors [VoiceProvider] in architecture: on-demand fetch, deferred serving,
|
||||
/// persistent storage of both incoming and outgoing session data.
|
||||
class ImageProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'stored_image_sessions_v1';
|
||||
static const Duration _outgoingTtl = Duration(minutes: 15);
|
||||
static const int maxDirectPayloadHops = 3;
|
||||
|
||||
/// Incoming sessions keyed by sessionId.
|
||||
final Map<String, ImageSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Outgoing sessions cached for deferred serving.
|
||||
final Map<String, _OutgoingSession> _outgoing = {};
|
||||
|
||||
/// Hook for sending a raw binary payload to a contact.
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
|
||||
ImageProvider() {
|
||||
_restore();
|
||||
}
|
||||
|
||||
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
|
||||
|
||||
// ── Accessors ────────────────────────────────────────────────────────────
|
||||
|
||||
ImageSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingFragmentIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < session.total; i++) {
|
||||
if (session.fragments[i] == null) missing.add(i);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availableFragmentIndices(String sessionId) {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.fragments.map((fragment) => fragment.index).toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.fragments.length; i++) {
|
||||
if (session.fragments[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||
|
||||
/// Add a received [fragment]. New compact fragments rely on prior envelope
|
||||
/// metadata for total/format, while legacy fragments can still self-describe.
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(fragment.sessionId, () {
|
||||
if (fragment.total < 1) {
|
||||
throw StateError(
|
||||
'Image envelope missing for compact fragment ${fragment.sessionId}',
|
||||
);
|
||||
}
|
||||
return ImageSession(
|
||||
sessionId: fragment.sessionId,
|
||||
format: fragment.format,
|
||||
total: fragment.total,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[fragment.sessionId]!;
|
||||
if (fragment.index < session.total) {
|
||||
final wasMissing = session.fragments[fragment.index] == null;
|
||||
session.fragments[fragment.index] = fragment;
|
||||
if (wasMissing) {
|
||||
final now = DateTime.now();
|
||||
session.firstFragmentAt ??= now;
|
||||
session.lastFragmentAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register envelope metadata for a session (called when IE1 is received
|
||||
/// before any binary fragments arrive).
|
||||
void registerEnvelope(ImageEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.width == 0 ||
|
||||
existing.height == 0 ||
|
||||
existing.total != envelope.total ||
|
||||
existing.format != envelope.format;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = ImageSession(
|
||||
sessionId: envelope.sessionId,
|
||||
format: envelope.format,
|
||||
total: envelope.total,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
merged.firstFragmentAt = existing.firstFragmentAt;
|
||||
merged.lastFragmentAt = existing.lastFragmentAt;
|
||||
for (final fragment in existing.fragments) {
|
||||
if (fragment == null) continue;
|
||||
if (fragment.index < merged.total) {
|
||||
merged.fragments[fragment.index] = fragment;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Outgoing session management ──────────────────────────────────────────
|
||||
|
||||
/// Cache encoded fragments for deferred serving.
|
||||
///
|
||||
/// Also registers the session as complete in [_sessions] so the local
|
||||
/// bubble can display the sent image immediately without a fetch round-trip.
|
||||
void cacheOutgoingSession(
|
||||
String sessionId,
|
||||
List<ImagePacket> fragments,
|
||||
ImageEnvelope envelope,
|
||||
) {
|
||||
if (fragments.isEmpty) return;
|
||||
_evictExpiredOutgoing();
|
||||
_outgoing[sessionId] = _OutgoingSession(
|
||||
sessionId: sessionId,
|
||||
fragments: List<ImagePacket>.from(fragments),
|
||||
envelope: envelope,
|
||||
cachedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Populate incoming session so the bubble shows the image right away.
|
||||
final session = ImageSession(
|
||||
sessionId: sessionId,
|
||||
format: envelope.format,
|
||||
total: fragments.length,
|
||||
width: envelope.width,
|
||||
height: envelope.height,
|
||||
);
|
||||
for (final f in fragments) {
|
||||
if (f.index < session.total) session.fragments[f.index] = f;
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stream cached image fragments to [requester] via raw binary packets.
|
||||
Future<bool> serveSessionTo({
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
final fragments = outgoing != null
|
||||
? List<ImagePacket>.from(outgoing.fragments)
|
||||
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
|
||||
const <ImagePacket>[];
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return serveCachedSessionFragments<ImagePacket>(
|
||||
providerLabel: 'ImageProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: fragments,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (fragment) => fragment.index,
|
||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_resetInMemoryState();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_scopedStorageKey());
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
_resetInMemoryState();
|
||||
await _restore();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _evictExpiredOutgoing() {
|
||||
final now = DateTime.now();
|
||||
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
try {
|
||||
_evictExpiredOutgoing();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final payload = <String, dynamic>{
|
||||
'incoming': _sessions.values
|
||||
.map(
|
||||
(s) => {
|
||||
'sessionId': s.sessionId,
|
||||
'fmtId': s.format.id,
|
||||
'total': s.total,
|
||||
'width': s.width,
|
||||
'height': s.height,
|
||||
'fragments': s.fragments
|
||||
.map(
|
||||
(f) => f == null ? null : base64.encode(f.encodeBinary()),
|
||||
)
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'outgoing': _outgoing.values
|
||||
.map(
|
||||
(s) => {
|
||||
'sessionId': s.sessionId,
|
||||
'cachedAt': s.cachedAt.millisecondsSinceEpoch,
|
||||
'envelope': s.envelope.encode(),
|
||||
'fragments': s.fragments
|
||||
.map((f) => base64.encode(f.encodeBinary()))
|
||||
.toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_scopedStorageKey());
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
|
||||
for (final item in (parsed['incoming'] as List<dynamic>? ?? [])) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final fmtId = map['fmtId'] as int?;
|
||||
final total = map['total'] as int?;
|
||||
final width = map['width'] as int? ?? 256;
|
||||
final height = map['height'] as int? ?? 256;
|
||||
if (sessionId == null || fmtId == null || total == null || total <= 0) {
|
||||
continue;
|
||||
}
|
||||
final session = ImageSession(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.fromId(fmtId),
|
||||
total: total,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
final frags = map['fragments'] as List<dynamic>? ?? [];
|
||||
for (var i = 0; i < frags.length && i < total; i++) {
|
||||
final enc = frags[i] as String?;
|
||||
if (enc == null || enc.isEmpty) continue;
|
||||
final pkt = ImagePacket.tryParseBinary(base64.decode(enc));
|
||||
if (pkt != null && pkt.index < total) {
|
||||
session.fragments[pkt.index] = pkt;
|
||||
}
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
for (final item in (parsed['outgoing'] as List<dynamic>? ?? [])) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final cachedMs = map['cachedAt'] as int?;
|
||||
final envelopeText = map['envelope'] as String?;
|
||||
if (sessionId == null || cachedMs == null || envelopeText == null) {
|
||||
continue;
|
||||
}
|
||||
final envelope = ImageEnvelope.tryParse(envelopeText);
|
||||
if (envelope == null) continue;
|
||||
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedMs);
|
||||
if (DateTime.now().difference(cachedAt) > _outgoingTtl) continue;
|
||||
|
||||
final fragsRaw = map['fragments'] as List<dynamic>? ?? [];
|
||||
final fragments = <ImagePacket>[];
|
||||
for (final enc in fragsRaw) {
|
||||
final pkt = ImagePacket.tryParseBinary(
|
||||
base64.decode((enc ?? '') as String),
|
||||
);
|
||||
if (pkt != null) fragments.add(pkt);
|
||||
}
|
||||
if (fragments.isNotEmpty) {
|
||||
_outgoing[sessionId] = _OutgoingSession(
|
||||
sessionId: sessionId,
|
||||
fragments: fragments,
|
||||
envelope: envelope,
|
||||
cachedAt: cachedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_sessions.isNotEmpty || _outgoing.isNotEmpty) {
|
||||
debugPrint(
|
||||
'📷 [ImageProvider] Restored ${_sessions.length} incoming, '
|
||||
'${_outgoing.length} outgoing sessions',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _resetInMemoryState() {
|
||||
_sessions.clear();
|
||||
_outgoing.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingSession {
|
||||
final String sessionId;
|
||||
final List<ImagePacket> fragments;
|
||||
final ImageEnvelope envelope;
|
||||
final DateTime cachedAt;
|
||||
|
||||
const _OutgoingSession({
|
||||
required this.sessionId,
|
||||
required this.fragments,
|
||||
required this.envelope,
|
||||
required this.cachedAt,
|
||||
});
|
||||
}
|
||||
782
lib/providers/map_provider.dart
Normal file
782
lib/providers/map_provider.dart
Normal file
@@ -0,0 +1,782 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/custom_map_config.dart';
|
||||
import '../models/location_trail.dart';
|
||||
import '../models/map_coordinate_space.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import '../utils/custom_map_id.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
static const String _customMapConfigKey = 'custom_map_config_v1';
|
||||
static const String _customMapModeKey = 'custom_map_mode_v1';
|
||||
static const String _customMapsDirName = 'custom_maps';
|
||||
|
||||
MapProvider() {
|
||||
unawaited(_loadInitialState());
|
||||
}
|
||||
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
LatLng? _targetLocation;
|
||||
LatLngBounds? _targetBounds;
|
||||
double? _targetZoom;
|
||||
bool _shouldAnimate = false;
|
||||
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
String? _targetMapId;
|
||||
|
||||
LocationTrail? _currentTrail;
|
||||
bool _isTrailVisible = true;
|
||||
bool _isTrailRecordingEnabled = true;
|
||||
final List<LocationTrail> _trailHistory = [];
|
||||
|
||||
bool _showCadastralOverlay = false;
|
||||
bool _showForestRoadsOverlay = false;
|
||||
bool _showHikingTrailsOverlay = false;
|
||||
bool _showMainRoadsOverlay = false;
|
||||
bool _showHouseNumbersOverlay = false;
|
||||
bool _showFireHazardZonesOverlay = false;
|
||||
bool _showHistoricalFiresOverlay = false;
|
||||
bool _showFirebreaksOverlay = false;
|
||||
bool _showKrasFireZonesOverlay = false;
|
||||
bool _showPlaceNamesOverlay = false;
|
||||
bool _showMunicipalityBordersOverlay = false;
|
||||
|
||||
bool _hideRepeatersOnMap = false;
|
||||
|
||||
LocationTrail? _importedTrail;
|
||||
|
||||
bool _isSelectingDownloadArea = false;
|
||||
LatLngBounds? _downloadAreaBounds;
|
||||
|
||||
CustomMapConfig? _customMapConfig;
|
||||
bool _isUsingCustomMap = false;
|
||||
|
||||
LatLng? get targetLocation => _targetLocation;
|
||||
LatLngBounds? get targetBounds => _targetBounds;
|
||||
double? get targetZoom => _targetZoom;
|
||||
bool get shouldAnimate => _shouldAnimate;
|
||||
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
|
||||
String? get targetMapId => _targetMapId;
|
||||
LocationTrail? get currentTrail => _currentTrail;
|
||||
bool get isTrailVisible => _isTrailVisible;
|
||||
bool get isTrailRecordingEnabled => _isTrailRecordingEnabled;
|
||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||
|
||||
bool get showCadastralOverlay => _showCadastralOverlay;
|
||||
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
|
||||
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
|
||||
bool get showMainRoadsOverlay => _showMainRoadsOverlay;
|
||||
bool get showHouseNumbersOverlay => _showHouseNumbersOverlay;
|
||||
bool get showFireHazardZonesOverlay => _showFireHazardZonesOverlay;
|
||||
bool get showHistoricalFiresOverlay => _showHistoricalFiresOverlay;
|
||||
bool get showFirebreaksOverlay => _showFirebreaksOverlay;
|
||||
bool get showKrasFireZonesOverlay => _showKrasFireZonesOverlay;
|
||||
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
|
||||
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
|
||||
|
||||
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||
|
||||
LocationTrail? get importedTrail => _importedTrail;
|
||||
|
||||
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
||||
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
|
||||
|
||||
CustomMapConfig? get customMapConfig => _customMapConfig;
|
||||
bool get hasCustomMap => _customMapConfig != null;
|
||||
bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null;
|
||||
bool get shouldHideGpsData => isUsingCustomMap;
|
||||
|
||||
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _loadInitialState();
|
||||
}
|
||||
|
||||
bool matchesActiveCustomMap(String? mapId) {
|
||||
return hasCustomMap &&
|
||||
normalizeCustomMapId(_customMapConfig!.mapId) ==
|
||||
normalizeCustomMapId(mapId);
|
||||
}
|
||||
|
||||
void navigateToLocation({
|
||||
required LatLng location,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
_targetLocation = location;
|
||||
_targetBounds = null;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
_targetMapId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? navigateToMapPoint({
|
||||
required LatLng point,
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
double zoom = 15.0,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(mapId)) {
|
||||
return 'Load the matching custom map to view this item.';
|
||||
}
|
||||
if (!_isUsingCustomMap) {
|
||||
_isUsingCustomMap = true;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
} else if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
|
||||
_targetLocation = point;
|
||||
_targetBounds = null;
|
||||
_targetZoom = zoom;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = coordinateSpace;
|
||||
_targetMapId = mapId;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? navigateToBounds({
|
||||
required LatLngBounds bounds,
|
||||
required MapCoordinateSpace coordinateSpace,
|
||||
String? mapId,
|
||||
bool animate = true,
|
||||
}) {
|
||||
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(mapId)) {
|
||||
return 'Load the matching custom map to view this item.';
|
||||
}
|
||||
if (!_isUsingCustomMap) {
|
||||
_isUsingCustomMap = true;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
} else if (_isUsingCustomMap) {
|
||||
_isUsingCustomMap = false;
|
||||
unawaited(_saveCustomMapState());
|
||||
}
|
||||
|
||||
_targetBounds = bounds;
|
||||
_targetLocation = bounds.center;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = animate;
|
||||
_targetCoordinateSpace = coordinateSpace;
|
||||
_targetMapId = mapId;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
void clearNavigation() {
|
||||
_targetLocation = null;
|
||||
_targetBounds = null;
|
||||
_targetZoom = null;
|
||||
_shouldAnimate = false;
|
||||
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||
_targetMapId = null;
|
||||
}
|
||||
|
||||
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
||||
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
|
||||
if (drawing == null) {
|
||||
return 'Drawing not found.';
|
||||
}
|
||||
|
||||
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
if (!matchesActiveCustomMap(drawing.mapId)) {
|
||||
return 'Load the matching custom map to view this drawing.';
|
||||
}
|
||||
return navigateToBounds(
|
||||
bounds: drawing.getBounds(),
|
||||
coordinateSpace: drawing.coordinateSpace,
|
||||
mapId: drawing.mapId,
|
||||
);
|
||||
}
|
||||
|
||||
final bounds = drawing.getBounds();
|
||||
final latDiff = (bounds.north - bounds.south).abs();
|
||||
final lonDiff = (bounds.east - bounds.west).abs();
|
||||
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
|
||||
|
||||
double zoom = 15.0;
|
||||
if (maxDiff < 0.001) {
|
||||
zoom = 17.0;
|
||||
} else if (maxDiff < 0.005) {
|
||||
zoom = 16.0;
|
||||
} else if (maxDiff < 0.01) {
|
||||
zoom = 15.0;
|
||||
} else if (maxDiff < 0.05) {
|
||||
zoom = 13.0;
|
||||
} else if (maxDiff < 0.1) {
|
||||
zoom = 12.0;
|
||||
} else {
|
||||
zoom = 10.0;
|
||||
}
|
||||
|
||||
navigateToLocation(
|
||||
location: drawing.getCenter(),
|
||||
zoom: zoom,
|
||||
animate: true,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
String? navigateToSarMarker(SarMarker marker) {
|
||||
if (marker.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||
return navigateToMapPoint(
|
||||
point: marker.location,
|
||||
coordinateSpace: MapCoordinateSpace.customMap,
|
||||
mapId: marker.mapId,
|
||||
);
|
||||
}
|
||||
navigateToLocation(location: marker.location, zoom: 15.0, animate: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> loadCustomMapFromGallery() async {
|
||||
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (picked == null) {
|
||||
return false;
|
||||
}
|
||||
final bytes = await picked.readAsBytes();
|
||||
await setCustomMapImage(bytes: bytes, displayName: picked.name);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> replaceCustomMap() async {
|
||||
await loadCustomMapFromGallery();
|
||||
}
|
||||
|
||||
Future<void> setCustomMapImage({
|
||||
required Uint8List bytes,
|
||||
required String displayName,
|
||||
}) async {
|
||||
final dimensions = await _decodeImageSize(bytes);
|
||||
final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!;
|
||||
final documentsDir = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${documentsDir.path}/$_customMapsDirName');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final extension = _normalizedExtension(displayName);
|
||||
final nextPath = '${dir.path}/custom_map_$mapId.$extension';
|
||||
final nextFile = File(nextPath);
|
||||
await nextFile.writeAsBytes(bytes, flush: true);
|
||||
|
||||
final previousPath = _customMapConfig?.filePath;
|
||||
_customMapConfig = CustomMapConfig(
|
||||
filePath: nextPath,
|
||||
displayName: displayName,
|
||||
mapId: mapId,
|
||||
imageWidth: dimensions.$1,
|
||||
imageHeight: dimensions.$2,
|
||||
);
|
||||
_isUsingCustomMap = true;
|
||||
await _saveCustomMapState();
|
||||
|
||||
if (previousPath != null && previousPath != nextPath) {
|
||||
unawaited(_deleteFileIfExists(previousPath));
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setCustomMapCalibration({
|
||||
required LatLng pointA,
|
||||
required LatLng pointB,
|
||||
required double metersPerPixel,
|
||||
}) async {
|
||||
if (_customMapConfig == null) return;
|
||||
_customMapConfig = _customMapConfig!.copyWith(
|
||||
calibrationPointA: pointA,
|
||||
calibrationPointB: pointB,
|
||||
metersPerPixel: metersPerPixel,
|
||||
);
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> clearCustomMapCalibration() async {
|
||||
if (_customMapConfig == null) return;
|
||||
_customMapConfig = _customMapConfig!.copyWith(
|
||||
clearMetersPerPixel: true,
|
||||
clearCalibrationPointA: true,
|
||||
clearCalibrationPointB: true,
|
||||
);
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeCustomMap() async {
|
||||
final filePath = _customMapConfig?.filePath;
|
||||
_customMapConfig = null;
|
||||
_isUsingCustomMap = false;
|
||||
clearNavigation();
|
||||
await _saveCustomMapState();
|
||||
if (filePath != null) {
|
||||
await _deleteFileIfExists(filePath);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> enterCustomMapMode() async {
|
||||
if (!hasCustomMap || _isUsingCustomMap) return;
|
||||
_isUsingCustomMap = true;
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> exitCustomMapMode() async {
|
||||
if (!_isUsingCustomMap) return;
|
||||
_isUsingCustomMap = false;
|
||||
await _saveCustomMapState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateZoom(double zoom) {
|
||||
_targetZoom = zoom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startTrail() {
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
|
||||
_currentTrail = LocationTrail(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
startTime: DateTime.now(),
|
||||
);
|
||||
_isTrailVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||
if (!_isTrailRecordingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||
startTrail();
|
||||
}
|
||||
|
||||
_currentTrail!.addPoint(
|
||||
TrailPoint(
|
||||
position: position,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void endTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail!.isActive = false;
|
||||
_currentTrail!.endTime = DateTime.now();
|
||||
if (_currentTrail!.points.isNotEmpty) {
|
||||
_trailHistory.add(_currentTrail!);
|
||||
}
|
||||
_currentTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleTrailVisibility() {
|
||||
_isTrailVisible = !_isTrailVisible;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setTrailRecordingEnabled(bool enabled) {
|
||||
if (_isTrailRecordingEnabled == enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isTrailRecordingEnabled = enabled;
|
||||
if (!enabled) {
|
||||
if (_currentTrail != null) {
|
||||
endTrail();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearCurrentTrail() {
|
||||
if (_currentTrail != null) {
|
||||
_currentTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearAllTrails() {
|
||||
_currentTrail = null;
|
||||
_trailHistory.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
double get totalTrailDistance {
|
||||
if (_currentTrail == null) return 0;
|
||||
return _currentTrail!.totalDistance;
|
||||
}
|
||||
|
||||
Duration get trailDuration {
|
||||
if (_currentTrail == null) return Duration.zero;
|
||||
return _currentTrail!.duration;
|
||||
}
|
||||
|
||||
Future<void> toggleCadastralOverlay() async {
|
||||
_showCadastralOverlay = !_showCadastralOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleForestRoadsOverlay() async {
|
||||
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleHikingTrailsOverlay() async {
|
||||
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleMainRoadsOverlay() async {
|
||||
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleHouseNumbersOverlay() async {
|
||||
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleFireHazardZonesOverlay() async {
|
||||
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleHistoricalFiresOverlay() async {
|
||||
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleFirebreaksOverlay() async {
|
||||
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleKrasFireZonesOverlay() async {
|
||||
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> togglePlaceNamesOverlay() async {
|
||||
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> toggleMunicipalityBordersOverlay() async {
|
||||
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
||||
notifyListeners();
|
||||
await _saveOverlayState();
|
||||
}
|
||||
|
||||
Future<void> loadOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showCadastralOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
|
||||
_showForestRoadsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
|
||||
_showMainRoadsOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
|
||||
false;
|
||||
_showHistoricalFiresOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
|
||||
_showFirebreaksOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
|
||||
_showPlaceNamesOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
|
||||
false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialState() async {
|
||||
await Future.wait([
|
||||
loadOverlayState(),
|
||||
loadRepeaterVisibilitySettings(),
|
||||
_loadCustomMapState(),
|
||||
_removeDeprecatedSettings(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _removeDeprecatedSettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
|
||||
}
|
||||
|
||||
Future<void> _saveOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_cadastral_overlay'),
|
||||
_showCadastralOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_forest_roads_overlay'),
|
||||
_showForestRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_hiking_trails_overlay'),
|
||||
_showHikingTrailsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_main_roads_overlay'),
|
||||
_showMainRoadsOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_house_numbers_overlay'),
|
||||
_showHouseNumbersOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_fire_hazard_zones_overlay'),
|
||||
_showFireHazardZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_historical_fires_overlay'),
|
||||
_showHistoricalFiresOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_firebreaks_overlay'),
|
||||
_showFirebreaksOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_kras_fire_zones_overlay'),
|
||||
_showKrasFireZonesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_place_names_overlay'),
|
||||
_showPlaceNamesOverlay,
|
||||
);
|
||||
await prefs.setBool(
|
||||
_scopedKey('map_show_municipality_borders_overlay'),
|
||||
_showMunicipalityBordersOverlay,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||
if (_hideRepeatersOnMap == hide) return;
|
||||
_hideRepeatersOnMap = hide;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
|
||||
}
|
||||
|
||||
Future<void> loadRepeaterVisibilitySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_hideRepeatersOnMap =
|
||||
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setImportedTrail(LocationTrail trail) {
|
||||
_importedTrail = trail;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearImportedTrail() {
|
||||
_importedTrail = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||
endTrail();
|
||||
}
|
||||
_currentTrail = importedTrail;
|
||||
_isTrailVisible = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
||||
_isSelectingDownloadArea = true;
|
||||
_downloadAreaBounds = initialBounds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void exitDownloadAreaMode() {
|
||||
_isSelectingDownloadArea = false;
|
||||
_downloadAreaBounds = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
||||
_downloadAreaBounds = bounds;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
|
||||
if (configJson != null && configJson.isNotEmpty) {
|
||||
final decoded = jsonDecode(configJson);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
final config = CustomMapConfig.fromJson(decoded);
|
||||
if (config != null && await File(config.filePath).exists()) {
|
||||
_customMapConfig = config;
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_customMapConfig = null;
|
||||
}
|
||||
_isUsingCustomMap =
|
||||
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
|
||||
_customMapConfig != null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveCustomMapState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (_customMapConfig == null) {
|
||||
await prefs.remove(_scopedKey(_customMapConfigKey));
|
||||
} else {
|
||||
await prefs.setString(
|
||||
_scopedKey(_customMapConfigKey),
|
||||
jsonEncode(_customMapConfig!.toJson()),
|
||||
);
|
||||
}
|
||||
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
|
||||
}
|
||||
|
||||
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
return (frame.image.width, frame.image.height);
|
||||
}
|
||||
|
||||
String _normalizedExtension(String displayName) {
|
||||
final dotIndex = displayName.lastIndexOf('.');
|
||||
if (dotIndex == -1 || dotIndex == displayName.length - 1) {
|
||||
return 'png';
|
||||
}
|
||||
return displayName.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Future<void> _deleteFileIfExists(String path) async {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> exportWorkspaceJson() {
|
||||
return {
|
||||
'currentTrail': _currentTrail?.toJson(),
|
||||
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
|
||||
'importedTrail': _importedTrail?.toJson(),
|
||||
'isTrailVisible': _isTrailVisible,
|
||||
'isTrailRecordingEnabled': _isTrailRecordingEnabled,
|
||||
'showCadastralOverlay': _showCadastralOverlay,
|
||||
'showForestRoadsOverlay': _showForestRoadsOverlay,
|
||||
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
|
||||
'showMainRoadsOverlay': _showMainRoadsOverlay,
|
||||
'showHouseNumbersOverlay': _showHouseNumbersOverlay,
|
||||
'showFireHazardZonesOverlay': _showFireHazardZonesOverlay,
|
||||
'showHistoricalFiresOverlay': _showHistoricalFiresOverlay,
|
||||
'showFirebreaksOverlay': _showFirebreaksOverlay,
|
||||
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
|
||||
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
|
||||
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
|
||||
'hideRepeatersOnMap': _hideRepeatersOnMap,
|
||||
};
|
||||
}
|
||||
|
||||
void applyWorkspaceJson(Map<String, dynamic> json) {
|
||||
_currentTrail = json['currentTrail'] is Map<String, dynamic>
|
||||
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_trailHistory
|
||||
..clear()
|
||||
..addAll(
|
||||
(json['trailHistory'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(LocationTrail.fromJson),
|
||||
);
|
||||
_importedTrail = json['importedTrail'] is Map<String, dynamic>
|
||||
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
|
||||
: null;
|
||||
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
|
||||
_isTrailRecordingEnabled =
|
||||
json['isTrailRecordingEnabled'] as bool? ?? true;
|
||||
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
|
||||
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
|
||||
_showHikingTrailsOverlay =
|
||||
json['showHikingTrailsOverlay'] as bool? ?? false;
|
||||
_showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false;
|
||||
_showHouseNumbersOverlay =
|
||||
json['showHouseNumbersOverlay'] as bool? ?? false;
|
||||
_showFireHazardZonesOverlay =
|
||||
json['showFireHazardZonesOverlay'] as bool? ?? false;
|
||||
_showHistoricalFiresOverlay =
|
||||
json['showHistoricalFiresOverlay'] as bool? ?? false;
|
||||
_showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false;
|
||||
_showKrasFireZonesOverlay =
|
||||
json['showKrasFireZonesOverlay'] as bool? ?? false;
|
||||
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
|
||||
_showMunicipalityBordersOverlay =
|
||||
json['showMunicipalityBordersOverlay'] as bool? ?? false;
|
||||
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _scopedKey(String baseKey) {
|
||||
return ProfileStorageScope.scopedKey(baseKey);
|
||||
}
|
||||
}
|
||||
3082
lib/providers/messages_provider.dart
Normal file
3082
lib/providers/messages_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
582
lib/providers/offline_tiles_provider.dart
Normal file
582
lib/providers/offline_tiles_provider.dart
Normal file
@@ -0,0 +1,582 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/map_layer.dart';
|
||||
import '../services/offline_tile_cache_service.dart';
|
||||
import '../services/tile_download_service.dart';
|
||||
import '../services/tile_math_service.dart';
|
||||
import '../services/tile_sharing_service.dart';
|
||||
|
||||
export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog;
|
||||
export '../services/offline_tile_cache_service.dart'
|
||||
show StyleInfo, DownloadRegion;
|
||||
|
||||
/// Download progress state.
|
||||
class DownloadProgress {
|
||||
final int downloaded;
|
||||
final int skipped;
|
||||
final int failed;
|
||||
final int total;
|
||||
|
||||
const DownloadProgress({
|
||||
this.downloaded = 0,
|
||||
this.skipped = 0,
|
||||
this.failed = 0,
|
||||
this.total = 0,
|
||||
});
|
||||
|
||||
int get processed => downloaded + skipped + failed;
|
||||
double get percent => total == 0 ? 0 : processed / total;
|
||||
bool get isComplete => total > 0 && processed >= total;
|
||||
}
|
||||
|
||||
/// A downloaded/skipped tile rectangle for map overlay.
|
||||
class TileOverlay {
|
||||
final double north, south, east, west;
|
||||
final bool isSkipped;
|
||||
|
||||
const TileOverlay({
|
||||
required this.north,
|
||||
required this.south,
|
||||
required this.east,
|
||||
required this.west,
|
||||
this.isSkipped = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Drawing mode for polygon selection.
|
||||
enum DrawingMode { none, polygon, rectangle }
|
||||
|
||||
/// State management for offline tile downloading.
|
||||
class OfflineTilesProvider extends ChangeNotifier {
|
||||
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
|
||||
final TileSharingService _sharing = TileSharingService.instance;
|
||||
TileDownloadService? _downloadService;
|
||||
StreamSubscription<TileDownloadEvent>? _downloadSubscription;
|
||||
StreamSubscription<Set<TilePeer>>? _peersSubscription;
|
||||
bool _isDisposed = false;
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
DrawingMode _downloadSelectionMode = DrawingMode.none;
|
||||
final List<List<LatLng>> _polygons = [];
|
||||
List<LatLng> _currentVertices = [];
|
||||
LatLng? _rectangleFirstCorner;
|
||||
|
||||
// Download settings
|
||||
int _minZoom = 8;
|
||||
int _maxZoom = 14;
|
||||
MapLayer _selectedLayer = MapLayer.openStreetMap;
|
||||
|
||||
// Download progress
|
||||
bool _isDownloading = false;
|
||||
DownloadProgress _progress = const DownloadProgress();
|
||||
final List<TileOverlay> _tileOverlays = [];
|
||||
|
||||
// Cache info
|
||||
int _cacheSizeBytes = 0;
|
||||
|
||||
// Sharing state
|
||||
bool _isServerRunning = false;
|
||||
Set<TilePeer> _discoveredPeers = {};
|
||||
List<PeerCatalog> _peerCatalogs = [];
|
||||
bool _isFetchingCatalogs = false;
|
||||
bool _isSyncing = false;
|
||||
String _syncStatus = '';
|
||||
double _syncProgress = 0;
|
||||
|
||||
// Local style info
|
||||
List<StyleInfo> _localStyles = [];
|
||||
|
||||
// Coverage overlay — which cached style's tiles to show on the map
|
||||
StyleInfo? _coverageStyle;
|
||||
List<TileOverlay> _coverageOverlays = [];
|
||||
|
||||
// Getters
|
||||
DrawingMode get drawingMode => _drawingMode;
|
||||
DrawingMode get downloadSelectionMode => _downloadSelectionMode;
|
||||
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
|
||||
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
|
||||
LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
|
||||
int get minZoom => _minZoom;
|
||||
int get maxZoom => _maxZoom;
|
||||
MapLayer get selectedLayer => _selectedLayer;
|
||||
bool get isDownloading => _isDownloading;
|
||||
DownloadProgress get progress => _progress;
|
||||
List<TileOverlay> get tileOverlays => _tileOverlays;
|
||||
int get cacheSizeBytes => _cacheSizeBytes;
|
||||
bool get hasPolygons => _polygons.isNotEmpty;
|
||||
bool get isServerRunning => _isServerRunning;
|
||||
Set<TilePeer> get discoveredPeers => _discoveredPeers;
|
||||
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
|
||||
bool get isFetchingCatalogs => _isFetchingCatalogs;
|
||||
bool get isSyncing => _isSyncing;
|
||||
String get syncStatus => _syncStatus;
|
||||
double get syncProgress => _syncProgress;
|
||||
List<StyleInfo> get localStyles => _localStyles;
|
||||
StyleInfo? get coverageStyle => _coverageStyle;
|
||||
List<TileOverlay> get coverageOverlays => _coverageOverlays;
|
||||
|
||||
/// Estimated tile count for the current selection.
|
||||
int get estimatedTileCount {
|
||||
if (_polygons.isEmpty) return 0;
|
||||
return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom);
|
||||
}
|
||||
|
||||
// Drawing methods
|
||||
|
||||
void setDrawingMode(DrawingMode mode) {
|
||||
_drawingMode = mode;
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void startDownloadSelectionMode(DrawingMode mode) {
|
||||
_polygons.clear();
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_downloadSelectionMode = mode;
|
||||
_drawingMode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void addVertex(LatLng point) {
|
||||
if (_drawingMode == DrawingMode.polygon) {
|
||||
_currentVertices = [..._currentVertices, point];
|
||||
notifyListeners();
|
||||
} else if (_drawingMode == DrawingMode.rectangle) {
|
||||
if (_rectangleFirstCorner == null) {
|
||||
_rectangleFirstCorner = point;
|
||||
notifyListeners();
|
||||
} else {
|
||||
// Complete rectangle
|
||||
final corner1 = _rectangleFirstCorner!;
|
||||
final corner2 = point;
|
||||
final rect = [
|
||||
LatLng(corner1.latitude, corner1.longitude),
|
||||
LatLng(corner1.latitude, corner2.longitude),
|
||||
LatLng(corner2.latitude, corner2.longitude),
|
||||
LatLng(corner2.latitude, corner1.longitude),
|
||||
];
|
||||
_polygons.add(rect);
|
||||
_rectangleFirstCorner = null;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void finishPolygon() {
|
||||
if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) {
|
||||
_polygons.add(List.from(_currentVertices));
|
||||
_currentVertices = [];
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void removePolygon(int index) {
|
||||
if (index >= 0 && index < _polygons.length) {
|
||||
_polygons.removeAt(index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearPolygons() {
|
||||
_polygons.clear();
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setCurrentViewBounds({
|
||||
required double north,
|
||||
required double south,
|
||||
required double east,
|
||||
required double west,
|
||||
}) {
|
||||
_polygons
|
||||
..clear()
|
||||
..add([
|
||||
LatLng(north, west),
|
||||
LatLng(north, east),
|
||||
LatLng(south, east),
|
||||
LatLng(south, west),
|
||||
]);
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_downloadSelectionMode = DrawingMode.none;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void undoLastVertex() {
|
||||
if (_currentVertices.isNotEmpty) {
|
||||
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Download settings
|
||||
|
||||
void setMinZoom(int zoom) {
|
||||
_minZoom = zoom.clamp(0, 19);
|
||||
if (_maxZoom < _minZoom) _maxZoom = _minZoom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMaxZoom(int zoom) {
|
||||
_maxZoom = zoom.clamp(0, 19);
|
||||
if (_minZoom > _maxZoom) _minZoom = _maxZoom;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedLayer(MapLayer layer) {
|
||||
_selectedLayer = layer;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSelectedLayerIfDifferent(MapLayer layer) {
|
||||
if (_selectedLayer.type == layer.type &&
|
||||
_selectedLayer.urlTemplate == layer.urlTemplate) {
|
||||
return;
|
||||
}
|
||||
_selectedLayer = layer;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Download control
|
||||
|
||||
Future<void> startDownload() async {
|
||||
if (_isDownloading || _polygons.isEmpty) return;
|
||||
|
||||
_isDownloading = true;
|
||||
_progress = const DownloadProgress();
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
|
||||
_downloadService = TileDownloadService();
|
||||
final stream = _downloadService!.downloadTiles(
|
||||
polygons: _polygons,
|
||||
minZoom: _minZoom,
|
||||
maxZoom: _maxZoom,
|
||||
urlTemplate: _selectedLayer.urlTemplate,
|
||||
displayName: _selectedLayer.name,
|
||||
headers: _selectedLayer.headers,
|
||||
);
|
||||
|
||||
await for (final event in stream) {
|
||||
switch (event) {
|
||||
case TileDownloadStarted(:final totalTiles):
|
||||
_progress = DownloadProgress(total: totalTiles);
|
||||
notifyListeners();
|
||||
|
||||
case TileDownloaded(:final north, :final south, :final east, :final west):
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded + 1,
|
||||
skipped: _progress.skipped,
|
||||
failed: _progress.failed,
|
||||
total: _progress.total,
|
||||
);
|
||||
_addOverlay(TileOverlay(
|
||||
north: north, south: south, east: east, west: west,
|
||||
));
|
||||
notifyListeners();
|
||||
|
||||
case TileSkipped():
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded,
|
||||
skipped: _progress.skipped + 1,
|
||||
failed: _progress.failed,
|
||||
total: _progress.total,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
case TileBatchSkipped(:final count):
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded,
|
||||
skipped: _progress.skipped + count,
|
||||
failed: _progress.failed,
|
||||
total: _progress.total,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
case TileFailed():
|
||||
_progress = DownloadProgress(
|
||||
downloaded: _progress.downloaded,
|
||||
skipped: _progress.skipped,
|
||||
failed: _progress.failed + 1,
|
||||
total: _progress.total,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
case TileDownloadComplete():
|
||||
_isDownloading = false;
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
|
||||
case TileDownloadCancelled():
|
||||
_isDownloading = false;
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
_isDownloading = false;
|
||||
_downloadService?.dispose();
|
||||
_downloadService = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancelDownload() {
|
||||
_downloadService?.cancel();
|
||||
}
|
||||
|
||||
void clearOverlays() {
|
||||
_tileOverlays.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _addOverlay(TileOverlay overlay) {
|
||||
_tileOverlays.add(overlay);
|
||||
// Limit overlays to prevent OOM
|
||||
if (_tileOverlays.length > 500) {
|
||||
_tileOverlays.removeRange(0, _tileOverlays.length - 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache management
|
||||
|
||||
Future<void> refreshCacheSize() async {
|
||||
_cacheSizeBytes = await _cache.getCacheSize();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> deleteStyle(StyleInfo style) async {
|
||||
if (_coverageStyle?.hash == style.hash) hideCoverage();
|
||||
await _cache.deleteStyle(style.hash);
|
||||
await refreshCacheSize();
|
||||
await refreshLocalStyles();
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
hideCoverage();
|
||||
await _cache.clearCache();
|
||||
_cacheSizeBytes = 0;
|
||||
_localStyles = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Coverage overlay — show cached tile bounds on the map
|
||||
|
||||
/// Show the coverage of a cached style on the map.
|
||||
/// Loads tile coordinates from the manifest and converts to bounds.
|
||||
Future<void> showCoverage(StyleInfo style) async {
|
||||
if (_coverageStyle?.hash == style.hash) {
|
||||
// Toggle off if same style tapped again
|
||||
hideCoverage();
|
||||
return;
|
||||
}
|
||||
|
||||
_coverageStyle = style;
|
||||
_coverageOverlays = [];
|
||||
notifyListeners();
|
||||
|
||||
final tiles = await _cache.listTilesForStyle(style.hash);
|
||||
|
||||
// Convert cached tile coordinates to bound overlays
|
||||
final overlays = <TileOverlay>[];
|
||||
for (final tile in tiles) {
|
||||
final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
|
||||
overlays.add(TileOverlay(
|
||||
north: bounds.north,
|
||||
south: bounds.south,
|
||||
east: bounds.east,
|
||||
west: bounds.west,
|
||||
isSkipped: true, // green color
|
||||
));
|
||||
}
|
||||
|
||||
_coverageOverlays = overlays;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void hideCoverage() {
|
||||
_coverageStyle = null;
|
||||
_coverageOverlays = [];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Sharing controls
|
||||
|
||||
Future<void> toggleServer() async {
|
||||
if (_isServerRunning) {
|
||||
await _sharing.stopServer();
|
||||
_isServerRunning = false;
|
||||
} else {
|
||||
await _sharing.startServer();
|
||||
_isServerRunning = _sharing.isRunning;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> startPeerDiscovery() async {
|
||||
_peersSubscription?.cancel();
|
||||
_peersSubscription = _sharing.peersStream.listen((peers) {
|
||||
_discoveredPeers = peers;
|
||||
notifyListeners();
|
||||
});
|
||||
await _sharing.startDiscovery();
|
||||
}
|
||||
|
||||
Future<void> stopPeerDiscovery() async {
|
||||
_peersSubscription?.cancel();
|
||||
_peersSubscription = null;
|
||||
await _sharing.stopPeerDiscovery();
|
||||
_discoveredPeers = {};
|
||||
if (!_isDisposed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void addManualPeer(String ipAddress) {
|
||||
_sharing.addManualPeer(ipAddress);
|
||||
_discoveredPeers = _sharing.discoveredPeers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removePeer(TilePeer peer) {
|
||||
_sharing.removePeer(peer);
|
||||
_discoveredPeers = _sharing.discoveredPeers;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Peer catalog & P2P sync
|
||||
|
||||
/// Refresh local style info.
|
||||
Future<void> refreshLocalStyles() async {
|
||||
_localStyles = await _cache.listStylesDetailed();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Fetch catalogs from all discovered peers to see what they have.
|
||||
Future<void> refreshPeerCatalogs() async {
|
||||
_isFetchingCatalogs = true;
|
||||
notifyListeners();
|
||||
|
||||
_peerCatalogs = await _sharing.fetchAllPeerCatalogs();
|
||||
_isFetchingCatalogs = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Sync a style from one or more peers that have it.
|
||||
/// Finds all peers offering [styleHash] and pulls missing tiles.
|
||||
Future<void> syncStyleFromPeers(StyleInfo style) async {
|
||||
if (_isSyncing) return;
|
||||
|
||||
// Find all peers that have this style
|
||||
final peersWithStyle = <TilePeer>[];
|
||||
for (final catalog in _peerCatalogs) {
|
||||
if (catalog.styles.any((s) => s.hash == style.hash)) {
|
||||
peersWithStyle.add(catalog.peer);
|
||||
}
|
||||
}
|
||||
if (peersWithStyle.isEmpty) return;
|
||||
|
||||
_isSyncing = true;
|
||||
_syncStatus = 'Starting sync of ${style.displayName}...';
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
|
||||
final stream = _sharing.syncStyleFromPeers(
|
||||
peers: peersWithStyle,
|
||||
styleHash: style.hash,
|
||||
styleMeta: style,
|
||||
);
|
||||
|
||||
await for (final event in stream) {
|
||||
switch (event) {
|
||||
case PeerSyncStarted(:final totalTiles):
|
||||
_syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles';
|
||||
_syncProgress = 0;
|
||||
notifyListeners();
|
||||
|
||||
case PeerSyncTileDownloaded(:final downloaded, :final total):
|
||||
_syncStatus =
|
||||
'Syncing ${style.displayName}: $downloaded/$total tiles';
|
||||
_syncProgress = total > 0 ? downloaded / total : 0;
|
||||
notifyListeners();
|
||||
|
||||
case PeerSyncTileSkipped(:final skipped, :final total):
|
||||
_syncProgress = total > 0 ? skipped / total : 0;
|
||||
notifyListeners();
|
||||
|
||||
case PeerSyncComplete(:final downloaded, :final skipped, :final failed):
|
||||
_syncStatus =
|
||||
'Done! $downloaded new, $skipped cached, $failed failed';
|
||||
_isSyncing = false;
|
||||
notifyListeners();
|
||||
await refreshCacheSize();
|
||||
await refreshLocalStyles();
|
||||
|
||||
case PeerSyncCancelled():
|
||||
_syncStatus = 'Sync cancelled';
|
||||
_isSyncing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void cancelSync() {
|
||||
_sharing.cancelSync();
|
||||
}
|
||||
|
||||
// Presets — load a previously downloaded region for quick re-download
|
||||
|
||||
/// Load a saved download region as the current selection.
|
||||
/// Restores polygons, zoom range, and map layer.
|
||||
void loadPreset(StyleInfo style) {
|
||||
if (style.region == null) return;
|
||||
|
||||
final region = style.region!;
|
||||
|
||||
// Restore polygons
|
||||
_polygons.clear();
|
||||
for (final polyData in region.polygons) {
|
||||
final poly = polyData.map((v) => LatLng(v[0], v[1])).toList();
|
||||
if (poly.length >= 3) _polygons.add(poly);
|
||||
}
|
||||
|
||||
// Restore zoom range
|
||||
_minZoom = region.minZoom;
|
||||
_maxZoom = region.maxZoom;
|
||||
|
||||
// Try to find the matching map layer
|
||||
if (style.urlTemplate.isNotEmpty) {
|
||||
final matchingLayer = MapLayer.allLayers.where(
|
||||
(l) => l.urlTemplate == style.urlTemplate,
|
||||
);
|
||||
if (matchingLayer.isNotEmpty) {
|
||||
_selectedLayer = matchingLayer.first;
|
||||
}
|
||||
}
|
||||
|
||||
_currentVertices = [];
|
||||
_rectangleFirstCorner = null;
|
||||
_drawingMode = DrawingMode.none;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_downloadSubscription?.cancel();
|
||||
_downloadService?.dispose();
|
||||
_peersSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
1058
lib/providers/sensors_provider.dart
Normal file
1058
lib/providers/sensors_provider.dart
Normal file
File diff suppressed because it is too large
Load Diff
470
lib/providers/voice_provider.dart
Normal file
470
lib/providers/voice_provider.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/profiles_feature_service.dart';
|
||||
import 'helpers/raw_session_retransmit.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../services/voice_player_service.dart';
|
||||
|
||||
/// Reassembly state for one voice session.
|
||||
class VoiceSession {
|
||||
final String sessionId;
|
||||
final VoicePacketMode mode;
|
||||
final int total;
|
||||
final List<VoicePacket?> packets; // indexed by packet.index
|
||||
DateTime? firstPacketAt;
|
||||
DateTime? lastPacketAt;
|
||||
|
||||
VoiceSession({
|
||||
required this.sessionId,
|
||||
required this.mode,
|
||||
required this.total,
|
||||
}) : packets = List.filled(total, null);
|
||||
|
||||
int get receivedCount => packets.where((p) => p != null).length;
|
||||
bool get isComplete => receivedCount == total;
|
||||
|
||||
Duration? estimateRemaining() {
|
||||
if (isComplete) return Duration.zero;
|
||||
if (firstPacketAt == null || lastPacketAt == null) return null;
|
||||
if (receivedCount < 2) return null;
|
||||
|
||||
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
|
||||
if (elapsedMs <= 0) return null;
|
||||
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
|
||||
final remaining = total - receivedCount;
|
||||
if (remaining <= 0) return Duration.zero;
|
||||
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
|
||||
}
|
||||
|
||||
/// Total estimated audio duration in seconds (sum of all received packets).
|
||||
double get estimatedDurationSeconds {
|
||||
var ms = 0;
|
||||
for (final p in packets) {
|
||||
if (p != null) ms += p.durationMs;
|
||||
}
|
||||
return ms / 1000.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages incoming voice packet sessions and coordinates playback.
|
||||
class VoiceProvider with ChangeNotifier {
|
||||
static const String _voiceSessionsStorageKey = 'stored_voice_sessions_v1';
|
||||
static const int maxDirectPayloadHops = 3;
|
||||
final VoiceCodecService _codec;
|
||||
final VoicePlayerService _player;
|
||||
late final StreamSubscription<void> _playerEventsSub;
|
||||
|
||||
/// Active sessions keyed by sessionId.
|
||||
final Map<String, VoiceSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Currently playing session ID, or null.
|
||||
String? _playingSessionId;
|
||||
|
||||
/// Hook for sending a raw voice payload to a destination contact path.
|
||||
Future<void> Function({
|
||||
required Uint8List contactPath,
|
||||
required int contactPathLen,
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
|
||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||
|
||||
VoiceProvider({
|
||||
required VoiceCodecService codec,
|
||||
required VoicePlayerService player,
|
||||
}) : _codec = codec,
|
||||
_player = player {
|
||||
_playerEventsSub = _player.events.listen((_) {
|
||||
if (_playingSessionId != null &&
|
||||
!_player.isPlaying &&
|
||||
_player.duration.inMilliseconds > 0 &&
|
||||
_player.position >= _player.duration) {
|
||||
_playingSessionId = null;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
_restorePersistedVoiceData();
|
||||
}
|
||||
|
||||
String _storageKey() =>
|
||||
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
|
||||
|
||||
// ── Session accessors ────────────────────────────────────────────────────
|
||||
|
||||
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
||||
bool isComplete(String sessionId) =>
|
||||
_sessions[sessionId]?.isComplete ?? false;
|
||||
bool isPlaying(String sessionId) => _playingSessionId == sessionId;
|
||||
Duration get playbackPosition => _player.position;
|
||||
Duration get playbackDuration => _player.duration;
|
||||
|
||||
double playbackProgress(String sessionId) {
|
||||
if (_playingSessionId != sessionId) return 0.0;
|
||||
final totalMs = _player.duration.inMilliseconds;
|
||||
if (totalMs <= 0) return 0.0;
|
||||
final posMs = _player.position.inMilliseconds.clamp(0, totalMs);
|
||||
return posMs / totalMs;
|
||||
}
|
||||
|
||||
bool hasOutgoingSession(String sessionId) =>
|
||||
_outgoingSessions.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingPacketIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final missing = <int>[];
|
||||
for (var i = 0; i < session.total; i++) {
|
||||
if (session.packets[i] == null) missing.add(i);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availablePacketIndices(String sessionId) {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.packets.map((packet) => packet.index).toList()..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.packets.length; i++) {
|
||||
if (session.packets[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Packet reception ─────────────────────────────────────────────────────
|
||||
|
||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||
/// Returns true if the session just became complete.
|
||||
bool addPacket(VoicePacket packet) {
|
||||
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(packet.sessionId, () {
|
||||
if (packet.total < 1) {
|
||||
throw StateError(
|
||||
'Voice envelope missing for compact packet ${packet.sessionId}',
|
||||
);
|
||||
}
|
||||
return VoiceSession(
|
||||
sessionId: packet.sessionId,
|
||||
mode: packet.mode,
|
||||
total: packet.total,
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[packet.sessionId]!;
|
||||
if (packet.index < session.total) {
|
||||
final wasMissing = session.packets[packet.index] == null;
|
||||
session.packets[packet.index] = packet;
|
||||
if (wasMissing) {
|
||||
final now = DateTime.now();
|
||||
session.firstPacketAt ??= now;
|
||||
session.lastPacketAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
final justComplete = session.isComplete;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
if (_playingSessionId == sessionId) {
|
||||
unawaited(_player.stop());
|
||||
_playingSessionId = null;
|
||||
}
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void registerEnvelope(VoiceEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.total != envelope.total || existing.mode != envelope.mode;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
merged.firstPacketAt = existing.firstPacketAt;
|
||||
merged.lastPacketAt = existing.lastPacketAt;
|
||||
for (final packet in existing.packets) {
|
||||
if (packet == null) continue;
|
||||
if (packet.index < merged.total) {
|
||||
merged.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cache encoded packets for deferred voice serving.
|
||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||
if (packets.isEmpty) return;
|
||||
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||
sessionId: sessionId,
|
||||
packets: List<VoicePacket>.from(packets),
|
||||
);
|
||||
_persistVoiceData();
|
||||
}
|
||||
|
||||
/// Stream a cached voice session to a requester over raw direct packets.
|
||||
Future<bool> serveSessionTo({
|
||||
required String sessionId,
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
final packets = outgoing != null
|
||||
? List<VoicePacket>.from(outgoing.packets)
|
||||
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
|
||||
const <VoicePacket>[];
|
||||
if (packets.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return serveCachedSessionFragments<VoicePacket>(
|
||||
providerLabel: 'VoiceProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: packets,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (packet) => packet.index,
|
||||
encodeBinary: (packet) => packet.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playback ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode and play the voice session with [sessionId].
|
||||
/// Plays whatever packets are available (handles partial reception gracefully).
|
||||
Future<void> play(String sessionId) async {
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) {
|
||||
debugPrint(
|
||||
'❌ [VoiceProvider] play($sessionId) — session not found, known: ${_sessions.keys.toList()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'🎙️ [VoiceProvider] play($sessionId): ${session.receivedCount}/${session.total} packets, mode=${session.mode.label}',
|
||||
);
|
||||
|
||||
try {
|
||||
final decodedPcm = await _codec.decodePackets(
|
||||
session.packets,
|
||||
session.mode,
|
||||
);
|
||||
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||
_playingSessionId = sessionId;
|
||||
notifyListeners();
|
||||
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||
if (_playingSessionId == sessionId) {
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _player.stop();
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
Future<void> clearStoredVoiceData() async {
|
||||
await _resetInMemoryState();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_storageKey());
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reloadProfileScopedState() async {
|
||||
await _resetInMemoryState();
|
||||
await _restorePersistedVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persistVoiceData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final payload = <String, dynamic>{
|
||||
'incoming': _sessions.values
|
||||
.map(
|
||||
(session) => {
|
||||
'sessionId': session.sessionId,
|
||||
'modeId': session.mode.id,
|
||||
'total': session.total,
|
||||
'packets': session.packets.map((p) => p?.encodeText()).toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'outgoing': _outgoingSessions.values
|
||||
.map(
|
||||
(session) => {
|
||||
'sessionId': session.sessionId,
|
||||
'packets': session.packets.map((p) => p.encodeText()).toList(),
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
await prefs.setString(_storageKey(), jsonEncode(payload));
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restorePersistedVoiceData() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_storageKey());
|
||||
if (raw == null || raw.isEmpty) return;
|
||||
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
|
||||
final incoming = parsed['incoming'] as List<dynamic>? ?? const [];
|
||||
for (final item in incoming) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
final modeId = map['modeId'] as int?;
|
||||
final total = map['total'] as int?;
|
||||
if (sessionId == null ||
|
||||
modeId == null ||
|
||||
total == null ||
|
||||
total <= 0) {
|
||||
continue;
|
||||
}
|
||||
final mode = VoicePacketMode.fromId(modeId);
|
||||
final session = VoiceSession(
|
||||
sessionId: sessionId,
|
||||
mode: mode,
|
||||
total: total,
|
||||
);
|
||||
final packets = map['packets'] as List<dynamic>? ?? const [];
|
||||
for (var i = 0; i < packets.length && i < session.total; i++) {
|
||||
final encoded = packets[i] as String?;
|
||||
if (encoded == null || encoded.isEmpty) continue;
|
||||
final packet = VoicePacket.tryParseText(encoded);
|
||||
if (packet != null && packet.index < session.total) {
|
||||
session.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[sessionId] = session;
|
||||
}
|
||||
|
||||
final outgoing = parsed['outgoing'] as List<dynamic>? ?? const [];
|
||||
for (final item in outgoing) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
final sessionId = map['sessionId'] as String?;
|
||||
if (sessionId == null || sessionId.isEmpty) continue;
|
||||
final packetsRaw = map['packets'] as List<dynamic>? ?? const [];
|
||||
final packets = <VoicePacket>[];
|
||||
for (final encoded in packetsRaw) {
|
||||
final packet = VoicePacket.tryParseText((encoded ?? '') as String);
|
||||
if (packet != null) packets.add(packet);
|
||||
}
|
||||
if (packets.isNotEmpty) {
|
||||
_outgoingSessions[sessionId] = _OutgoingVoiceSession(
|
||||
sessionId: sessionId,
|
||||
packets: packets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
debugPrint(
|
||||
'🎙️ [VoiceProvider] Restored ${_sessions.length} incoming and ${_outgoingSessions.length} outgoing voice sessions',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [VoiceProvider] Failed to restore voice data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetInMemoryState() async {
|
||||
if (_playingSessionId != null || _player.isPlaying) {
|
||||
await _player.stop();
|
||||
}
|
||||
_sessions.clear();
|
||||
_outgoingSessions.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
_playingSessionId = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerEventsSub.cancel();
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingVoiceSession {
|
||||
final String sessionId;
|
||||
final List<VoicePacket> packets;
|
||||
|
||||
const _OutgoingVoiceSession({required this.sessionId, required this.packets});
|
||||
}
|
||||
Reference in New Issue
Block a user