mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
fix: Restore telemetry refresh behavior
ref:
This commit is contained in:
@@ -519,61 +519,58 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
final canExportHashChannelPsk = Channel.isHashChannelName(
|
||||
channel.advName.trim(),
|
||||
);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
final actions = <_ChannelSheetAction>[
|
||||
_ChannelSheetAction(
|
||||
icon: Icons.message_outlined,
|
||||
label: l10n.messages,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await _openMessagesForChannel(context, channel);
|
||||
},
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.message_outlined),
|
||||
title: Text(l10n.messages),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await _openMessagesForChannel(context, channel);
|
||||
},
|
||||
),
|
||||
if (channel.displayLocation != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.map_outlined),
|
||||
title: Text(l10n.viewOnMap),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showChannelOnMap(context, channel);
|
||||
},
|
||||
),
|
||||
if (canExportHashChannelPsk)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.key_outlined),
|
||||
title: Text('${l10n.exportToClipboard} psk_base64'),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await _exportHashChannelPskBase64(context, channel);
|
||||
},
|
||||
),
|
||||
if (!channel.isPublicChannel)
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
title: Text(
|
||||
l10n.deleteChannel,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.pop(sheetContext);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
if (!context.mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
_showDeleteChannelDialog(context, channel);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
if (channel.displayLocation != null)
|
||||
_ChannelSheetAction(
|
||||
icon: Icons.map_outlined,
|
||||
label: l10n.viewOnMap,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
_showChannelOnMap(context, channel);
|
||||
},
|
||||
),
|
||||
if (canExportHashChannelPsk)
|
||||
_ChannelSheetAction(
|
||||
icon: Icons.key_outlined,
|
||||
label: '${l10n.exportToClipboard} psk_base64',
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await _exportHashChannelPskBase64(context, channel);
|
||||
},
|
||||
),
|
||||
if (!channel.isPublicChannel)
|
||||
_ChannelSheetAction(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
label: l10n.deleteChannel,
|
||||
destructive: true,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
if (!context.mounted) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
_showDeleteChannelDialog(context, channel);
|
||||
});
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) => _ChannelActionSheet(
|
||||
channel: channel,
|
||||
actions: actions,
|
||||
onClose: () => Navigator.pop(sheetContext),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1980,6 +1977,302 @@ class _ExpandableParticipantStackState
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelSheetAction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Future<void> Function() onTap;
|
||||
final bool destructive;
|
||||
|
||||
const _ChannelSheetAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.destructive = false,
|
||||
});
|
||||
}
|
||||
|
||||
class _ChannelActionSheet extends StatelessWidget {
|
||||
final Contact channel;
|
||||
final List<_ChannelSheetAction> actions;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const _ChannelActionSheet({
|
||||
required this.channel,
|
||||
required this.actions,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final title = channel.getLocalizedDisplayName(context);
|
||||
final subtitle = channel.isPublicChannel
|
||||
? l10n.broadcastToAllNearby
|
||||
: '${l10n.channel} ${channel.publicKey[1]}';
|
||||
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: colorScheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ContactAvatar(
|
||||
contact: channel,
|
||||
radius: 28,
|
||||
displayName: title,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -0.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_ChannelSheetChip(
|
||||
icon: channel.isPublicChannel
|
||||
? Icons.broadcast_on_personal_rounded
|
||||
: Icons.tag_rounded,
|
||||
label: channel.isPublicChannel
|
||||
? l10n.broadcastToAllNearby
|
||||
: channel.publicKeyShort,
|
||||
monospace: !channel.isPublicChannel,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
onPressed: onClose,
|
||||
tooltip: l10n.close,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 12.0;
|
||||
const minTileWidth = 132.0;
|
||||
final actionCount = actions.length;
|
||||
final maxColumnsByWidth =
|
||||
((constraints.maxWidth + spacing) /
|
||||
(minTileWidth + spacing))
|
||||
.floor()
|
||||
.clamp(1, 3);
|
||||
var columnCount = actionCount.clamp(1, maxColumnsByWidth);
|
||||
if (actionCount > 3 &&
|
||||
columnCount > 2 &&
|
||||
actionCount % columnCount == 1) {
|
||||
columnCount -= 1;
|
||||
}
|
||||
final itemWidth =
|
||||
(constraints.maxWidth - (spacing * (columnCount - 1))) /
|
||||
columnCount;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
for (final action in actions)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: _ChannelPrimaryActionButton(action: action),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelPrimaryActionButton extends StatelessWidget {
|
||||
final _ChannelSheetAction action;
|
||||
|
||||
const _ChannelPrimaryActionButton({required this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final accent = action.destructive ? colorScheme.error : colorScheme.primary;
|
||||
final backgroundColor = action.destructive
|
||||
? colorScheme.errorContainer.withValues(alpha: 0.82)
|
||||
: Color.alphaBlend(
|
||||
accent.withValues(alpha: 0.12),
|
||||
colorScheme.surfaceContainerLow,
|
||||
);
|
||||
final foregroundColor = action.destructive
|
||||
? colorScheme.onErrorContainer
|
||||
: accent;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: action.onTap,
|
||||
child: Ink(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: action.destructive
|
||||
? colorScheme.error.withValues(alpha: 0.18)
|
||||
: accent.withValues(alpha: 0.14),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: foregroundColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Icon(action.icon, color: foregroundColor, size: 16),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
action.label,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: action.destructive
|
||||
? colorScheme.onErrorContainer
|
||||
: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelSheetChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool monospace;
|
||||
|
||||
const _ChannelSheetChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.monospace = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: monospace ? 'monospace' : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OverflowAvatar extends StatelessWidget {
|
||||
final int count;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
||||
|
||||
enum _DiscoveryMenuAction { repeaters, sensors }
|
||||
enum _DiscoveryListFilter { all, repeaters, sensors, others }
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
@@ -23,7 +23,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
static const int _sensorAdvertType = 4;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
final Set<int> _runningDiscoveryTypes = <int>{};
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
bool _isResolvingAll = false;
|
||||
String _searchQuery = '';
|
||||
_DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all;
|
||||
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||
|
||||
@override
|
||||
@@ -34,15 +37,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
|
||||
switch (action) {
|
||||
case _DiscoveryMenuAction.repeaters:
|
||||
await _discoverNodeType(_repeaterAdvertType);
|
||||
break;
|
||||
case _DiscoveryMenuAction.sensors:
|
||||
await _discoverNodeType(_sensorAdvertType);
|
||||
break;
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _clearAllDiscoveries() async {
|
||||
@@ -80,7 +78,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)),
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,8 +293,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
};
|
||||
}
|
||||
|
||||
String? _typeLabelForAdvert(PendingAdvert advert) {
|
||||
return switch (advert.typeValue) {
|
||||
String? _typeLabelForValue(int? typeValue) {
|
||||
return switch (typeValue) {
|
||||
_repeaterAdvertType => 'Repeater',
|
||||
_sensorAdvertType => 'Sensor',
|
||||
3 => 'Room',
|
||||
@@ -303,22 +303,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
};
|
||||
}
|
||||
|
||||
String? _resolvedTypeLabelForAdvert(
|
||||
int? _resolvedTypeValueForAdvert(
|
||||
PendingAdvert advert,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
final directType = _typeLabelForAdvert(advert);
|
||||
if (directType != null) {
|
||||
final directType = advert.typeValue;
|
||||
if (directType != null && directType != 0) {
|
||||
return directType;
|
||||
}
|
||||
|
||||
for (final node in cachedNodes) {
|
||||
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||
return switch (node.type) {
|
||||
1 => 'Repeater',
|
||||
4 => 'Sensor',
|
||||
3 => 'Room',
|
||||
2 => 'Chat',
|
||||
1 => _repeaterAdvertType,
|
||||
4 => _sensorAdvertType,
|
||||
3 => 3,
|
||||
2 => 1,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -327,6 +327,81 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _resolvedTypeLabelForAdvert(
|
||||
PendingAdvert advert,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
return _typeLabelForValue(_resolvedTypeValueForAdvert(advert, cachedNodes));
|
||||
}
|
||||
|
||||
bool get _hasActiveInlineFilter =>
|
||||
_searchQuery.trim().isNotEmpty ||
|
||||
_selectedFilter != _DiscoveryListFilter.all;
|
||||
|
||||
bool _matchesSelectedFilter(
|
||||
PendingAdvert advert,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
if (_selectedFilter == _DiscoveryListFilter.all) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final resolvedType = _resolvedTypeValueForAdvert(advert, cachedNodes);
|
||||
return switch (_selectedFilter) {
|
||||
_DiscoveryListFilter.all => true,
|
||||
_DiscoveryListFilter.repeaters => resolvedType == _repeaterAdvertType,
|
||||
_DiscoveryListFilter.sensors => resolvedType == _sensorAdvertType,
|
||||
_DiscoveryListFilter.others =>
|
||||
resolvedType != _repeaterAdvertType &&
|
||||
resolvedType != _sensorAdvertType,
|
||||
};
|
||||
}
|
||||
|
||||
bool _matchesSearchQuery(
|
||||
PendingAdvert advert,
|
||||
ContactsProvider contactsProvider,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
final query = _searchQuery.trim().toLowerCase();
|
||||
if (query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final displayName = _displayNameForAdvert(
|
||||
advert,
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
).toLowerCase();
|
||||
final typeLabel = (_resolvedTypeLabelForAdvert(advert, cachedNodes) ?? '')
|
||||
.toLowerCase();
|
||||
|
||||
return displayName.contains(query) ||
|
||||
advert.publicKeyHex.toLowerCase().contains(query) ||
|
||||
advert.shortDisplayKey.toLowerCase().contains(query) ||
|
||||
typeLabel.contains(query);
|
||||
}
|
||||
|
||||
List<PendingAdvert> _filteredPendingAdverts(
|
||||
List<PendingAdvert> adverts,
|
||||
ContactsProvider contactsProvider,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
return adverts
|
||||
.where((advert) => _matchesSelectedFilter(advert, cachedNodes))
|
||||
.where(
|
||||
(advert) =>
|
||||
_matchesSearchQuery(advert, contactsProvider, cachedNodes),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String _summaryTitle({required int totalCount, required int filteredCount}) {
|
||||
if (filteredCount == totalCount) {
|
||||
return 'Discovered nodes ($totalCount)';
|
||||
}
|
||||
return 'Discovered nodes ($filteredCount/$totalCount)';
|
||||
}
|
||||
|
||||
Widget _buildAdvertTitle(
|
||||
BuildContext context, {
|
||||
required String displayName,
|
||||
@@ -362,193 +437,236 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.discovery),
|
||||
actions: [
|
||||
Consumer<ConnectionProvider>(
|
||||
builder: (context, connectionProvider, child) {
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||
_repeaterAdvertType,
|
||||
);
|
||||
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||
_sensorAdvertType,
|
||||
);
|
||||
|
||||
return PopupMenuButton<_DiscoveryMenuAction>(
|
||||
tooltip: 'Discovery tools',
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<_DiscoveryMenuAction>(
|
||||
value: _DiscoveryMenuAction.repeaters,
|
||||
enabled: isConnected && !repeatersBusy,
|
||||
child: Row(
|
||||
children: [
|
||||
repeatersBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.router_outlined),
|
||||
SizedBox(width: 12),
|
||||
Text(l10n.discoverRepeaters),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<_DiscoveryMenuAction>(
|
||||
value: _DiscoveryMenuAction.sensors,
|
||||
enabled: isConnected && !sensorsBusy,
|
||||
child: Row(
|
||||
children: [
|
||||
sensorsBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.sensors_outlined),
|
||||
SizedBox(width: 12),
|
||||
Text(l10n.discoverSensors),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(title: Text(l10n.discovery)),
|
||||
body: FutureBuilder<List<MeshMapNode>>(
|
||||
future: _cachedNodesFuture,
|
||||
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
|
||||
builder: (context, contactsProvider, connectionProvider, child) {
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||
builder: (context, nodesSnapshot) =>
|
||||
Consumer2<ContactsProvider, ConnectionProvider>(
|
||||
builder: (context, contactsProvider, connectionProvider, child) {
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||
_repeaterAdvertType,
|
||||
);
|
||||
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||
_sensorAdvertType,
|
||||
);
|
||||
|
||||
// Track which pending adverts are already in the contacts list
|
||||
final resolvedKeySet = <String>{};
|
||||
for (final advert in pendingAdverts) {
|
||||
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
||||
resolvedKeySet.add(advert.publicKeyHex);
|
||||
}
|
||||
}
|
||||
// Track which pending adverts are already in the contacts list
|
||||
final resolvedKeySet = <String>{};
|
||||
for (final advert in pendingAdverts) {
|
||||
if (contactsProvider.findContactByKey(advert.publicKey) !=
|
||||
null) {
|
||||
resolvedKeySet.add(advert.publicKeyHex);
|
||||
}
|
||||
}
|
||||
|
||||
final totalCount = pendingAdverts.length;
|
||||
final totalCount = pendingAdverts.length;
|
||||
final filteredAdverts = _filteredPendingAdverts(
|
||||
pendingAdverts,
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
);
|
||||
final filteredCount = filteredAdverts.length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Discovered nodes ($totalCount)',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_summaryTitle(
|
||||
totalCount: totalCount,
|
||||
filteredCount: filteredCount,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (totalCount > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildInlineSearchField(context),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildTypeFilterChip(
|
||||
context,
|
||||
label: l10n.all,
|
||||
filter: _DiscoveryListFilter.all,
|
||||
),
|
||||
_buildTypeFilterChip(
|
||||
context,
|
||||
label: l10n.repeatersFilter,
|
||||
filter: _DiscoveryListFilter.repeaters,
|
||||
),
|
||||
_buildTypeFilterChip(
|
||||
context,
|
||||
label: l10n.sensors,
|
||||
filter: _DiscoveryListFilter.sensors,
|
||||
),
|
||||
_buildTypeFilterChip(
|
||||
context,
|
||||
label: l10n.others,
|
||||
filter: _DiscoveryListFilter.others,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const spacing = 10.0;
|
||||
final useTwoColumns =
|
||||
constraints.maxWidth >= 360;
|
||||
final buttonWidth = useTwoColumns
|
||||
? (constraints.maxWidth - spacing) / 2
|
||||
: constraints.maxWidth;
|
||||
|
||||
return Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: [
|
||||
_buildHeaderActionButton(
|
||||
width: buttonWidth,
|
||||
onPressed: isConnected && !repeatersBusy
|
||||
? () => _discoverNodeType(
|
||||
_repeaterAdvertType,
|
||||
)
|
||||
: null,
|
||||
icon: repeatersBusy
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.router_outlined),
|
||||
label: Text(l10n.discoverRepeaters),
|
||||
),
|
||||
_buildHeaderActionButton(
|
||||
width: buttonWidth,
|
||||
onPressed: isConnected && !sensorsBusy
|
||||
? () => _discoverNodeType(
|
||||
_sensorAdvertType,
|
||||
)
|
||||
: null,
|
||||
icon: sensorsBusy
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.sensors_outlined),
|
||||
label: Text(l10n.discoverSensors),
|
||||
),
|
||||
_buildHeaderActionButton(
|
||||
width: buttonWidth,
|
||||
onPressed:
|
||||
isConnected &&
|
||||
pendingAdverts.isNotEmpty &&
|
||||
!_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons
|
||||
.download_for_offline_outlined,
|
||||
),
|
||||
label: Text(l10n.resolveAll),
|
||||
),
|
||||
_buildHeaderActionButton(
|
||||
width: buttonWidth,
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
label: Text(l10n.clearAllLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (totalCount == 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed:
|
||||
isConnected &&
|
||||
pendingAdverts.isNotEmpty &&
|
||||
!_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.download_for_offline_outlined,
|
||||
),
|
||||
label: Text(l10n.resolveAll),
|
||||
),
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: Icon(Icons.clear_all_rounded),
|
||||
label: Text(l10n.clearAllLabel),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No discovered nodes',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Use the discovery actions above to find repeaters and sensors on the mesh.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_hasActiveInlineFilter &&
|
||||
totalCount > 0 &&
|
||||
filteredCount == 0)
|
||||
_buildNoFilterResults(context),
|
||||
...filteredAdverts.map(
|
||||
(advert) => _buildPendingAdvertCard(
|
||||
context,
|
||||
advert: advert,
|
||||
contactsProvider: contactsProvider,
|
||||
isConnected: isConnected,
|
||||
isResolved: resolvedKeySet.contains(
|
||||
advert.publicKeyHex,
|
||||
),
|
||||
cachedNodes: cachedNodes,
|
||||
l10n: l10n,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (totalCount == 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No discovered nodes',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Use the menu to discover repeaters and sensors on the mesh.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...pendingAdverts.map((advert) =>
|
||||
_buildPendingAdvertCard(
|
||||
context,
|
||||
advert: advert,
|
||||
contactsProvider: contactsProvider,
|
||||
isConnected: isConnected,
|
||||
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
|
||||
cachedNodes: cachedNodes,
|
||||
l10n: l10n,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -563,7 +681,11 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
required AppLocalizations l10n,
|
||||
}) {
|
||||
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
|
||||
final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes);
|
||||
final displayName = _displayNameForAdvert(
|
||||
advert,
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
);
|
||||
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
|
||||
final downMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.rxRssiDbm,
|
||||
@@ -652,6 +774,180 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInlineSearchField(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final hasFilter = _searchQuery.trim().isNotEmpty;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: hasFilter
|
||||
? colorScheme.primary.withValues(alpha: 0.38)
|
||||
: colorScheme.outline.withValues(alpha: 0.32),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.025),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
height: 42,
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12, right: 8),
|
||||
child: Icon(
|
||||
Icons.search_rounded,
|
||||
size: 17,
|
||||
color: hasFilter
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
cursorColor: colorScheme.primary,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search discovered nodes',
|
||||
hintStyle: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasFilter)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Material(
|
||||
color: colorScheme.primary.withValues(alpha: 0.10),
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 14,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTypeFilterChip(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
required _DiscoveryListFilter filter,
|
||||
}) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final selected = _selectedFilter == filter;
|
||||
final color = selected ? scheme.primary : scheme.outline;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (_selectedFilter == filter) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_selectedFilter = filter;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: selected ? 0.14 : 0.08),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: selected ? 0.45 : 0.22),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoFilterResults(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
child: Text(
|
||||
'No matches',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderActionButton({
|
||||
required double width,
|
||||
required VoidCallback? onPressed,
|
||||
required Widget icon,
|
||||
required Widget label,
|
||||
}) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
label: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignalSummary(
|
||||
BuildContext context, {
|
||||
SignalMetric? downMetric,
|
||||
|
||||
@@ -48,6 +48,110 @@ class MessagesTab extends StatefulWidget {
|
||||
State<MessagesTab> createState() => _MessagesTabState();
|
||||
}
|
||||
|
||||
class _ComposerActionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ComposerActionTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.color,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final effectiveColor = enabled
|
||||
? color
|
||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.45);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
enabled
|
||||
? colorScheme.surfaceContainerLow
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
enabled
|
||||
? effectiveColor.withValues(alpha: 0.08)
|
||||
: colorScheme.surfaceContainerHigh,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: enabled
|
||||
? effectiveColor.withValues(alpha: 0.16)
|
||||
: colorScheme.outlineVariant.withValues(alpha: 0.16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: effectiveColor.withValues(alpha: enabled ? 0.08 : 0.03),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(icon, color: effectiveColor, size: 20),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.2,
|
||||
color: enabled
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessagesTabState extends State<MessagesTab> {
|
||||
static const int _maxContactMessageBytes = 156;
|
||||
static const int _maxChannelMessageBytes = 127;
|
||||
@@ -1468,81 +1572,207 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
void _showComposerActions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
Future<void> runAction(Future<void> Function() action) async {
|
||||
await _runAfterSheetDismissal(sheetContext, action);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.search),
|
||||
title: Text(AppLocalizations.of(context)!.searchMessages),
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
_showFilteredMessageSearch();
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.add_location_alt),
|
||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
_showSarDialog();
|
||||
});
|
||||
},
|
||||
),
|
||||
if (_voiceSupported)
|
||||
ListTile(
|
||||
enabled: !_isSendingVoice,
|
||||
leading: Icon(_isRecording ? Icons.stop : Icons.mic),
|
||||
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
|
||||
onTap: _isSendingVoice
|
||||
? null
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
if (_isRecording) {
|
||||
await _stopAndSendVoice();
|
||||
} else {
|
||||
await _startVoiceRecording();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||
),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
colorScheme.surface,
|
||||
colorScheme.surfaceContainerLow,
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
enabled: !_isSendingImage,
|
||||
leading: Icon(Icons.photo_library),
|
||||
title: Text(AppLocalizations.of(context)!.sendImageFromGallery),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _pickAndSendImage(source: ImageSource.gallery);
|
||||
});
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(32),
|
||||
bottom: Radius.circular(28),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.14),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'More actions',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Search, share, or start something from this chat.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.pop(sheetContext),
|
||||
tooltip: l10n.close,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final actions = <Widget>[
|
||||
_ComposerActionTile(
|
||||
icon: Icons.search_rounded,
|
||||
title: l10n.searchMessages,
|
||||
subtitle: 'Find text in the current conversation',
|
||||
color: const Color(0xFF2B6CB0),
|
||||
onTap: () => runAction(() async {
|
||||
_showFilteredMessageSearch();
|
||||
}),
|
||||
),
|
||||
_ComposerActionTile(
|
||||
icon: Icons.add_location_alt_rounded,
|
||||
title: l10n.sendSarMarker,
|
||||
subtitle: 'Share a marker with coordinates',
|
||||
color: const Color(0xFFB45309),
|
||||
onTap: () => runAction(() async {
|
||||
_showSarDialog();
|
||||
}),
|
||||
),
|
||||
if (_voiceSupported)
|
||||
_ComposerActionTile(
|
||||
icon: _isRecording
|
||||
? Icons.stop_rounded
|
||||
: Icons.mic_rounded,
|
||||
title: _isRecording
|
||||
? 'Stop recording'
|
||||
: 'Record voice',
|
||||
subtitle: _isSendingVoice
|
||||
? 'Voice message is sending'
|
||||
: _isRecording
|
||||
? 'Finish and send your clip'
|
||||
: 'Capture and send a voice note',
|
||||
color: const Color(0xFF7C3AED),
|
||||
enabled: !_isSendingVoice,
|
||||
onTap: !_isSendingVoice
|
||||
? () => runAction(() async {
|
||||
if (_isRecording) {
|
||||
await _stopAndSendVoice();
|
||||
} else {
|
||||
await _startVoiceRecording();
|
||||
}
|
||||
})
|
||||
: null,
|
||||
),
|
||||
_ComposerActionTile(
|
||||
icon: Icons.photo_library_rounded,
|
||||
title: l10n.sendImageFromGallery,
|
||||
subtitle: 'Choose an image from your library',
|
||||
color: const Color(0xFF0F766E),
|
||||
enabled: !_isSendingImage,
|
||||
onTap: !_isSendingImage
|
||||
? () => runAction(() async {
|
||||
await _pickAndSendImage(
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
),
|
||||
_ComposerActionTile(
|
||||
icon: Icons.camera_alt_rounded,
|
||||
title: l10n.takePhoto,
|
||||
subtitle: 'Capture something right now',
|
||||
color: const Color(0xFF2563EB),
|
||||
enabled: !_isSendingImage,
|
||||
onTap: !_isSendingImage
|
||||
? () => runAction(() async {
|
||||
await _pickAndSendImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
),
|
||||
_ComposerActionTile(
|
||||
icon: Icons.grid_3x3_rounded,
|
||||
title: l10n.startTictactoe,
|
||||
subtitle: l10n.dmOnly,
|
||||
color: const Color(0xFFBE185D),
|
||||
onTap: () => runAction(() async {
|
||||
await _startTicTacToeGame();
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: actions.length,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisExtent: 126,
|
||||
),
|
||||
itemBuilder: (context, index) => actions[index],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
enabled: !_isSendingImage,
|
||||
leading: Icon(Icons.camera_alt),
|
||||
title: Text(AppLocalizations.of(context)!.takePhoto),
|
||||
onTap: _isSendingImage
|
||||
? null
|
||||
: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _pickAndSendImage(source: ImageSource.camera);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.grid_3x3),
|
||||
title: Text(AppLocalizations.of(context)!.startTictactoe),
|
||||
subtitle: Text(AppLocalizations.of(context)!.dmOnly),
|
||||
onTap: () async {
|
||||
await _runAfterSheetDismissal(sheetContext, () async {
|
||||
await _startTicTacToeGame();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -105,74 +105,120 @@ class _SensorsTabState extends State<SensorsTab> {
|
||||
contactsProvider,
|
||||
connectionProvider: context.read<ConnectionProvider>(),
|
||||
);
|
||||
final searchController = TextEditingController();
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
if (candidates.isEmpty) {
|
||||
return const SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No eligible nodes available. Discover a relay or node first.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Add sensor node',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pickARelayOrNodeToWatchInSensors,
|
||||
try {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
if (candidates.isEmpty) {
|
||||
return const SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No eligible nodes available. Discover a relay or node first.',
|
||||
),
|
||||
),
|
||||
...candidates.map(
|
||||
(contact) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 4,
|
||||
);
|
||||
}
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (context, setModalState) {
|
||||
final query = searchController.text.trim().toLowerCase();
|
||||
final filteredCandidates = candidates.where((contact) {
|
||||
if (query.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
return contact.displayName.toLowerCase().contains(query) ||
|
||||
contact.publicKeyHex.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: const Color(0xFFDDEAF8),
|
||||
child: Icon(
|
||||
_typeIcon(contact),
|
||||
color: const Color(0xFF1E4F7A),
|
||||
),
|
||||
),
|
||||
title: Text(contact.displayName),
|
||||
subtitle: _SensorCandidatePreview(contact: contact),
|
||||
isThreeLine: true,
|
||||
onTap: () async {
|
||||
await sensorsProvider.addSensor(contact);
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${contact.displayName} added to Sensors',
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Add sensor node',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.pickARelayOrNodeToWatchInSensors,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: (_) => setModalState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
hintText: 'Search sensors',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (filteredCandidates.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
child: Text(
|
||||
'No sensor candidates match your search.',
|
||||
),
|
||||
),
|
||||
...filteredCandidates.map(
|
||||
(contact) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: const Color(0xFFDDEAF8),
|
||||
child: Icon(
|
||||
_typeIcon(contact),
|
||||
color: const Color(0xFF1E4F7A),
|
||||
),
|
||||
),
|
||||
title: Text(contact.displayName),
|
||||
subtitle: _SensorCandidatePreview(contact: contact),
|
||||
isThreeLine: true,
|
||||
onTap: () async {
|
||||
await sensorsProvider.addSensor(contact);
|
||||
if (!sheetContext.mounted) return;
|
||||
Navigator.of(sheetContext).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${contact.displayName} added to Sensors',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
searchController.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showMetricSelector(
|
||||
@@ -292,14 +338,21 @@ class _SensorsTabState extends State<SensorsTab> {
|
||||
connectionProvider,
|
||||
child,
|
||||
) {
|
||||
final displayKeys = sensorsProvider.displaySensorKeys(
|
||||
final watchedKeys = sensorsProvider.watchedSensorKeys;
|
||||
final hasPersistedSensors = watchedKeys.isNotEmpty;
|
||||
final selfDisplayKey = sensorsProvider.displaySelfKey(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
final hasPersistedSensors =
|
||||
sensorsProvider.watchedSensorKeys.isNotEmpty;
|
||||
final hasAnyCards =
|
||||
selfDisplayKey != null || watchedKeys.isNotEmpty;
|
||||
|
||||
Widget buildSensorCard(String key, int index) {
|
||||
Widget buildSensorCard(
|
||||
String key, {
|
||||
required int index,
|
||||
required int totalCount,
|
||||
required bool isWatchedCard,
|
||||
}) {
|
||||
final contact = sensorsProvider.contactForDisplay(
|
||||
key,
|
||||
contactsProvider: contactsProvider,
|
||||
@@ -314,106 +367,104 @@ class _SensorsTabState extends State<SensorsTab> {
|
||||
return Padding(
|
||||
key: ValueKey<String>('sensor_card_$key'),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == displayKeys.length - 1 ? 0 : 12,
|
||||
bottom: index == totalCount - 1 ? 0 : 12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SensorTelemetryCard(
|
||||
contact: contact,
|
||||
state: sensorsProvider.stateFor(key),
|
||||
visibleFields: visibleFields,
|
||||
fieldOrder: sensorsProvider.metricOrderFor(
|
||||
key,
|
||||
availableFieldKeys,
|
||||
),
|
||||
labelOverrides: sensorsProvider.labelOverridesFor(
|
||||
key,
|
||||
),
|
||||
fieldSpans: {
|
||||
for (final field in visibleFields)
|
||||
field: sensorsProvider.fieldSpanFor(key, field),
|
||||
},
|
||||
onRemove: hasPersistedSensors
|
||||
? () async {
|
||||
await sensorsProvider.removeSensor(key);
|
||||
}
|
||||
: null,
|
||||
onCustomize: () =>
|
||||
_showMetricSelector(context, key, contact),
|
||||
onShowMetHistory: (contact) =>
|
||||
showBTHomeMetHistorySheet(
|
||||
context,
|
||||
contact: contact,
|
||||
),
|
||||
onRefresh: () => sensorsProvider.refreshSensor(
|
||||
publicKeyHex: key,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
),
|
||||
),
|
||||
child: ReorderableDelayedDragStartListener(
|
||||
enabled: isWatchedCard,
|
||||
index: index,
|
||||
child: SensorTelemetryCard(
|
||||
contact: contact,
|
||||
state: sensorsProvider.stateFor(key),
|
||||
visibleFields: visibleFields,
|
||||
fieldOrder: sensorsProvider.metricOrderFor(
|
||||
key,
|
||||
availableFieldKeys,
|
||||
),
|
||||
if (hasPersistedSensors) ...[
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Tooltip(
|
||||
message: 'Move card',
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
labelOverrides: sensorsProvider.labelOverridesFor(key),
|
||||
fieldSpans: {
|
||||
for (final field in visibleFields)
|
||||
field: sensorsProvider.fieldSpanFor(key, field),
|
||||
},
|
||||
onRemove: isWatchedCard
|
||||
? () async {
|
||||
await sensorsProvider.removeSensor(key);
|
||||
}
|
||||
: null,
|
||||
onCustomize: () =>
|
||||
_showMetricSelector(context, key, contact),
|
||||
onShowMetHistory: (contact) =>
|
||||
showBTHomeMetHistorySheet(context, contact: contact),
|
||||
onMoveUp: isWatchedCard && index > 0
|
||||
? () =>
|
||||
sensorsProvider.reorderSensors(index, index - 1)
|
||||
: null,
|
||||
onMoveDown: isWatchedCard && index < totalCount - 1
|
||||
? () =>
|
||||
sensorsProvider.reorderSensors(index, index + 2)
|
||||
: null,
|
||||
onRefresh: () => sensorsProvider.refreshSensor(
|
||||
publicKeyHex: key,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _refreshAll(context),
|
||||
child: displayKeys.isEmpty
|
||||
child: !hasAnyCards
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: const [_EmptySensorsState()],
|
||||
)
|
||||
: hasPersistedSensors
|
||||
: hasPersistedSensors && selfDisplayKey == null
|
||||
? ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
itemCount: displayKeys.length,
|
||||
itemCount: watchedKeys.length,
|
||||
onReorder: (oldIndex, newIndex) =>
|
||||
sensorsProvider.reorderSensors(oldIndex, newIndex),
|
||||
itemBuilder: (context, index) =>
|
||||
buildSensorCard(displayKeys[index], index),
|
||||
itemBuilder: (context, index) => buildSensorCard(
|
||||
watchedKeys[index],
|
||||
index: index,
|
||||
totalCount: watchedKeys.length,
|
||||
isWatchedCard: true,
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
for (var i = 0; i < displayKeys.length; i++)
|
||||
buildSensorCard(displayKeys[i], i),
|
||||
if (selfDisplayKey != null)
|
||||
buildSensorCard(
|
||||
selfDisplayKey,
|
||||
index: 0,
|
||||
totalCount: watchedKeys.isEmpty ? 1 : 2,
|
||||
isWatchedCard: false,
|
||||
),
|
||||
if (selfDisplayKey != null && watchedKeys.isEmpty)
|
||||
_SelfOnlySensorsCta(
|
||||
onAddSensor: () => _showAddSensorSheet(context),
|
||||
),
|
||||
if (watchedKeys.isNotEmpty)
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: watchedKeys.length,
|
||||
onReorder: (oldIndex, newIndex) => sensorsProvider
|
||||
.reorderSensors(oldIndex, newIndex),
|
||||
itemBuilder: (context, index) => buildSensorCard(
|
||||
watchedKeys[index],
|
||||
index: index,
|
||||
totalCount: watchedKeys.length,
|
||||
isWatchedCard: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1011,6 +1062,81 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> {
|
||||
}
|
||||
}
|
||||
|
||||
class _SelfOnlySensorsCta extends StatelessWidget {
|
||||
final VoidCallback onAddSensor;
|
||||
|
||||
const _SelfOnlySensorsCta({required this.onAddSensor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.add_chart_rounded,
|
||||
color: theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Add another device',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Bring in weather stations, repeaters, or other devices to watch them here.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FilledButton.icon(
|
||||
onPressed: onAddSensor,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('Choose'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _typeIcon(Contact contact) {
|
||||
if (contact.isSensor) {
|
||||
return Icons.sensors;
|
||||
|
||||
Reference in New Issue
Block a user