Modernize contact list pills

This commit is contained in:
Janez T
2026-03-07 18:41:37 +01:00
parent 4010680da0
commit 1867f815d2

View File

@@ -75,110 +75,89 @@ 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
return Card( ? PopupMenuButton<String>(
margin: const EdgeInsets.only(bottom: 8), icon: const Icon(Icons.more_vert),
child: ListTile( onSelected: (value) {
leading: Stack( if (value == 'delete') {
_showDeleteChannelDialog(context, contact);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'delete',
child: Row(
children: [ children: [
CircleAvatar( const Icon(Icons.delete, color: Colors.red, size: 20),
backgroundColor: _getTypeColor(contact.type, context), const SizedBox(width: 8),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(_getTypeIcon(contact.type), color: Colors.white),
),
// New contact indicator badge (top-right)
if (contact.isNew)
Positioned(
top: 0,
right: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
// Room login status indicator badge (bottom-right)
if (contact.type == ContactType.room && roomLoginState != null)
Positioned(
bottom: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: _getRoomStatusColor(roomLoginState),
shape: BoxShape.circle,
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( Text(
'${battery.round()}%', AppLocalizations.of(context)!.deleteChannel,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: const TextStyle(color: Colors.red),
color: BatteryDisplayHelper.getBatteryColor(battery), ),
fontWeight: FontWeight.w600, ],
), ),
), ),
], ],
if (isPingInProgress) ...[ )
const SizedBox(width: 6), : null;
SizedBox( void handleTap() {
width: 14, if (isSimpleMode && contact.type == ContactType.chat) {
height: 14, _showSetRouteDialog(context, contact);
child: CircularProgressIndicator( } else if (isSimpleMode && contact.type == ContactType.repeater) {
strokeWidth: 2, _jumpToMapForRepeater(context, contact);
color: Theme.of(context).colorScheme.primary, } else if (isSimpleMode &&
), contact.type == ContactType.room &&
), !contact.isPublicChannel) {
], _showRoomLoginDialog(context, contact);
], } else {
), _showContactDetails(context, contact);
subtitle: isSimpleMode }
}
final onLongPress = isPingInProgress
? null
: () async {
final connectionProvider = context.read<ConnectionProvider>();
final hasPath = contact.routeHasPath;
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: hasPath,
onRetryWithFlooding: () {
if (context.mounted) {
ToastLogger.warning(
context,
AppLocalizations.of(
context,
)!.directPingTimeout(contact.displayName),
);
}
},
);
if (context.mounted && !result.success) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.pingFailed(contact.displayName),
);
}
};
final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
);
final subtitleWidget = isSimpleMode
? Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4), const SizedBox(height: 2),
// Simple mode: Show last update time
Row( Row(
children: [ children: [
Icon( Icon(
Icons.access_time, Icons.access_time,
size: 12, size: 12,
color: contact.isRecentlySeen color: contact.isRecentlySeen ? Colors.green : Colors.grey,
? Colors.green
: Colors.grey,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -187,15 +166,11 @@ class ContactTile extends StatelessWidget {
), ),
], ],
), ),
// Simple mode: Show location and distance
if (location != null) ...[ if (location != null) ...[
const SizedBox(height: 4),
Row( Row(
children: [ children: [
const Icon( const Icon(Icons.location_on, size: 12, color: Colors.blue),
Icons.location_on,
size: 12,
color: Colors.blue,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( child: Text(
@@ -222,19 +197,21 @@ class ContactTile extends StatelessWidget {
), ),
], ],
] else ] else
Text( Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
AppLocalizations.of(context)!.noGpsData, AppLocalizations.of(context)!.noGpsData,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.labelSmall?.copyWith(color: Colors.grey), ).textTheme.labelSmall?.copyWith(color: Colors.grey),
), ),
),
], ],
) )
: Column( : Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4), const SizedBox(height: 2),
// Room login status badges
if (roomLoginState != null && roomLoginState.isLoggedIn) ...[ if (roomLoginState != null && roomLoginState.isLoggedIn) ...[
Row( Row(
children: [ children: [
@@ -306,15 +283,12 @@ class ContactTile extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
], ],
// Last seen + GPS info combined
Row( Row(
children: [ children: [
Icon( Icon(
Icons.access_time, Icons.access_time,
size: 12, size: 12,
color: contact.isRecentlySeen color: contact.isRecentlySeen ? Colors.green : Colors.grey,
? Colors.green
: Colors.grey,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -326,11 +300,7 @@ class ContactTile extends StatelessWidget {
const Text('', style: TextStyle(color: Colors.grey)), const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8), const SizedBox(width: 8),
if (hasTelemetry) if (hasTelemetry)
const Icon( const Icon(Icons.sensors, size: 12, color: Colors.green)
Icons.sensors,
size: 12,
color: Colors.green,
)
else else
const Icon( const Icon(
Icons.sensors_off, Icons.sensors_off,
@@ -349,11 +319,7 @@ class ContactTile extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('', style: TextStyle(color: Colors.grey)), const Text('', style: TextStyle(color: Colors.grey)),
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon( const Icon(Icons.sensors_off, size: 12, color: Colors.grey),
Icons.sensors_off,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
AppLocalizations.of(context)!.noGpsData, AppLocalizations.of(context)!.noGpsData,
@@ -362,7 +328,6 @@ class ContactTile extends StatelessWidget {
], ],
], ],
), ),
// Distance info (new row)
if (contact.type != ContactType.channel || if (contact.type != ContactType.channel ||
distanceText != null) ...[ distanceText != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -379,84 +344,191 @@ class ContactTile extends StatelessWidget {
), ),
], ],
], ],
);
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
), ),
trailing: contact.isChannel && !contact.isPublicChannel border: Border.all(
? PopupMenuButton<String>( color: colorScheme.outlineVariant.withValues(alpha: 0.35),
icon: const Icon(Icons.more_vert), ),
onSelected: (value) { boxShadow: [
if (value == 'delete') { BoxShadow(
_showDeleteChannelDialog(context, contact); color: Colors.black.withValues(alpha: 0.045),
} blurRadius: 12,
}, offset: const Offset(0, 4),
itemBuilder: (context) => [ ),
PopupMenuItem( ],
value: 'delete', ),
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( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon(Icons.delete, color: Colors.red, size: 20), Stack(
const SizedBox(width: 8), clipBehavior: Clip.none,
Text( children: [
AppLocalizations.of(context)!.deleteChannel, Container(
style: const TextStyle(color: Colors.red), 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),
) )
: null, : Icon(
onTap: () { _getTypeIcon(contact.type),
// In simple mode, tap directly opens the route editor for chat contacts color: Colors.white,
if (isSimpleMode && contact.type == ContactType.chat) { size: 20,
_showSetRouteDialog(context, contact); ),
} else if (isSimpleMode && contact.type == ContactType.repeater) { ),
// In simple mode, tapping a repeater jumps to the map ),
_jumpToMapForRepeater(context, contact); if (contact.isNew)
} else if (isSimpleMode && Positioned(
contact.type == ContactType.room && top: -2,
!contact.isPublicChannel) { right: -2,
_showRoomLoginDialog(context, contact); child: Container(
} else { width: 14,
_showContactDetails(context, contact); height: 14,
} decoration: BoxDecoration(
}, color: Colors.blue,
onLongPress: isPingInProgress shape: BoxShape.circle,
? null border: Border.all(color: Colors.white, width: 2),
: () async { ),
final connectionProvider = context.read<ConnectionProvider>(); ),
),
// Determine if we should use flooding (no path) or direct (has path) if (contact.type == ContactType.room &&
final hasPath = contact.routeHasPath; roomLoginState != null)
Positioned(
// Use smart ping with automatic fallback bottom: -2,
final result = await connectionProvider.smartPing( right: -2,
contactPublicKey: contact.publicKey, child: Container(
hasPath: hasPath, padding: const EdgeInsets.all(3),
onRetryWithFlooding: () { decoration: BoxDecoration(
// Called when retrying with flooding after direct timeout color: _getRoomStatusColor(roomLoginState),
if (context.mounted) { shape: BoxShape.circle,
ToastLogger.warning( border: Border.all(color: Colors.white, width: 2),
context, ),
AppLocalizations.of( child: Icon(
context, _getRoomStatusIcon(roomLoginState),
)!.directPingTimeout(contact.displayName), size: 11,
); color: Colors.white,
} ),
}, ),
); ),
],
// Show final result ),
if (context.mounted) { const SizedBox(width: 10),
if (!result.success) { Expanded(
ToastLogger.error( child: Column(
context, crossAxisAlignment: CrossAxisAlignment.start,
AppLocalizations.of( children: [
context, Row(
)!.pingFailed(contact.displayName), crossAxisAlignment: CrossAxisAlignment.start,
); children: [
} Expanded(
} child: Column(
}, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
contact.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
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,
),
),
),
if (!isSimpleMode && battery != null) ...[
const SizedBox(width: 6),
_buildBatteryBadge(context, battery),
],
if (isPingInProgress) ...[
const SizedBox(width: 6),
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
],
if (trailing != null) ...[
const SizedBox(width: 2),
trailing,
],
],
),
subtitleWidget,
],
),
),
],
),
),
),
), ),
); );
} }
@@ -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,
),
),
],
),
);
}
} }