From fc6ae39a53350761ad4568738d43246eddfb2bd3 Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Sat, 7 Feb 2026 13:01:32 +0100 Subject: [PATCH] Display advertisements --- example/CHANGELOG.md | 1 + example/lib/home/scanner_screen.dart | 11 +- .../lib/home/widgets/scanned_item_widget.dart | 107 +++++++--- .../peripheral_detail_page.dart | 196 +++++++++++++++++- .../widgets/result_header_bar.dart | 106 ++++++++++ .../widgets/result_widget.dart | 81 +------- .../lib/widgets/flash_on_update_widget.dart | 49 +++++ 7 files changed, 448 insertions(+), 103 deletions(-) create mode 100644 example/lib/peripheral_details/widgets/result_header_bar.dart create mode 100644 example/lib/widgets/flash_on_update_widget.dart diff --git a/example/CHANGELOG.md b/example/CHANGELOG.md index f45df10..9201a00 100644 --- a/example/CHANGELOG.md +++ b/example/CHANGELOG.md @@ -16,6 +16,7 @@ * Improved scan button visibility - converted to prominent filled button with text label * Enhanced "no devices found" state with explicit "Start Scan" call-to-action button * Display RSSI values in device details + * Show last advertisement (name, RSSI, timestamp) on scan screen and list of received ads on detail screen; both flash on new advertisement. Scanning continues when opening a device. * **UI & Navigation:** * Moved search field to app bar header for better accessibility diff --git a/example/lib/home/scanner_screen.dart b/example/lib/home/scanner_screen.dart index ae52dc6..8aeb358 100644 --- a/example/lib/home/scanner_screen.dart +++ b/example/lib/home/scanner_screen.dart @@ -35,6 +35,7 @@ class _ScannerScreenState extends State { AvailabilityState? bleAvailabilityState; ScanFilter? scanFilter; final Map _isExpanded = {}; + final Map _deviceAdFlashTrigger = {}; @override void initState() { @@ -85,6 +86,8 @@ class _ScannerScreenState extends State { } _bleDevices[index] = result; } + _deviceAdFlashTrigger[result.deviceId] = + (_deviceAdFlashTrigger[result.deviceId] ?? 0) + 1; setState(() {}); } @@ -823,6 +826,9 @@ class _ScannerScreenState extends State { _filteredDevices.length - index - 1]; return ScannedItemWidget( bleDevice: device, + adFlashTrigger: + _deviceAdFlashTrigger[device.deviceId] ?? + 0, isExpanded: _isExpanded[device.deviceId] ?? false, onExpand: (isExpanded) { @@ -839,11 +845,6 @@ class _ScannerScreenState extends State { PeripheralDetailPage(device), ), ); - // Stop scan but keep results visible - UniversalBle.stopScan(); - setState(() { - _isScanning = false; - }); }, ); }, diff --git a/example/lib/home/widgets/scanned_item_widget.dart b/example/lib/home/widgets/scanned_item_widget.dart index b481acf..d7b6701 100644 --- a/example/lib/home/widgets/scanned_item_widget.dart +++ b/example/lib/home/widgets/scanned_item_widget.dart @@ -4,20 +4,42 @@ import 'package:universal_ble/universal_ble.dart'; import 'package:universal_ble_example/data/company_identifier_service.dart'; import 'package:universal_ble_example/home/widgets/rssi_signal_indicator.dart'; import 'package:universal_ble_example/widgets/company_info_widget.dart'; +import 'package:universal_ble_example/widgets/flash_on_update_widget.dart'; class ScannedItemWidget extends StatelessWidget { final BleDevice bleDevice; + final int adFlashTrigger; final VoidCallback? onTap; final bool isExpanded; final Function(bool) onExpand; const ScannedItemWidget({ super.key, required this.bleDevice, + this.adFlashTrigger = 0, this.onTap, required this.isExpanded, required this.onExpand, }); + static String _formatAdTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } + + static String _advertisementPayload(BleDevice device) { + if (device.manufacturerDataList.isNotEmpty) { + final hex = device.manufacturerDataList.first.payloadRadix16; + return hex.length > 24 ? '${hex.substring(0, 24)}…' : hex; + } + if (device.serviceData.isNotEmpty) { + final entry = device.serviceData.entries.first; + final hex = '0x${entry.value.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase()}'; + return hex.length > 24 ? '${hex.substring(0, 24)}…' : hex; + } + return '—'; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -27,33 +49,40 @@ class ScannedItemWidget extends StatelessWidget { List rawManufacturerData = bleDevice.manufacturerDataList; if (name == null || name.isEmpty) name = 'Unknown Device'; - return Card( - elevation: 2, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Signal indicator - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: - colorScheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), + final adTimeStr = bleDevice.timestampDateTime != null + ? _formatAdTime(bleDevice.timestampDateTime!) + : '—'; + final payloadStr = _advertisementPayload(bleDevice); + + return FlashOnUpdateWidget( + trigger: adFlashTrigger, + child: Card( + elevation: 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Signal indicator + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + colorScheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + ), + child: RssiSignalIndicator(rssi: bleDevice.rssi ?? 0), ), - child: RssiSignalIndicator(rssi: bleDevice.rssi ?? 0), - ), const SizedBox(width: 16), // Device info Expanded( @@ -134,6 +163,31 @@ class ScannedItemWidget extends StatelessWidget { ], ), const SizedBox(height: 6), + // Last advertisement (timestamp + payload only) + Row( + children: [ + Icon( + Icons.ad_units_outlined, + size: 12, + color: colorScheme.primary + .withValues(alpha: 0.8), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Last ad: $adTimeStr · $payloadStr', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: colorScheme.onSurface + .withValues(alpha: 0.7), + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), Wrap( spacing: 4, runSpacing: 4, @@ -460,6 +514,7 @@ class ScannedItemWidget extends StatelessWidget { ), ), ), + ), ); } } diff --git a/example/lib/peripheral_details/peripheral_detail_page.dart b/example/lib/peripheral_details/peripheral_detail_page.dart index 912a33c..06ef4ca 100644 --- a/example/lib/peripheral_details/peripheral_detail_page.dart +++ b/example/lib/peripheral_details/peripheral_detail_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:universal_ble/universal_ble.dart'; import 'package:universal_ble_example/data/storage_service.dart'; import 'package:universal_ble_example/data/utils.dart'; +import 'package:universal_ble_example/peripheral_details/widgets/result_header_bar.dart'; import 'package:universal_ble_example/peripheral_details/widgets/result_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_list_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_side_widget.dart'; @@ -34,11 +35,14 @@ class _PeripheralDetailPageState extends State { bool _isDiscoveringServices = false; bool _isDeviceInfoExpanded = false; bool _isDeviceActionsExpanded = true; + bool _isReceivedAdvertisementsExpanded = true; final Map _subscribedCharacteristics = {}; bool _autoConnect = false; StreamSubscription? connectionStreamSubscription; StreamSubscription? pairingStateSubscription; + StreamSubscription? _advertisementSubscription; + List _receivedAdvertisements = []; BleService? selectedService; BleCharacteristic? selectedCharacteristic; final ScrollController _logsScrollController = ScrollController(); @@ -62,6 +66,17 @@ class _PeripheralDetailPageState extends State { void initState() { super.initState(); + _receivedAdvertisements = [bleDevice]; + _advertisementSubscription = UniversalBle.scanStream + .where((d) => d.deviceId == bleDevice.deviceId) + .listen((d) { + if (mounted) { + setState(() { + _receivedAdvertisements.add(d); + }); + } + }); + connectionStreamSubscription = bleDevice.connectionStream.listen( _handleConnectionChange, ); @@ -78,10 +93,11 @@ class _PeripheralDetailPageState extends State { @override void dispose() { - super.dispose(); + _advertisementSubscription?.cancel(); connectionStreamSubscription?.cancel(); pairingStateSubscription?.cancel(); UniversalBle.onValueChange = null; + super.dispose(); } void _addLog(String type, dynamic data) { @@ -618,6 +634,9 @@ class _PeripheralDetailPageState extends State { // Device info with manufacturer data and advertised services _buildDeviceInfo(), + // Received advertisements list + _buildReceivedAdvertisements(), + // Connect/Disconnect button _buildConnectDisconnectButton(), @@ -1300,6 +1319,181 @@ class _PeripheralDetailPageState extends State { ); } + String _formatAdTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } + + static String _advertisementPayload(BleDevice device) { + if (device.manufacturerDataList.isNotEmpty) { + return device.manufacturerDataList.first.payloadRadix16; + } + if (device.serviceData.isNotEmpty) { + final entry = device.serviceData.entries.first; + return '0x${entry.value.map((b) => b.toRadixString(16).padLeft(2, '0')).join('').toUpperCase()}'; + } + return '—'; + } + + Future _copyReceivedAdvertisements() async { + if (_receivedAdvertisements.isEmpty) return; + final lines = _receivedAdvertisements.reversed.map((ad) { + final ts = ad.timestampDateTime; + final timeStr = ts != null ? _formatAdTime(ts) : '—'; + final payload = _advertisementPayload(ad); + return '$timeStr RSSI: ${ad.rssi ?? "—"} $payload'; + }).toList(); + await Clipboard.setData(ClipboardData(text: lines.join('\n'))); + if (mounted) { + _showSnackBar('All advertisements copied to clipboard'); + } + } + + Widget _buildReceivedAdvertisements() { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 2.0, + ), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + initiallyExpanded: _isReceivedAdvertisementsExpanded, + onExpansionChanged: (expanded) { + setState(() { + _isReceivedAdvertisementsExpanded = expanded; + }); + }, + tilePadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + childrenPadding: const EdgeInsets.only( + left: 16, + right: 16, + bottom: 16, + ), + title: ResultHeaderBar( + icon: Icons.ad_units_outlined, + title: 'Received advertisements', + count: _receivedAdvertisements.length, + onCopy: _receivedAdvertisements.isEmpty + ? null + : _copyReceivedAdvertisements, + copyTooltip: 'Copy all advertisements', + onClear: _receivedAdvertisements.isEmpty + ? null + : () { + setState(() { + _receivedAdvertisements.clear(); + }); + }, + clearTooltip: 'Clear all advertisements', + titleFontSize: 18, + ), + trailing: Icon( + _isReceivedAdvertisementsExpanded + ? Icons.expand_less + : Icons.expand_more, + ), + children: [ + _receivedAdvertisements.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'No advertisements yet', + style: TextStyle( + fontSize: 13, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ) + : ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.separated( + shrinkWrap: true, + itemCount: _receivedAdvertisements.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final ad = _receivedAdvertisements[ + _receivedAdvertisements.length - 1 - index]; + final ts = ad.timestampDateTime; + final timeStr = ts != null + ? _formatAdTime(ts) + : '—'; + final payload = _advertisementPayload(ad); + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon( + Icons.schedule, + size: 14, + color: colorScheme.onSurface + .withValues(alpha: 0.6), + ), + const SizedBox(width: 8), + Text( + timeStr, + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: colorScheme.onSurface + .withValues(alpha: 0.8), + ), + ), + const SizedBox(width: 12), + Text( + 'RSSI: ${ad.rssi ?? "—"}', + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface + .withValues(alpha: 0.8), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + payload, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: colorScheme.onSurface + .withValues(alpha: 0.7), + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildDeviceActions() { final colorScheme = Theme.of(context).colorScheme; return Padding( diff --git a/example/lib/peripheral_details/widgets/result_header_bar.dart b/example/lib/peripheral_details/widgets/result_header_bar.dart new file mode 100644 index 0000000..afd22a2 --- /dev/null +++ b/example/lib/peripheral_details/widgets/result_header_bar.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +/// Shared header row for result-style cards (e.g. Logs, Received advertisements). +/// Layout: [icon] [title] [copy?] [Spacer] [count?] [clear?] +/// Copy, count badge, and clear button are shown only when [count] > 0. +class ResultHeaderBar extends StatelessWidget { + const ResultHeaderBar({ + super.key, + required this.icon, + required this.title, + required this.count, + this.onCopy, + this.copyTooltip = 'Copy', + this.onClear, + this.clearTooltip = 'Clear all', + this.titleFontSize = 16, + }); + + final IconData icon; + final String title; + final int count; + final VoidCallback? onCopy; + final String copyTooltip; + final VoidCallback? onClear; + final String clearTooltip; + final double titleFontSize; + + bool get _hasItems => count > 0; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + Icon( + icon, + color: colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: titleFontSize, + color: colorScheme.onSurface, + ), + ), + if (_hasItems && onCopy != null) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.copy, + color: colorScheme.primary, + size: 20, + ), + onPressed: onCopy, + tooltip: copyTooltip, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + ], + const Spacer(), + if (_hasItems) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$count', + style: TextStyle( + color: colorScheme.onPrimaryContainer, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + if (_hasItems && onClear != null) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.close, + color: colorScheme.error, + size: 20, + ), + onPressed: onClear, + tooltip: clearTooltip, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + ), + ], + ], + ); + } +} diff --git a/example/lib/peripheral_details/widgets/result_widget.dart b/example/lib/peripheral_details/widgets/result_widget.dart index 067ee13..8cbb135 100644 --- a/example/lib/peripheral_details/widgets/result_widget.dart +++ b/example/lib/peripheral_details/widgets/result_widget.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:universal_ble_example/peripheral_details/widgets/result_header_bar.dart'; + class ResultWidget extends StatelessWidget { final List results; final bool scrollable; @@ -50,77 +52,14 @@ class ResultWidget extends StatelessWidget { topRight: Radius.circular(12), ), ), - child: Row( - children: [ - Icon( - Icons.history, - color: colorScheme.primary, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'Logs', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: colorScheme.onSurface, - ), - ), - if (results.isNotEmpty) ...[ - const SizedBox(width: 8), - IconButton( - icon: Icon( - Icons.copy, - color: colorScheme.primary, - size: 20, - ), - onPressed: onCopyTap, - tooltip: 'Copy all logs', - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - ), - ], - const Spacer(), - if (results.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${results.length}', - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - if (results.isNotEmpty) ...[ - const SizedBox(width: 8), - IconButton( - icon: Icon( - Icons.close, - color: colorScheme.error, - size: 20, - ), - onPressed: () => onClearTap(null), - tooltip: 'Clear all logs', - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - ), - ], - ], + child: ResultHeaderBar( + icon: Icons.history, + title: 'Logs', + count: results.length, + onCopy: onCopyTap, + copyTooltip: 'Copy all logs', + onClear: () => onClearTap(null), + clearTooltip: 'Clear all logs', ), ); } diff --git a/example/lib/widgets/flash_on_update_widget.dart b/example/lib/widgets/flash_on_update_widget.dart new file mode 100644 index 0000000..9c69df1 --- /dev/null +++ b/example/lib/widgets/flash_on_update_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Wraps [child] and briefly highlights it when [trigger] changes. +/// Uses a semi-opaque overlay that fades to transparent over ~300ms. +class FlashOnUpdateWidget extends StatelessWidget { + const FlashOnUpdateWidget({ + super.key, + required this.trigger, + required this.child, + this.highlightColor, + }); + + /// When this value changes, the flash animation runs once. + final Object trigger; + + final Widget child; + + /// If null, [Theme.colorScheme.primaryContainer] is used with opacity. + final Color? highlightColor; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final color = highlightColor ?? + colorScheme.primaryContainer.withValues(alpha: 0.6); + + return TweenAnimationBuilder( + key: ValueKey(trigger), + tween: Tween(begin: 1, end: 0), + duration: const Duration(milliseconds: 300), + builder: (context, value, childWidget) { + return Stack( + children: [ + childWidget!, + if (value > 0) + Positioned.fill( + child: IgnorePointer( + child: Container( + color: color.withValues(alpha: value * 0.6), + ), + ), + ), + ], + ); + }, + child: child, + ); + } +}