mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Modernize contact list pills
This commit is contained in:
@@ -75,388 +75,460 @@ class ContactTile extends StatelessWidget {
|
|||||||
final roomLoginState = contact.type == ContactType.room
|
final roomLoginState = contact.type == ContactType.room
|
||||||
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
|
||||||
: null;
|
: null;
|
||||||
|
final trailing = contact.isChannel && !contact.isPublicChannel
|
||||||
|
? PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'delete') {
|
||||||
|
_showDeleteChannelDialog(context, contact);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete, color: Colors.red, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)!.deleteChannel,
|
||||||
|
style: const TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
void handleTap() {
|
||||||
|
if (isSimpleMode && contact.type == ContactType.chat) {
|
||||||
|
_showSetRouteDialog(context, contact);
|
||||||
|
} else if (isSimpleMode && contact.type == ContactType.repeater) {
|
||||||
|
_jumpToMapForRepeater(context, contact);
|
||||||
|
} else if (isSimpleMode &&
|
||||||
|
contact.type == ContactType.room &&
|
||||||
|
!contact.isPublicChannel) {
|
||||||
|
_showRoomLoginDialog(context, contact);
|
||||||
|
} else {
|
||||||
|
_showContactDetails(context, contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Card(
|
final onLongPress = isPingInProgress
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
? null
|
||||||
child: ListTile(
|
: () async {
|
||||||
leading: Stack(
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
children: [
|
final hasPath = contact.routeHasPath;
|
||||||
CircleAvatar(
|
|
||||||
backgroundColor: _getTypeColor(contact.type, context),
|
final result = await connectionProvider.smartPing(
|
||||||
child: contact.roleEmoji != null
|
contactPublicKey: contact.publicKey,
|
||||||
? Text(
|
hasPath: hasPath,
|
||||||
contact.roleEmoji!,
|
onRetryWithFlooding: () {
|
||||||
style: const TextStyle(fontSize: 24),
|
if (context.mounted) {
|
||||||
)
|
ToastLogger.warning(
|
||||||
: Icon(_getTypeIcon(contact.type), color: Colors.white),
|
context,
|
||||||
),
|
AppLocalizations.of(
|
||||||
// New contact indicator badge (top-right)
|
context,
|
||||||
if (contact.isNew)
|
)!.directPingTimeout(contact.displayName),
|
||||||
Positioned(
|
);
|
||||||
top: 0,
|
}
|
||||||
right: 0,
|
},
|
||||||
child: Container(
|
);
|
||||||
width: 12,
|
|
||||||
height: 12,
|
if (context.mounted && !result.success) {
|
||||||
decoration: BoxDecoration(
|
ToastLogger.error(
|
||||||
color: Colors.blue,
|
context,
|
||||||
shape: BoxShape.circle,
|
AppLocalizations.of(context)!.pingFailed(contact.displayName),
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
);
|
||||||
),
|
}
|
||||||
),
|
};
|
||||||
),
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
// Room login status indicator badge (bottom-right)
|
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
if (contact.type == ContactType.room && roomLoginState != null)
|
fontWeight: FontWeight.w800,
|
||||||
Positioned(
|
letterSpacing: -0.3,
|
||||||
bottom: 0,
|
);
|
||||||
right: 0,
|
final subtitleWidget = isSimpleMode
|
||||||
child: Container(
|
? Column(
|
||||||
padding: const EdgeInsets.all(2),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: _getRoomStatusColor(roomLoginState),
|
const SizedBox(height: 2),
|
||||||
shape: BoxShape.circle,
|
Row(
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_getRoomStatusIcon(roomLoginState),
|
|
||||||
size: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
contact.displayName,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Battery indicator - hidden in simple mode
|
|
||||||
if (!isSimpleMode && battery != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
BatteryDisplayHelper.getBatteryIcon(battery),
|
|
||||||
size: 16,
|
|
||||||
color: BatteryDisplayHelper.getBatteryColor(battery),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
'${battery.round()}%',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: BatteryDisplayHelper.getBatteryColor(battery),
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (isPingInProgress) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: isSimpleMode
|
|
||||||
? Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
Icon(
|
||||||
// Simple mode: Show last update time
|
Icons.access_time,
|
||||||
Row(
|
size: 12,
|
||||||
children: [
|
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||||
Icon(
|
),
|
||||||
Icons.access_time,
|
const SizedBox(width: 4),
|
||||||
size: 12,
|
Text(
|
||||||
color: contact.isRecentlySeen
|
'${AppLocalizations.of(context)!.lastSeen}: ${_getLocalizedTimeSinceLastSeen(context)}',
|
||||||
? Colors.green
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
: Colors.grey,
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
Text(
|
if (location != null) ...[
|
||||||
'${AppLocalizations.of(context)!.lastSeen}: ${_getLocalizedTimeSinceLastSeen(context)}',
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
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,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (contact.type != ContactType.channel ||
|
||||||
|
distanceText != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (contact.type != ContactType.channel)
|
||||||
|
_buildRoutePill(context, contact),
|
||||||
|
if (distanceText != null)
|
||||||
|
_buildDistancePill(context, distanceText),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Simple mode: Show location and distance
|
],
|
||||||
if (location != null) ...[
|
] else
|
||||||
Row(
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(top: 4),
|
||||||
const Icon(
|
child: Text(
|
||||||
Icons.location_on,
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
size: 12,
|
style: Theme.of(
|
||||||
color: Colors.blue,
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (roomLoginState.isAdmin)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 2,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
decoration: BoxDecoration(
|
||||||
Expanded(
|
color: Colors.red.withValues(alpha: 0.2),
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(4),
|
||||||
'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
|
border: Border.all(color: Colors.red, width: 1),
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
child: Row(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (contact.type != ContactType.channel ||
|
children: [
|
||||||
distanceText != null) ...[
|
const Icon(
|
||||||
const SizedBox(height: 6),
|
Icons.admin_panel_settings,
|
||||||
Wrap(
|
size: 10,
|
||||||
spacing: 8,
|
color: Colors.red,
|
||||||
runSpacing: 6,
|
),
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
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.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
border: Border.all(color: Colors.green, width: 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (contact.type != ContactType.channel)
|
const Icon(
|
||||||
_buildRoutePill(context, contact),
|
Icons.check_circle,
|
||||||
if (distanceText != null)
|
size: 10,
|
||||||
_buildDistancePill(context, distanceText),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
] else
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 12,
|
||||||
|
color: contact.isRecentlySeen ? Colors.green : Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_getLocalizedTimeSinceLastSeen(context),
|
||||||
|
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(
|
Text(
|
||||||
AppLocalizations.of(context)!.noGpsData,
|
AppLocalizations.of(context)!.noGpsData,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
context,
|
|
||||||
).textTheme.labelSmall?.copyWith(color: Colors.grey),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
: Column(
|
if (contact.type != ContactType.channel ||
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
distanceText != null) ...[
|
||||||
children: [
|
const SizedBox(height: 6),
|
||||||
const SizedBox(height: 4),
|
Wrap(
|
||||||
// Room login status badges
|
spacing: 8,
|
||||||
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
|
runSpacing: 6,
|
||||||
Row(
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (roomLoginState.isAdmin)
|
if (contact.type != ContactType.channel)
|
||||||
Container(
|
_buildRoutePill(context, contact),
|
||||||
padding: const EdgeInsets.symmetric(
|
if (distanceText != null)
|
||||||
horizontal: 4,
|
_buildDistancePill(context, distanceText),
|
||||||
vertical: 2,
|
],
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
color: Colors.red.withValues(alpha: 0.2),
|
],
|
||||||
borderRadius: BorderRadius.circular(4),
|
);
|
||||||
border: Border.all(color: Colors.red, width: 1),
|
|
||||||
),
|
return Container(
|
||||||
child: Row(
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.surfaceContainerLow,
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.045),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
onTap: handleTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
_getTypeColor(contact.type, context),
|
||||||
|
_getTypeColor(
|
||||||
|
contact.type,
|
||||||
|
context,
|
||||||
|
).withValues(alpha: 0.72),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: _getTypeColor(
|
||||||
|
contact.type,
|
||||||
|
context,
|
||||||
|
).withValues(alpha: 0.22),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: contact.roleEmoji != null
|
||||||
|
? Text(
|
||||||
|
contact.roleEmoji!,
|
||||||
|
style: const TextStyle(fontSize: 22),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_getTypeIcon(contact.type),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (contact.isNew)
|
||||||
|
Positioned(
|
||||||
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (contact.type == ContactType.room &&
|
||||||
|
roomLoginState != null)
|
||||||
|
Positioned(
|
||||||
|
bottom: -2,
|
||||||
|
right: -2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getRoomStatusColor(roomLoginState),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_getRoomStatusIcon(roomLoginState),
|
||||||
|
size: 11,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
|
||||||
Icons.admin_panel_settings,
|
|
||||||
size: 10,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.admin,
|
contact.displayName,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
maxLines: 1,
|
||||||
?.copyWith(
|
overflow: TextOverflow.ellipsis,
|
||||||
color: Colors.red,
|
style: titleStyle,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (roomLoginState.isAdmin) const SizedBox(width: 4),
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 4,
|
horizontal: 6,
|
||||||
vertical: 2,
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.75),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
contact.type.displayName,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
if (!isSimpleMode && battery != null) ...[
|
||||||
color: Colors.green.withValues(alpha: 0.2),
|
const SizedBox(width: 6),
|
||||||
borderRadius: BorderRadius.circular(4),
|
_buildBatteryBadge(context, battery),
|
||||||
border: Border.all(color: Colors.green, width: 1),
|
],
|
||||||
),
|
if (isPingInProgress) ...[
|
||||||
child: Row(
|
const SizedBox(width: 6),
|
||||||
mainAxisSize: MainAxisSize.min,
|
SizedBox(
|
||||||
children: [
|
width: 16,
|
||||||
const Icon(
|
height: 16,
|
||||||
Icons.check_circle,
|
child: CircularProgressIndicator(
|
||||||
size: 10,
|
strokeWidth: 2,
|
||||||
color: Colors.green,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 2),
|
),
|
||||||
Text(
|
],
|
||||||
AppLocalizations.of(context)!.loggedIn,
|
if (trailing != null) ...[
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
const SizedBox(width: 2),
|
||||||
?.copyWith(
|
trailing,
|
||||||
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),
|
subtitleWidget,
|
||||||
Text(
|
|
||||||
_getLocalizedTimeSinceLastSeen(context),
|
|
||||||
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 (contact.type != ContactType.channel ||
|
],
|
||||||
distanceText != null) ...[
|
),
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
Wrap(
|
),
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 6,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (contact.type != ContactType.channel)
|
|
||||||
_buildRoutePill(context, contact),
|
|
||||||
if (distanceText != null)
|
|
||||||
_buildDistancePill(context, distanceText),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: contact.isChannel && !contact.isPublicChannel
|
|
||||||
? PopupMenuButton<String>(
|
|
||||||
icon: const Icon(Icons.more_vert),
|
|
||||||
onSelected: (value) {
|
|
||||||
if (value == 'delete') {
|
|
||||||
_showDeleteChannelDialog(context, contact);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'delete',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.delete, color: Colors.red, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.deleteChannel,
|
|
||||||
style: const TextStyle(color: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
// In simple mode, tap directly opens the route editor for chat contacts
|
|
||||||
if (isSimpleMode && contact.type == ContactType.chat) {
|
|
||||||
_showSetRouteDialog(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: isPingInProgress
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
|
|
||||||
// Determine if we should use flooding (no path) or direct (has path)
|
|
||||||
final hasPath = contact.routeHasPath;
|
|
||||||
|
|
||||||
// Use smart ping with automatic fallback
|
|
||||||
final result = await connectionProvider.smartPing(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
hasPath: hasPath,
|
|
||||||
onRetryWithFlooding: () {
|
|
||||||
// Called when retrying with flooding after direct timeout
|
|
||||||
if (context.mounted) {
|
|
||||||
ToastLogger.warning(
|
|
||||||
context,
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.directPingTimeout(contact.displayName),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show final result
|
|
||||||
if (context.mounted) {
|
|
||||||
if (!result.success) {
|
|
||||||
ToastLogger.error(
|
|
||||||
context,
|
|
||||||
AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.pingFailed(contact.displayName),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1419,4 +1491,33 @@ class ContactTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBatteryBadge(BuildContext context, double battery) {
|
||||||
|
final color = BatteryDisplayHelper.getBatteryColor(battery);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
BatteryDisplayHelper.getBatteryIcon(battery),
|
||||||
|
size: 12,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${battery.round()}%',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user