Display advertisements

This commit is contained in:
Foti Dim
2026-02-07 13:01:32 +01:00
parent 9df3da17ae
commit fc6ae39a53
7 changed files with 448 additions and 103 deletions
+1
View File
@@ -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
+6 -5
View File
@@ -35,6 +35,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
AvailabilityState? bleAvailabilityState;
ScanFilter? scanFilter;
final Map<String, bool> _isExpanded = {};
final Map<String, int> _deviceAdFlashTrigger = {};
@override
void initState() {
@@ -85,6 +86,8 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
_bleDevices[index] = result;
}
_deviceAdFlashTrigger[result.deviceId] =
(_deviceAdFlashTrigger[result.deviceId] ?? 0) + 1;
setState(() {});
}
@@ -823,6 +826,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
_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<ScannerScreen> {
PeripheralDetailPage(device),
),
);
// Stop scan but keep results visible
UniversalBle.stopScan();
setState(() {
_isScanning = false;
});
},
);
},
@@ -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<ManufacturerData> 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 {
),
),
),
),
);
}
}
@@ -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<PeripheralDetailPage> {
bool _isDiscoveringServices = false;
bool _isDeviceInfoExpanded = false;
bool _isDeviceActionsExpanded = true;
bool _isReceivedAdvertisementsExpanded = true;
final Map<String, bool> _subscribedCharacteristics = {};
bool _autoConnect = false;
StreamSubscription? connectionStreamSubscription;
StreamSubscription? pairingStateSubscription;
StreamSubscription<BleDevice>? _advertisementSubscription;
List<BleDevice> _receivedAdvertisements = [];
BleService? selectedService;
BleCharacteristic? selectedCharacteristic;
final ScrollController _logsScrollController = ScrollController();
@@ -62,6 +66,17 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
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<PeripheralDetailPage> {
@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<PeripheralDetailPage> {
// 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<PeripheralDetailPage> {
);
}
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<void> _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(
@@ -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,
),
),
],
],
);
}
}
@@ -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<String> 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',
),
);
}
@@ -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<double>(
key: ValueKey(trigger),
tween: Tween<double>(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,
);
}
}