mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add GPX import/export functionality and enhance trail management
- Implemented GPX export and import features in GpxService for location trails. - Added UI controls for exporting and importing trails in the TrailControls widget. - Introduced localization strings for GPX operations in Italian and Slovenian. - Enhanced MapProvider to manage imported trails and toggle visibility for contact trails. - Updated trail color management with TrailColorService for consistent color assignment based on contact roles. - Improved UI for displaying contact trails with toggle options for individual and all contact trails. - Refactored detailed compass dialog to remove unnecessary path toggle button for contacts.
This commit is contained in:
339
lib/services/gpx_service.dart
Normal file
339
lib/services/gpx_service.dart
Normal file
@@ -0,0 +1,339 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../models/location_trail.dart';
|
||||
|
||||
/// Service for importing and exporting location trails in GPX format
|
||||
class GpxService {
|
||||
/// Export a LocationTrail to GPX 1.1 format
|
||||
/// Returns the GPX content as a string
|
||||
static String exportToGpx(LocationTrail trail, {String? customName}) {
|
||||
final builder = XmlBuilder();
|
||||
|
||||
builder.processing('xml', 'version="1.0" encoding="UTF-8"');
|
||||
builder.element('gpx', nest: () {
|
||||
// GPX attributes
|
||||
builder.attribute('version', '1.1');
|
||||
builder.attribute('creator', 'MeshCore SAR');
|
||||
builder.attribute(
|
||||
'xmlns',
|
||||
'http://www.topografix.com/GPX/1/1',
|
||||
);
|
||||
builder.attribute(
|
||||
'xmlns:xsi',
|
||||
'http://www.w3.org/2001/XMLSchema-instance',
|
||||
);
|
||||
builder.attribute(
|
||||
'xsi:schemaLocation',
|
||||
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd',
|
||||
);
|
||||
|
||||
// Metadata section
|
||||
builder.element('metadata', nest: () {
|
||||
final name = customName ??
|
||||
'MeshCore Trail - ${_formatDateTime(trail.startTime)}';
|
||||
builder.element('name', nest: () => builder.text(name));
|
||||
builder.element(
|
||||
'time',
|
||||
nest: () => builder.text(trail.startTime.toIso8601String()),
|
||||
);
|
||||
|
||||
// Add trail statistics in description
|
||||
final distance = trail.totalDistance;
|
||||
final duration = trail.duration;
|
||||
final description =
|
||||
'Distance: ${_formatDistance(distance)}, '
|
||||
'Duration: ${_formatDuration(duration)}, '
|
||||
'Points: ${trail.points.length}';
|
||||
builder.element('desc', nest: () => builder.text(description));
|
||||
});
|
||||
|
||||
// Track section
|
||||
builder.element('trk', nest: () {
|
||||
final trackName = customName ?? 'MeshCore Trail';
|
||||
builder.element('name', nest: () => builder.text(trackName));
|
||||
|
||||
// Track segment with all points
|
||||
builder.element('trkseg', nest: () {
|
||||
for (final point in trail.points) {
|
||||
builder.element('trkpt', nest: () {
|
||||
builder.attribute('lat', point.position.latitude.toString());
|
||||
builder.attribute('lon', point.position.longitude.toString());
|
||||
|
||||
// Timestamp (required for proper GPX)
|
||||
builder.element(
|
||||
'time',
|
||||
nest: () => builder.text(point.timestamp.toIso8601String()),
|
||||
);
|
||||
|
||||
// Elevation (optional, set to 0 if not available)
|
||||
builder.element('ele', nest: () => builder.text('0'));
|
||||
|
||||
// Extensions for additional data (accuracy, speed)
|
||||
if (point.accuracy != null || point.speed != null) {
|
||||
builder.element('extensions', nest: () {
|
||||
if (point.accuracy != null) {
|
||||
builder.element(
|
||||
'accuracy',
|
||||
nest: () => builder.text(point.accuracy.toString()),
|
||||
);
|
||||
}
|
||||
if (point.speed != null) {
|
||||
builder.element(
|
||||
'speed',
|
||||
nest: () => builder.text(point.speed.toString()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
final document = builder.buildDocument();
|
||||
return document.toXmlString(pretty: true, indent: ' ');
|
||||
}
|
||||
|
||||
/// Parse GPX content and return a LocationTrail
|
||||
/// Throws FormatException if GPX is invalid
|
||||
static LocationTrail importFromGpx(String gpxContent) {
|
||||
try {
|
||||
final document = XmlDocument.parse(gpxContent);
|
||||
final gpxElement = document.findElements('gpx').firstOrNull;
|
||||
|
||||
if (gpxElement == null) {
|
||||
throw const FormatException('Invalid GPX file: Missing <gpx> element');
|
||||
}
|
||||
|
||||
// Extract track name from metadata or track element (currently unused, kept for future use)
|
||||
// String? trackName;
|
||||
// final metadataName =
|
||||
// gpxElement.findElements('metadata').firstOrNull?.findElements('name').firstOrNull?.innerText;
|
||||
// final trackNameElement = gpxElement
|
||||
// .findElements('trk')
|
||||
// .firstOrNull
|
||||
// ?.findElements('name')
|
||||
// .firstOrNull
|
||||
// ?.innerText;
|
||||
// trackName = metadataName ?? trackNameElement ?? 'Imported Trail';
|
||||
|
||||
// Extract track points
|
||||
final trackPoints = <TrailPoint>[];
|
||||
final tracks = gpxElement.findElements('trk');
|
||||
|
||||
if (tracks.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Invalid GPX file: No <trk> elements found',
|
||||
);
|
||||
}
|
||||
|
||||
// Process first track only
|
||||
final track = tracks.first;
|
||||
final segments = track.findElements('trkseg');
|
||||
|
||||
for (final segment in segments) {
|
||||
final trkpts = segment.findElements('trkpt');
|
||||
|
||||
for (final trkpt in trkpts) {
|
||||
try {
|
||||
// Extract latitude and longitude (required)
|
||||
final latStr = trkpt.getAttribute('lat');
|
||||
final lonStr = trkpt.getAttribute('lon');
|
||||
|
||||
if (latStr == null || lonStr == null) {
|
||||
debugPrint('⚠️ Skipping track point: Missing lat/lon attributes');
|
||||
continue;
|
||||
}
|
||||
|
||||
final lat = double.parse(latStr);
|
||||
final lon = double.parse(lonStr);
|
||||
|
||||
// Extract timestamp (optional)
|
||||
final timeStr =
|
||||
trkpt.findElements('time').firstOrNull?.innerText;
|
||||
final timestamp = timeStr != null
|
||||
? DateTime.parse(timeStr)
|
||||
: DateTime.now();
|
||||
|
||||
// Extract elevation (optional, currently unused but parsed for future use)
|
||||
// final eleStr = trkpt.findElements('ele').firstOrNull?.innerText;
|
||||
// final elevation = eleStr != null ? double.tryParse(eleStr) : null;
|
||||
|
||||
// Extract extensions (accuracy, speed)
|
||||
double? accuracy;
|
||||
double? speed;
|
||||
final extensions =
|
||||
trkpt.findElements('extensions').firstOrNull;
|
||||
if (extensions != null) {
|
||||
final accuracyStr =
|
||||
extensions.findElements('accuracy').firstOrNull?.innerText;
|
||||
final speedStr =
|
||||
extensions.findElements('speed').firstOrNull?.innerText;
|
||||
accuracy = accuracyStr != null ? double.tryParse(accuracyStr) : null;
|
||||
speed = speedStr != null ? double.tryParse(speedStr) : null;
|
||||
}
|
||||
|
||||
// Create trail point
|
||||
trackPoints.add(
|
||||
TrailPoint(
|
||||
position: LatLng(lat, lon),
|
||||
timestamp: timestamp,
|
||||
accuracy: accuracy,
|
||||
speed: speed,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Error parsing track point: $e');
|
||||
// Continue with next point
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (trackPoints.isEmpty) {
|
||||
throw const FormatException(
|
||||
'Invalid GPX file: No valid track points found',
|
||||
);
|
||||
}
|
||||
|
||||
// Create LocationTrail from parsed points
|
||||
final startTime = trackPoints.first.timestamp;
|
||||
final endTime = trackPoints.last.timestamp;
|
||||
|
||||
return LocationTrail(
|
||||
id: 'imported_${DateTime.now().millisecondsSinceEpoch}',
|
||||
points: trackPoints,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
isActive: false,
|
||||
);
|
||||
} on XmlException catch (e) {
|
||||
throw FormatException('Invalid GPX XML: ${e.message}');
|
||||
} catch (e) {
|
||||
throw FormatException('Failed to parse GPX file: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Export trail to file and trigger system share sheet
|
||||
/// Returns true if successful
|
||||
static Future<bool> exportTrailToFile(
|
||||
LocationTrail trail, {
|
||||
String? customName,
|
||||
}) async {
|
||||
try {
|
||||
// Generate GPX content
|
||||
debugPrint('📤 Generating GPX content...');
|
||||
final gpxContent = exportToGpx(trail, customName: customName);
|
||||
|
||||
// Create filename with timestamp
|
||||
final timestamp = DateTime.now();
|
||||
final filename =
|
||||
'meshcore_trail_${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')}_${timestamp.hour.toString().padLeft(2, '0')}${timestamp.minute.toString().padLeft(2, '0')}${timestamp.second.toString().padLeft(2, '0')}.gpx';
|
||||
|
||||
// Save to temporary directory
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/$filename');
|
||||
await file.writeAsString(gpxContent);
|
||||
|
||||
debugPrint('📤 GPX file saved: ${file.path}');
|
||||
debugPrint('📤 File size: ${file.lengthSync()} bytes');
|
||||
|
||||
// Share the file using system share sheet
|
||||
final result = await Share.shareXFiles(
|
||||
[XFile(file.path, mimeType: 'application/gpx+xml')],
|
||||
subject: 'MeshCore Trail Export',
|
||||
text: 'MeshCore SAR location trail (${trail.points.length} points)',
|
||||
);
|
||||
|
||||
debugPrint('📤 Share result: ${result.status}');
|
||||
return result.status == ShareResultStatus.success ||
|
||||
result.status == ShareResultStatus.unavailable; // unavailable = user dismissed, still OK
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to export trail: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import trail from GPX file using file picker
|
||||
/// Returns LocationTrail if successful, null if cancelled or failed
|
||||
static Future<LocationTrail?> importTrailFromFile() async {
|
||||
try {
|
||||
// Open file picker for GPX files
|
||||
debugPrint('📥 Opening file picker for GPX import...');
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['gpx'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
debugPrint('📥 Import cancelled by user');
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = result.files.first;
|
||||
debugPrint('📥 Selected file: ${file.name}');
|
||||
debugPrint('📥 File size: ${file.size} bytes');
|
||||
|
||||
// Read file content
|
||||
String gpxContent;
|
||||
if (file.path != null) {
|
||||
// File has path (mobile)
|
||||
gpxContent = await File(file.path!).readAsString();
|
||||
} else if (file.bytes != null) {
|
||||
// File has bytes (web)
|
||||
gpxContent = String.fromCharCodes(file.bytes!);
|
||||
} else {
|
||||
throw Exception('Unable to read file content');
|
||||
}
|
||||
|
||||
// Parse GPX content
|
||||
debugPrint('📥 Parsing GPX content...');
|
||||
final trail = importFromGpx(gpxContent);
|
||||
debugPrint(
|
||||
'✅ Successfully imported trail: ${trail.points.length} points',
|
||||
);
|
||||
|
||||
return trail;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Failed to import trail: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format distance for display
|
||||
static String _formatDistance(double meters) {
|
||||
if (meters < 1000) {
|
||||
return '${meters.toStringAsFixed(0)} m';
|
||||
} else {
|
||||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format duration for display
|
||||
static String _formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m ${seconds}s';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format DateTime for filename
|
||||
static String _formatDateTime(DateTime dt) {
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
236
lib/services/trail_color_service.dart
Normal file
236
lib/services/trail_color_service.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
/// Service for assigning consistent colors to contact trails
|
||||
/// Uses emoji-based semantic mapping with deterministic hash fallback
|
||||
class TrailColorService {
|
||||
// 64-color palette optimized for visibility on maps
|
||||
// Organized by hue families for better distribution
|
||||
// Avoids pure blue (#2196F3) which is reserved for user trail
|
||||
static final List<Color> _colorPalette = [
|
||||
// Reds (8)
|
||||
const Color(0xFFE53935),
|
||||
const Color(0xFFD32F2F),
|
||||
const Color(0xFFC62828),
|
||||
const Color(0xFFB71C1C),
|
||||
const Color(0xFFFF5252),
|
||||
const Color(0xFFFF1744),
|
||||
const Color(0xFFD50000),
|
||||
const Color(0xFFC51162),
|
||||
|
||||
// Pinks (4)
|
||||
const Color(0xFFEC407A),
|
||||
const Color(0xFFE91E63),
|
||||
const Color(0xFFC2185B),
|
||||
const Color(0xFFAD1457),
|
||||
|
||||
// Purples (8)
|
||||
const Color(0xFF9C27B0),
|
||||
const Color(0xFF8E24AA),
|
||||
const Color(0xFF7B1FA2),
|
||||
const Color(0xFF6A1B9A),
|
||||
const Color(0xFFAB47BC),
|
||||
const Color(0xFF9C27B0),
|
||||
const Color(0xFF8E24AA),
|
||||
const Color(0xFF7B1FA2),
|
||||
|
||||
// Deep Purples (4)
|
||||
const Color(0xFF673AB7),
|
||||
const Color(0xFF5E35B1),
|
||||
const Color(0xFF512DA8),
|
||||
const Color(0xFF4527A0),
|
||||
|
||||
// Indigos (4)
|
||||
const Color(0xFF3F51B5),
|
||||
const Color(0xFF3949AB),
|
||||
const Color(0xFF303F9F),
|
||||
const Color(0xFF283593),
|
||||
|
||||
// Blues (4) - Dark blues only, avoid user trail blue
|
||||
const Color(0xFF1E88E5),
|
||||
const Color(0xFF1976D2),
|
||||
const Color(0xFF1565C0),
|
||||
const Color(0xFF0D47A1),
|
||||
|
||||
// Cyans (4)
|
||||
const Color(0xFF00ACC1),
|
||||
const Color(0xFF0097A7),
|
||||
const Color(0xFF00838F),
|
||||
const Color(0xFF006064),
|
||||
|
||||
// Teals (4)
|
||||
const Color(0xFF00897B),
|
||||
const Color(0xFF00796B),
|
||||
const Color(0xFF00695C),
|
||||
const Color(0xFF004D40),
|
||||
|
||||
// Greens (8)
|
||||
const Color(0xFF43A047),
|
||||
const Color(0xFF388E3C),
|
||||
const Color(0xFF2E7D32),
|
||||
const Color(0xFF1B5E20),
|
||||
const Color(0xFF66BB6A),
|
||||
const Color(0xFF4CAF50),
|
||||
const Color(0xFF388E3C),
|
||||
const Color(0xFF2E7D32),
|
||||
|
||||
// Limes (4)
|
||||
const Color(0xFF9E9D24),
|
||||
const Color(0xFF827717),
|
||||
const Color(0xFFC0CA33),
|
||||
const Color(0xFFAFB42B),
|
||||
|
||||
// Ambers (4)
|
||||
const Color(0xFFFFA726),
|
||||
const Color(0xFFFF9800),
|
||||
const Color(0xFFFB8C00),
|
||||
const Color(0xFFF57C00),
|
||||
|
||||
// Oranges (4)
|
||||
const Color(0xFFFF7043),
|
||||
const Color(0xFFFF5722),
|
||||
const Color(0xFFF4511E),
|
||||
const Color(0xFFE64A19),
|
||||
|
||||
// Browns (4)
|
||||
const Color(0xFF6D4C41),
|
||||
const Color(0xFF5D4037),
|
||||
const Color(0xFF4E342E),
|
||||
const Color(0xFF3E2723),
|
||||
];
|
||||
|
||||
// Emoji to color mapping for SAR roles
|
||||
// Uses semantic colors that match emergency service conventions
|
||||
static final Map<String, Color> _emojiColorMap = {
|
||||
// Emergency Services - Firefighters
|
||||
'🚒': Color(0xFFD32F2F), // Fire engine → Red
|
||||
'🧑🚒': Color(0xFFD32F2F), // Firefighter → Red
|
||||
'👨🚒': Color(0xFFD32F2F), // Firefighter → Red
|
||||
'👩🚒': Color(0xFFD32F2F), // Firefighter → Red
|
||||
'🔥': Color(0xFFFF5722), // Fire → Orange-Red
|
||||
|
||||
// Emergency Services - Medical
|
||||
'🚑': Color(0xFF43A047), // Ambulance → Green (medical cross)
|
||||
'👨⚕️': Color(0xFF43A047), // Health worker → Green
|
||||
'👩⚕️': Color(0xFF43A047), // Health worker → Green
|
||||
'🧑⚕️': Color(0xFF43A047), // Health worker → Green
|
||||
'⚕️': Color(0xFF43A047), // Medical symbol → Green
|
||||
|
||||
// Emergency Services - Police
|
||||
'👮': Color(0xFF1976D2), // Police → Blue
|
||||
'👮♂️': Color(0xFF1976D2), // Police → Blue
|
||||
'👮♀️': Color(0xFF1976D2), // Police → Blue
|
||||
'🚔': Color(0xFF1976D2), // Police car → Blue
|
||||
|
||||
// Emergency Services - Aviation
|
||||
'🧑✈️': Color(0xFF1565C0), // Pilot → Dark Blue
|
||||
'👨✈️': Color(0xFF1565C0), // Pilot → Dark Blue
|
||||
'👩✈️': Color(0xFF1565C0), // Pilot → Dark Blue
|
||||
'🚁': Color(0xFF8E24AA), // Helicopter → Purple
|
||||
|
||||
// SAR Roles - Mountain/Alpine
|
||||
'🏔️': Color(0xFF6D4C41), // Mountain → Brown
|
||||
'⛰️': Color(0xFF6D4C41), // Mountain → Brown
|
||||
'🧗': Color(0xFF6D4C41), // Climber → Brown
|
||||
'🧗♂️': Color(0xFF6D4C41), // Climber → Brown
|
||||
'🧗♀️': Color(0xFF6D4C41), // Climber → Brown
|
||||
'🥾': Color(0xFF5D4037), // Hiking boot → Dark Brown
|
||||
|
||||
// SAR Roles - K9 Unit
|
||||
'🐕': Color(0xFFFFA726), // Dog → Orange
|
||||
'🐶': Color(0xFFFFA726), // Dog → Orange
|
||||
'🦮': Color(0xFFFFA726), // Service dog → Orange
|
||||
|
||||
// SAR Roles - Water Rescue
|
||||
'🚤': Color(0xFF00ACC1), // Speedboat → Cyan
|
||||
'⛵': Color(0xFF00ACC1), // Sailboat → Cyan
|
||||
'🏊': Color(0xFF00897B), // Swimmer → Teal
|
||||
'🏊♂️': Color(0xFF00897B), // Swimmer → Teal
|
||||
'🏊♀️': Color(0xFF00897B), // Swimmer → Teal
|
||||
|
||||
// Team Roles - Leadership
|
||||
'🎯': Color(0xFFE64A19), // Target → Deep Orange (team leader)
|
||||
'⭐': Color(0xFFFDD835), // Star → Yellow (coordinator)
|
||||
'👑': Color(0xFFFDD835), // Crown → Yellow (leader)
|
||||
|
||||
// Team Roles - Communication
|
||||
'📡': Color(0xFF00897B), // Satellite → Teal (radio/comms)
|
||||
'📻': Color(0xFF00897B), // Radio → Teal
|
||||
'📞': Color(0xFF00897B), // Phone → Teal
|
||||
|
||||
// Team Roles - Navigation
|
||||
'🗺️': Color(0xFF00ACC1), // Map → Cyan (navigator)
|
||||
'🧭': Color(0xFF00ACC1), // Compass → Cyan
|
||||
'📍': Color(0xFFE53935), // Pin → Red (location marker)
|
||||
|
||||
// Team Roles - Documentation
|
||||
'📷': Color(0xFFAB47BC), // Camera → Light Purple
|
||||
'📹': Color(0xFFAB47BC), // Video camera → Light Purple
|
||||
'📝': Color(0xFF9E9D24), // Note → Lime (scribe)
|
||||
|
||||
// Equipment
|
||||
'🔦': Color(0xFFFB8C00), // Flashlight → Amber
|
||||
'⚡': Color(0xFFFDD835), // Lightning → Yellow (power/energy)
|
||||
'🔋': Color(0xFF43A047), // Battery → Green
|
||||
'🎒': Color(0xFF5D4037), // Backpack → Brown
|
||||
|
||||
// Generic Person Icons
|
||||
'👤': Color(0xFF9E9E9E), // Silhouette → Gray
|
||||
'🧑': Color(0xFF9E9E9E), // Person → Gray
|
||||
'👨': Color(0xFF9E9E9E), // Man → Gray
|
||||
'👩': Color(0xFF9E9E9E), // Woman → Gray
|
||||
'👥': Color(0xFF757575), // People → Dark Gray
|
||||
};
|
||||
|
||||
/// Get trail color for a contact
|
||||
/// Priority: Emoji mapping > Name hash > Default
|
||||
static Color getTrailColor(Contact contact) {
|
||||
// 1. Try emoji-based color mapping
|
||||
if (contact.roleEmoji != null) {
|
||||
final emojiColor = _emojiColorMap[contact.roleEmoji];
|
||||
if (emojiColor != null) {
|
||||
// Return with slight transparency for better map visibility
|
||||
return emojiColor.withValues(alpha: 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Deterministic color based on display name
|
||||
// Use display name (without emoji) for consistent hashing
|
||||
final name = contact.displayName.isNotEmpty
|
||||
? contact.displayName
|
||||
: contact.publicKeyHex;
|
||||
|
||||
final hash = _hashString(name);
|
||||
final colorIndex = hash % _colorPalette.length; // 0-63
|
||||
|
||||
return _colorPalette[colorIndex].withValues(alpha: 0.75);
|
||||
}
|
||||
|
||||
/// Simple string hash function (DJB2 algorithm)
|
||||
/// Same algorithm used for echo detection in the app
|
||||
static int _hashString(String str) {
|
||||
int hash = 5381;
|
||||
for (int i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) + str.codeUnitAt(i);
|
||||
hash = hash & 0xFFFFFFFF; // Keep 32-bit
|
||||
}
|
||||
return hash.abs();
|
||||
}
|
||||
|
||||
/// Get all unique colors currently in use by contacts with trails
|
||||
static List<Color> getActiveColors(List<Contact> contacts) {
|
||||
final colors = <Color>{};
|
||||
for (final contact in contacts) {
|
||||
if (contact.advertHistory.length >= 2) {
|
||||
colors.add(getTrailColor(contact));
|
||||
}
|
||||
}
|
||||
return colors.toList();
|
||||
}
|
||||
|
||||
/// Check if a color is from emoji mapping (semantic) vs hash-based
|
||||
static bool isSemanticColor(Contact contact) {
|
||||
if (contact.roleEmoji == null) return false;
|
||||
return _emojiColorMap.containsKey(contact.roleEmoji);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user