Refactor SAR marker handling and add template management

- Updated CompassSarList and DetailedCompassDialog to use marker.displayName instead of marker.type.displayName.
- Enhanced DrawingLayer and DrawingMarkersLayer to support simple mode for drawing visibility and interaction.
- Added toggle switches in DrawingToolbar for showing/hiding received drawings and SAR markers.
- Modified MapMarkers to utilize custom emojis and display names for markers.
- Introduced RecipientSelectorSheet for selecting message recipients with search functionality.
- Refactored SarUpdateSheet to use SAR templates instead of marker types, allowing for emoji and name customization.
- Created SarTemplateEditDialog for adding and editing SAR templates with color selection and preview.
This commit is contained in:
Janez T
2025-10-21 23:44:49 +02:00
parent e9d516b749
commit 021ce21cbe
60 changed files with 8736 additions and 1413 deletions

View File

@@ -8,6 +8,7 @@ import '../../models/room_login_state.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart';
import '../../providers/app_provider.dart';
import 'direct_message_sheet.dart';
import 'room_login_sheet.dart';
import '../../utils/toast_logger.dart';
@@ -18,6 +19,7 @@ class ContactTile extends StatelessWidget {
final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance;
final VoidCallback? onNavigateToMap;
const ContactTile({
super.key,
@@ -25,10 +27,14 @@ class ContactTile extends StatelessWidget {
this.currentPosition,
this.calculateDistance,
this.formatDistance,
this.onNavigateToMap,
});
@override
Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent;
final battery = contact.displayBattery;
final location = contact.displayLocation;
@@ -113,8 +119,8 @@ class ContactTile extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
// Battery indicator
if (battery != null) ...[
// Battery indicator - hidden in simple mode
if (!isSimpleMode && battery != null) ...[
const SizedBox(width: 4),
Icon(
_getBatteryIcon(battery),
@@ -130,8 +136,8 @@ class ContactTile extends StatelessWidget {
),
),
],
// Connection type indicator (direct/flood) - shown for all contact types
if (contact.type != ContactType.channel) ...[
// Connection type indicator (direct/flood) - hidden in simple mode
if (!isSimpleMode && contact.type != ContactType.channel) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
@@ -168,129 +174,186 @@ class ContactTile extends StatelessWidget {
],
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
// Room login status badges
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
Row(
subtitle: isSimpleMode
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (roomLoginState.isAdmin)
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red),
const SizedBox(width: 2),
Text(
AppLocalizations.of(context)!.admin,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
if (roomLoginState.isAdmin) const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.green, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
const SizedBox(height: 4),
// Simple mode: Only show location and distance
if (location != null) ...[
Row(
children: [
const Icon(Icons.check_circle, size: 10, color: Colors.green),
const SizedBox(width: 2),
Text(
AppLocalizations.of(context)!.loggedIn,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
const Icon(Icons.location_on, size: 12, color: Colors.blue),
const SizedBox(width: 4),
Expanded(
child: Text(
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
const SizedBox(height: 4),
],
// Last seen + GPS info combined
Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
contact.timeSinceLastSeen,
style: Theme.of(context).textTheme.labelSmall,
),
if (location != null) ...[
const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
if (hasTelemetry)
const Icon(Icons.sensors, size: 12, color: Colors.green)
else
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
if (distanceText != null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.straighten, size: 12, color: Colors.blue),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
],
),
],
] else
Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
),
] else ...[
const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(context).textTheme.labelSmall,
),
],
],
),
// Distance info (new row)
if (distanceText != null) ...[
const SizedBox(height: 4),
Row(
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.straighten, size: 12, color: Colors.blue),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
const SizedBox(height: 4),
// Room login status badges
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
Row(
children: [
if (roomLoginState.isAdmin)
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red),
const SizedBox(width: 2),
Text(
AppLocalizations.of(context)!.admin,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
if (roomLoginState.isAdmin) const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.green, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle, size: 10, color: Colors.green),
const SizedBox(width: 2),
Text(
AppLocalizations.of(context)!.loggedIn,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
],
),
),
],
),
const SizedBox(height: 4),
],
// Last seen + GPS info combined
Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
),
const SizedBox(width: 4),
Text(
contact.timeSinceLastSeen,
style: Theme.of(context).textTheme.labelSmall,
),
if (location != null) ...[
const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
if (hasTelemetry)
const Icon(Icons.sensors, size: 12, color: Colors.green)
else
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
),
] else ...[
const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.noGpsData,
style: Theme.of(context).textTheme.labelSmall,
),
],
],
),
// Distance info (new row)
if (distanceText != null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.straighten, size: 12, color: Colors.blue),
const SizedBox(width: 4),
Text(
'${AppLocalizations.of(context)!.distance}: $distanceText',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
],
),
],
],
),
],
],
),
trailing: null,
onTap: () => _showContactDetails(context, contact),
onTap: () {
// In simple mode, tap directly opens message sheet for chat contacts
if (isSimpleMode && contact.type == ContactType.chat) {
_showDirectMessageDialog(context, contact);
} else if (isSimpleMode && contact.type == ContactType.repeater) {
// In simple mode, tapping a repeater jumps to the map
_jumpToMapForRepeater(context, contact);
} else if (isSimpleMode && contact.type == ContactType.room && !contact.isPublicChannel) {
_showRoomLoginDialog(context, contact);
} else {
_showContactDetails(context, contact);
}
},
onLongPress: () async {
final connectionProvider = context.read<ConnectionProvider>();
@@ -360,6 +423,29 @@ class ContactTile extends StatelessWidget {
);
}
void _jumpToMapForRepeater(BuildContext context, Contact contact) {
final location = contact.displayLocation;
if (location != null) {
final mapProvider = context.read<MapProvider>();
// Navigate to map location
mapProvider.navigateToLocation(
location: LatLng(location.latitude, location.longitude),
zoom: 15.0,
animate: true,
);
// Switch to map tab using callback
onNavigateToMap?.call();
} else {
// No location available, just show toast
ToastLogger.info(
context,
'Repeater ${contact.displayName} has no location data',
);
}
}
void _showDeleteConfirmation(BuildContext context, Contact contact) {
showDialog(
context: context,
@@ -587,8 +673,8 @@ class ContactTile extends StatelessWidget {
);
Navigator.pop(context);
// Switch to map tab (assuming it's index 2)
DefaultTabController.of(context).animateTo(2);
// Switch to map tab using callback
onNavigateToMap?.call();
},
icon: const Icon(Icons.map, size: 18),
label: Text(AppLocalizations.of(context)!.viewOnMap),

View File

@@ -1,12 +1,14 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/app_provider.dart';
import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart';
@@ -174,6 +176,9 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final appProvider = context.watch<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
final contactLocation = widget.contact.displayLocation;
return Container(
height: MediaQuery.of(context).size.height * 0.9,
@@ -222,35 +227,90 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
),
),
// Info banner
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer),
const SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(context)!.directMessageInfo(widget.contact.displayName),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13,
// Mini map in simple mode (scrollable content)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
if (isSimpleMode && contactLocation != null) ...[
GestureDetector(
onTap: () {
// Hide keyboard when tapping on map
_focusNode.unfocus();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outline),
),
clipBehavior: Clip.antiAlias,
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
initialZoom: 13.0,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
),
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
width: 40,
height: 40,
child: Icon(
Icons.location_on,
color: colorScheme.primary,
size: 40,
),
),
],
),
],
),
),
),
),
),
],
const SizedBox(height: 8),
// Location coordinates
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.gps_fixed, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 16),
],
],
),
),
),
const SizedBox(height: 16),
const Spacer(),
// Message input
Container(
padding: EdgeInsets.only(
@@ -321,7 +381,7 @@ class _DirectMessageSheetState extends State<DirectMessageSheet> {
OutlinedButton.icon(
onPressed: _insertCurrentLocation,
icon: const Icon(Icons.my_location, size: 18),
label: const Text('Location'),
label: Text(AppLocalizations.of(context)!.myLocation),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
side: BorderSide(color: colorScheme.outline),

View File

@@ -124,7 +124,7 @@ class CompassSarList extends StatelessWidget {
color: markerColor,
size: 24,
),
title: Text(marker.type.displayName),
title: Text(marker.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}${marker.timeAgo}',
style: Theme.of(context).textTheme.bodySmall,

View File

@@ -534,7 +534,7 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
}
} else if (_selectedSarMarker != null) {
title = _selectedSarMarker!.type.displayName;
title = _selectedSarMarker!.displayName;
targetLocation = _selectedSarMarker!.location;
additionalInfo = _selectedSarMarker!.timeAgo;
@@ -664,21 +664,21 @@ class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
children: [
_buildLargeInfoCard(
context,
'Distance',
AppLocalizations.of(context)!.distance,
_formatDistance(distance),
Icons.straighten,
color,
),
_buildLargeInfoCard(
context,
'Bearing',
AppLocalizations.of(context)!.bearing,
'${bearing.round()}°',
Icons.navigation,
color,
),
_buildLargeInfoCard(
context,
'Direction',
AppLocalizations.of(context)!.direction,
_bearingToCardinal(bearing),
Icons.explore,
color,

View File

@@ -8,11 +8,13 @@ import '../../l10n/app_localizations.dart';
class DrawingLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final MapDrawing? previewDrawing;
final bool isSimpleMode;
const DrawingLayer({
super.key,
required this.drawings,
this.previewDrawing,
this.isSimpleMode = false,
});
@override
@@ -45,8 +47,9 @@ class DrawingLayer extends StatelessWidget {
opacity = 0.6;
strokeWidth = 4.0;
} else if (drawing.isReceived) {
// Received drawing from another node (thinner, more transparent)
opacity = 0.7;
// Received drawing from another node
// In simple mode: solid (opacity 1.0), in normal mode: translucent (0.7)
opacity = isSimpleMode ? 1.0 : 0.7;
strokeWidth = 3.0;
} else {
// Local drawing (solid line, normal thickness)
@@ -82,13 +85,17 @@ class DrawingLayer extends StatelessWidget {
class DrawingMarkersLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final Function(String drawingId)? onDeleteDrawing;
final Function(MapDrawing drawing)? onTapDrawing;
final bool showDeleteButtons;
final bool isSimpleMode;
const DrawingMarkersLayer({
super.key,
required this.drawings,
this.onDeleteDrawing,
this.onTapDrawing,
this.showDeleteButtons = false,
this.isSimpleMode = false,
});
@override
@@ -134,49 +141,64 @@ class DrawingMarkersLayer extends StatelessWidget {
),
),
);
} else if (drawing.isReceived && drawing.senderName != null) {
// Show sender badge for received drawings (when not in drawing mode)
} else if (drawing.isReceived && drawing.senderName != null && !isSimpleMode) {
// Show sender badge for received drawings (when not in drawing mode and not in simple mode)
// Make it tappable if message ID is available
markers.add(
Marker(
point: centerPoint,
width: 120,
height: 30,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.person,
color: Colors.white,
size: 14,
),
const SizedBox(width: 4),
Flexible(
child: Text(
drawing.senderName!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: GestureDetector(
onTap: drawing.messageId != null && onTapDrawing != null
? () => onTapDrawing!(drawing)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
),
],
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.person,
color: Colors.white,
size: 14,
),
const SizedBox(width: 4),
Flexible(
child: Text(
drawing.senderName!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
// Add indicator that this is tappable
if (drawing.messageId != null && onTapDrawing != null) ...[
const SizedBox(width: 4),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 10,
),
],
],
),
),
),
),

View File

@@ -201,6 +201,43 @@ class DrawingToolbar extends StatelessWidget {
drawingProvider.setDrawingMode(DrawingMode.rectangle);
},
),
const Divider(),
// Toggle received drawings visibility
SwitchListTile(
secondary: Icon(
drawingProvider.showReceivedDrawings
? Icons.visibility
: Icons.visibility_off,
),
title: Text(AppLocalizations.of(context)!.showReceivedDrawings),
subtitle: Text(
drawingProvider.showReceivedDrawings
? AppLocalizations.of(context)!.showingAllDrawings
: AppLocalizations.of(context)!.showingOnlyYourDrawings,
),
value: drawingProvider.showReceivedDrawings,
onChanged: (value) {
drawingProvider.toggleReceivedDrawings();
},
),
// Toggle SAR markers visibility
SwitchListTile(
secondary: Icon(
drawingProvider.showSarMarkers
? Icons.pin_drop
: Icons.pin_drop_outlined,
),
title: Text(AppLocalizations.of(context)!.showSarMarkers),
subtitle: Text(
drawingProvider.showSarMarkers
? AppLocalizations.of(context)!.showingSarMarkers
: AppLocalizations.of(context)!.hidingSarMarkers,
),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
},
),
if (drawingProvider.drawings.isNotEmpty) ...[
const Divider(),
ListTile(

View File

@@ -158,7 +158,7 @@ class MapMarkers {
),
padding: const EdgeInsets.all(6),
child: Text(
marker.type.emoji,
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
@@ -171,16 +171,27 @@ class MapMarkers {
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.type.getLocalizedName(context),
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
child: Builder(
builder: (context) {
// Debug: Print what we're actually displaying
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
debugPrint(' marker.notes: "${marker.notes}"');
debugPrint(' marker.type: ${marker.type}');
debugPrint(' marker.type.displayName: ${marker.type.displayName}');
debugPrint(' marker.displayName: ${marker.displayName}');
return Text(
marker.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
);
},
),
),
],
@@ -252,9 +263,9 @@ class MapMarkers {
builder: (context) => AlertDialog(
title: Row(
children: [
Text(marker.type.emoji, style: const TextStyle(fontSize: 24)),
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available
const SizedBox(width: 8),
Expanded(child: Text(marker.type.getLocalizedName(context))),
Expanded(child: Text(marker.displayName)),
],
),
content: Column(

View File

@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../l10n/app_localizations.dart';
/// Bottom sheet for selecting message recipient (channel, contact, or room)
class RecipientSelectorSheet extends StatefulWidget {
final List<Contact> contacts;
final List<Contact> rooms;
final String? currentDestinationType;
final String? currentRecipientPublicKey;
final Function(String type, Contact? recipient) onSelect;
const RecipientSelectorSheet({
super.key,
required this.contacts,
required this.rooms,
this.currentDestinationType,
this.currentRecipientPublicKey,
required this.onSelect,
});
@override
State<RecipientSelectorSheet> createState() => _RecipientSelectorSheetState();
}
class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<Contact> _filterContacts(List<Contact> contacts) {
if (_searchQuery.isEmpty) return contacts;
final query = _searchQuery.toLowerCase();
return contacts.where((contact) {
final name = contact.displayName?.toLowerCase() ?? contact.advName.toLowerCase();
return name.contains(query);
}).toList();
}
bool _isSelected(String type, Contact? contact) {
if (widget.currentDestinationType != type) return false;
if (type == 'channel') return true;
if (contact == null) return false;
return contact.publicKeyHex == widget.currentRecipientPublicKey;
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final filteredContacts = _filterContacts(widget.contacts);
final filteredRooms = _filterContacts(widget.rooms);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Row(
children: [
Text(
l10n.selectRecipient,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
tooltip: l10n.close,
),
],
),
),
// Search field
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.searchRecipients,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Recipients list
Flexible(
child: ListView(
shrinkWrap: true,
children: [
// Public Channel option
_buildRecipientTile(
context: context,
icon: Icons.public,
title: l10n.publicChannel,
subtitle: l10n.broadcastToAllNearby,
isSelected: _isSelected('channel', null),
onTap: () {
widget.onSelect('channel', null);
Navigator.pop(context);
},
),
const Divider(),
// Contacts section
if (widget.contacts.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
l10n.contacts,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
if (filteredContacts.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.noContactsFound,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
)
else
...filteredContacts.map((contact) {
return _buildRecipientTile(
context: context,
icon: Icons.person,
title: contact.displayName ?? contact.advName,
subtitle: contact.publicKeyShort,
emoji: contact.roleEmoji,
isSelected: _isSelected('contact', contact),
onTap: () {
widget.onSelect('contact', contact);
Navigator.pop(context);
},
);
}),
],
const Divider(),
// Rooms section
if (widget.rooms.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
l10n.rooms,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
if (filteredRooms.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.noRoomsFound,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).disabledColor,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
)
else
...filteredRooms.map((room) {
return _buildRecipientTile(
context: context,
icon: Icons.meeting_room,
title: room.displayName ?? room.advName,
subtitle: room.publicKeyShort,
emoji: room.roleEmoji,
isSelected: _isSelected('room', room),
onTap: () {
widget.onSelect('room', room);
Navigator.pop(context);
},
);
}),
],
// Empty state
if (widget.contacts.isEmpty && widget.rooms.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Icons.people_outline,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
l10n.noContactsOrRoomsAvailable,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).disabledColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.messagesWillBeSentToPublicChannel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).disabledColor,
),
textAlign: TextAlign.center,
),
],
),
),
],
const SizedBox(height: 16),
],
),
),
],
),
);
}
Widget _buildRecipientTile({
required BuildContext context,
required IconData icon,
required String title,
required String subtitle,
String? emoji,
required bool isSelected,
required VoidCallback onTap,
}) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
title: Row(
children: [
if (emoji != null && emoji.isNotEmpty) ...[
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
],
),
subtitle: Text(
subtitle,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
).copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
trailing: isSelected
? Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
)
: null,
onTap: onTap,
);
}
}

View File

@@ -4,14 +4,15 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../../providers/contacts_provider.dart';
import '../../models/contact.dart';
import '../../models/sar_marker.dart';
import '../../models/sar_template.dart';
import '../../services/validation_service.dart';
import '../../services/sar_template_service.dart';
import '../../l10n/app_localizations.dart';
/// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers
/// This widget is public so it can be used from both messages_tab.dart and map_tab.dart
class SarUpdateSheet extends StatefulWidget {
final Future<void> Function(SarMarkerType, Position, String?, Uint8List?, bool) onSend;
final Future<void> Function(String emoji, String name, Position, Uint8List?, bool) onSend;
final Position? prePopulatedPosition;
final bool allowLocationUpdate;
@@ -27,7 +28,9 @@ class SarUpdateSheet extends StatefulWidget {
}
class _SarUpdateSheetState extends State<SarUpdateSheet> {
SarMarkerType _selectedType = SarMarkerType.foundPerson;
SarTemplate? _selectedTemplate;
List<SarTemplate> _templates = [];
final SarTemplateService _templateService = SarTemplateService();
Position? _currentPosition;
bool _loadingLocation = false;
String? _locationError;
@@ -37,6 +40,7 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
@override
void initState() {
super.initState();
_initializeTemplates();
// Use pre-populated position if provided, otherwise get current location
if (widget.prePopulatedPosition != null) {
_currentPosition = widget.prePopulatedPosition;
@@ -46,6 +50,21 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
_setDefaultDestination();
}
Future<void> _initializeTemplates() async {
if (!_templateService.isInitialized) {
await _templateService.initialize();
}
if (mounted) {
setState(() {
_templates = _templateService.templates;
// Select first template by default
if (_templates.isNotEmpty) {
_selectedTemplate = _templates.first;
}
});
}
}
void _setDefaultDestination() {
// Set default to first room, or first channel if no rooms exist
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -140,19 +159,23 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
Widget build(BuildContext context) {
// Get keyboard height to adjust padding
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
return AnimatedPadding(
padding: EdgeInsets.only(bottom: keyboardHeight),
duration: const Duration(milliseconds: 100),
child: Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
@@ -196,7 +219,9 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
left: 16,
right: 16,
top: 16,
bottom: keyboardHeight > 0 ? keyboardHeight + 16 : 16,
// Add bottom padding for button area (button + padding + safe area)
// Button height ~48px + container padding 32px + safe area
bottom: 80 + bottomSafeArea,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -211,30 +236,17 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
),
),
const SizedBox(height: 12),
MarkerTypeChip(
type: SarMarkerType.foundPerson,
isSelected: _selectedType == SarMarkerType.foundPerson,
onTap: () => setState(() => _selectedType = SarMarkerType.foundPerson),
),
const SizedBox(height: 8),
MarkerTypeChip(
type: SarMarkerType.fire,
isSelected: _selectedType == SarMarkerType.fire,
onTap: () => setState(() => _selectedType = SarMarkerType.fire),
),
const SizedBox(height: 8),
MarkerTypeChip(
type: SarMarkerType.stagingArea,
isSelected: _selectedType == SarMarkerType.stagingArea,
onTap: () => setState(() => _selectedType = SarMarkerType.stagingArea),
),
const SizedBox(height: 8),
MarkerTypeChip(
type: SarMarkerType.object,
isSelected: _selectedType == SarMarkerType.object,
onTap: () => setState(() => _selectedType = SarMarkerType.object),
),
const SizedBox(height: 24),
..._templates.map((template) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TemplateChip(
template: template,
isSelected: _selectedTemplate?.id == template.id,
onTap: () => setState(() => _selectedTemplate = template),
),
);
}).toList(),
const SizedBox(height: 16),
// Destination selection (compact dropdown with rooms and channel)
Text(
@@ -595,35 +607,28 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
// Bottom action button
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _currentPosition == null || _selectedContact == null
onPressed: _currentPosition == null || _selectedContact == null || _selectedTemplate == null
? null
: () async {
final validator = ValidationService();
// Validate coordinates
final coordResult = validator.validateCoordinates(
_currentPosition!.latitude,
_currentPosition!.longitude,
);
if (!coordResult.isValid) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(coordResult.errorMessage!),
backgroundColor: Colors.red,
),
);
}
return;
}
final notes = _notesController.text.trim();
// Validate notes length if provided
final notes = _notesController.text.trim();
if (notes.isNotEmpty) {
final notesResult = validator.validateName(
notes,
@@ -642,6 +647,23 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
}
}
// Validate coordinates
final coordResult = validator.validateCoordinates(
_currentPosition!.latitude,
_currentPosition!.longitude,
);
if (!coordResult.isValid) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(coordResult.errorMessage!),
backgroundColor: Colors.red,
),
);
}
return;
}
// Validate location accuracy (warn if >50m)
if (_currentPosition!.accuracy != null &&
_currentPosition!.accuracy! > 50.0) {
@@ -669,10 +691,21 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
if (shouldContinue != true) return;
}
// Combine template name with optional notes
String displayText;
if (notes.isNotEmpty) {
// Include both template name and custom notes
displayText = '${_selectedTemplate!.name} - $notes';
} else {
// Just the template name
displayText = _selectedTemplate!.name;
}
// Send SAR marker with emoji and display text
await widget.onSend(
_selectedType,
_selectedTemplate!.emoji,
displayText,
_currentPosition!,
notes.isEmpty ? null : notes,
_selectedContact!.isChannel
? null
: _selectedContact!.publicKey,
@@ -701,43 +734,29 @@ class _SarUpdateSheetState extends State<SarUpdateSheet> {
),
],
),
),
);
}
}
/// Marker Type Chip widget - Displays a selectable SAR marker type
class MarkerTypeChip extends StatelessWidget {
final SarMarkerType type;
/// Template Chip widget - Displays a selectable SAR template
class TemplateChip extends StatelessWidget {
final SarTemplate template;
final bool isSelected;
final VoidCallback onTap;
const MarkerTypeChip({
const TemplateChip({
super.key,
required this.type,
required this.template,
required this.isSelected,
required this.onTap,
});
Color _getMarkerColor() {
switch (type) {
case SarMarkerType.foundPerson:
return Colors.green;
case SarMarkerType.fire:
return Colors.red;
case SarMarkerType.stagingArea:
return Colors.orange;
case SarMarkerType.object:
return Colors.purple;
case SarMarkerType.unknown:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
final color = _getMarkerColor();
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final color = template.color;
return InkWell(
onTap: onTap,
@@ -758,18 +777,35 @@ class MarkerTypeChip extends StatelessWidget {
child: Row(
children: [
Text(
type.emoji,
template.emoji,
style: const TextStyle(fontSize: 32),
),
const SizedBox(width: 16),
Expanded(
child: Text(
type.getLocalizedName(context),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: isSelected ? color : colorScheme.onSurface,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
template.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isSelected ? color : colorScheme.onSurface,
),
),
if (template.description.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
template.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
],
),
),
if (isSelected)

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import '../../models/sar_template.dart';
import '../../l10n/app_localizations.dart';
/// Dialog for adding or editing SAR templates
class SarTemplateEditDialog extends StatefulWidget {
final SarTemplate? template; // Null for new template
final Function(SarTemplate) onSave;
const SarTemplateEditDialog({
super.key,
this.template,
required this.onSave,
});
@override
State<SarTemplateEditDialog> createState() => _SarTemplateEditDialogState();
}
class _SarTemplateEditDialogState extends State<SarTemplateEditDialog> {
late TextEditingController _emojiController;
late TextEditingController _nameController;
late TextEditingController _descriptionController;
late String _selectedColor;
final List<Map<String, dynamic>> _colorOptions = [
{'name': 'Green', 'hex': '#4CAF50'},
{'name': 'Red', 'hex': '#F44336'},
{'name': 'Orange', 'hex': '#FF9800'},
{'name': 'Purple', 'hex': '#9C27B0'},
{'name': 'Blue', 'hex': '#2196F3'},
{'name': 'Yellow', 'hex': '#FFC107'},
{'name': 'Brown', 'hex': '#795548'},
{'name': 'Gray', 'hex': '#9E9E9E'},
];
String? _emojiError;
String? _nameError;
@override
void initState() {
super.initState();
_emojiController = TextEditingController(text: widget.template?.emoji ?? '');
_nameController = TextEditingController(text: widget.template?.name ?? '');
_descriptionController = TextEditingController(text: widget.template?.description ?? '');
_selectedColor = widget.template?.colorHex ?? '#4CAF50';
}
@override
void dispose() {
_emojiController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
bool _validate() {
final l10n = AppLocalizations.of(context)!;
setState(() {
_emojiError = null;
_nameError = null;
});
bool isValid = true;
if (_emojiController.text.trim().isEmpty) {
setState(() {
_emojiError = l10n.emojiRequired;
});
isValid = false;
}
if (_nameController.text.trim().isEmpty) {
setState(() {
_nameError = l10n.nameRequired;
});
isValid = false;
}
return isValid;
}
void _save() {
if (!_validate()) return;
final template = SarTemplate(
id: widget.template?.id ?? 'custom_${DateTime.now().millisecondsSinceEpoch}',
emoji: _emojiController.text.trim(),
name: _nameController.text.trim(),
description: _descriptionController.text.trim(),
colorHex: _selectedColor,
isDefault: widget.template?.isDefault ?? false,
);
widget.onSave(template);
Navigator.of(context).pop();
}
String _getPreview() {
final emoji = _emojiController.text.trim();
final description = _descriptionController.text.trim();
if (emoji.isEmpty) return 'S::0,0';
if (description.isEmpty) return 'S:$emoji:0,0';
return 'S:$emoji:0,0:$description';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Text(
widget.template == null ? l10n.addTemplate : l10n.editTemplate,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 24),
// Emoji field
TextField(
controller: _emojiController,
decoration: InputDecoration(
labelText: l10n.templateEmoji,
hintText: '🧑',
errorText: _emojiError,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.emoji_emotions),
),
maxLength: 4,
style: const TextStyle(fontSize: 24),
textAlign: TextAlign.center,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Name field
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.templateName,
hintText: l10n.templateNameHint,
errorText: _nameError,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.label),
),
maxLength: 30,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Description field
TextField(
controller: _descriptionController,
decoration: InputDecoration(
labelText: l10n.templateDescription,
hintText: l10n.templateDescriptionHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.description),
),
maxLength: 100,
maxLines: 2,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Color picker
Text(
l10n.templateColor,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: _colorOptions.map((colorOption) {
final hex = colorOption['hex'] as String;
final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16));
final isSelected = _selectedColor == hex;
return GestureDetector(
onTap: () => setState(() => _selectedColor = hex),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? colorScheme.primary : Colors.transparent,
width: 3,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
const SizedBox(height: 24),
// Preview
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.previewFormat,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_getPreview(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 24),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: Text(l10n.save),
),
],
),
],
),
),
);
},
),
);
}
}