Display advertisements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user