adonna 0.2.3
adonna: ^0.2.3 copied to clipboard
A Flutter plugin that wraps the Feasycom FeasyBeacon SDK for Android and iOS, providing cross-platform BLE beacon functionality with background monitoring capabilities.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:adonna/adonna.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:io';
import 'dart:convert';
import 'dart:typed_data';
void main() {
runApp(const AdonnaExampleApp());
}
class AdonnaExampleApp extends StatelessWidget {
const AdonnaExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Adonna Example',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const AdonnaHomePage(),
);
}
}
class AdonnaHomePage extends StatefulWidget {
const AdonnaHomePage({super.key});
@override
State<AdonnaHomePage> createState() => _AdonnaHomePageState();
}
class _AdonnaHomePageState extends State<AdonnaHomePage>
with TickerProviderStateMixin {
late TabController _tabController;
bool _isScanning = false;
bool _isConnected = false;
bool _isBackgroundMonitoring = false;
String? _connectedAddress;
String? _connectedName;
String _pin = '';
Map<String, dynamic>? _deviceInfo;
// Device lists
final Map<String, Map<String, dynamic>> _scannedDevices = {};
final Map<String, Map<String, dynamic>> _favoriteDevices = {};
// Background monitoring
final TextEditingController _uuidController = TextEditingController();
final TextEditingController _majorController = TextEditingController();
final TextEditingController _thresholdController = TextEditingController();
final List<Map<String, dynamic>> _backgroundEvents = [];
// iOS background monitoring
bool _isIosBackgroundMonitoring = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_loadFavorites();
_setupStreams();
_ensurePermissions();
// Set default UUID for testing
_uuidController.text = '12345678-1234-1234-1234-123456789012';
_thresholdController.text =
'200'; // Default threshold: ≤200ms = button pressed, >200ms = normal
}
@override
void dispose() {
_tabController.dispose();
_uuidController.dispose();
_majorController.dispose();
_thresholdController.dispose();
super.dispose();
}
void _setupStreams() {
// Scan events
FeasyBeacon.scanStream.listen((event) {
if (event is Map<String, dynamic>) {
final address = event['address'] as String?;
if (address != null) {
setState(() {
_scannedDevices[address] = Map<String, dynamic>.from(event);
});
}
}
});
// Background events
FeasyBeacon.backgroundEvents.listen((event) {
if (event is Map<String, dynamic>) {
setState(() {
_backgroundEvents.insert(0, Map<String, dynamic>.from(event));
if (_backgroundEvents.length > 50) {
_backgroundEvents.removeLast();
}
});
}
});
// OTA progress
FeasyBeacon.otaProgressStream.listen((event) {
if (event is Map<String, dynamic>) {
final progress = event['progress'] as int?;
final status = event['status'] as String?;
if (progress != null && status != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('OTA: $status - $progress%')));
}
}
});
// Device info stream
FeasyBeacon.deviceInfoStream.listen((event) {
if (event is Map<String, dynamic>) {
setState(() {
_deviceInfo = Map<String, dynamic>.from(event);
});
}
});
// Packet stream
FeasyBeacon.packetStream.listen((event) {
if (event is Map<String, dynamic>) {
final data = event['data'] as List<int>?;
if (data != null) {
final hexData = data
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Packet: $hexData')));
}
}
});
}
Future<void> _ensurePermissions() async {
await Permission.bluetooth.request();
await Permission.location.request();
await Permission.notification.request();
}
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favoritesJson = prefs.getString('favorite_devices');
if (favoritesJson != null) {
final favorites = Map<String, dynamic>.from(jsonDecode(favoritesJson));
setState(() {
_favoriteDevices.clear();
favorites.forEach((key, value) {
_favoriteDevices[key] = Map<String, dynamic>.from(value);
});
});
}
}
Future<void> _saveFavorites() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('favorite_devices', jsonEncode(_favoriteDevices));
}
Future<void> _addToFavorites(
String address,
Map<String, dynamic> device,
) async {
setState(() {
_favoriteDevices[address] = Map<String, dynamic>.from(device);
});
await _saveFavorites();
}
Future<void> _removeFromFavorites(String address) async {
setState(() {
_favoriteDevices.remove(address);
});
await _saveFavorites();
}
Future<void> _toggleScan() async {
if (_isScanning) {
await FeasyBeacon.stopScan();
setState(() {
_isScanning = false;
});
} else {
await FeasyBeacon.startScan();
setState(() {
_isScanning = true;
_scannedDevices.clear();
});
}
}
Future<void> _connectToDevice(String address) async {
try {
final success = await FeasyBeacon.connect(address: address);
if (success) {
setState(() {
_isConnected = true;
_connectedAddress = address;
_connectedName = _scannedDevices[address]?['name'] ?? 'Unknown';
});
// Add to favorites automatically
if (_scannedDevices.containsKey(address)) {
await _addToFavorites(address, _scannedDevices[address]!);
}
// Stop scanning when connected
if (_isScanning) {
await _toggleScan();
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Connected successfully!')),
);
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to connect')));
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Connection error: $e')));
}
}
Future<void> _disconnect() async {
try {
await FeasyBeacon.disconnect();
setState(() {
_isConnected = false;
_connectedAddress = null;
_connectedName = null;
_deviceInfo = null;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Disconnected')));
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Disconnect error: $e')));
}
}
Future<void> _getDeviceInfo() async {
if (!_isConnected) return;
try {
final info = await FeasyBeacon.getDeviceInfo();
setState(() {
_deviceInfo = info;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Device info loaded')));
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to get device info: $e')));
}
}
Future<void> _toggleBackgroundMonitoring() async {
if (_isBackgroundMonitoring) {
await FeasyBeacon.stopBackgroundMonitoring();
setState(() {
_isBackgroundMonitoring = false;
});
} else {
// Only start background monitoring if we have favorite devices
if (_favoriteDevices.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Add devices to favorites first before starting background monitoring',
),
),
);
return;
}
final success = await FeasyBeacon.startBackgroundMonitoring(
config: {
'uuid': _uuidController.text,
'major': _majorController.text.isNotEmpty
? int.parse(_majorController.text)
: null,
'thresholdMs': int.parse(_thresholdController.text),
'favoriteAddresses': _favoriteDevices.keys
.toList(), // Pass favorite device addresses
},
);
if (success) {
setState(() {
_isBackgroundMonitoring = true;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Background monitoring started for favorite devices only',
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to start background monitoring'),
),
);
}
}
}
Future<void> _toggleIosBackgroundMonitoring() async {
if (_isIosBackgroundMonitoring) {
await FeasyBeacon.stopBackgroundMonitoring();
setState(() {
_isIosBackgroundMonitoring = false;
});
} else {
final success = await FeasyBeacon.startIosBackgroundMonitoring(
uuid: _uuidController.text,
major: _majorController.text.isNotEmpty
? int.parse(_majorController.text)
: null,
thresholdMs: int.parse(_thresholdController.text),
);
if (success) {
setState(() {
_isIosBackgroundMonitoring = true;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('iOS background monitoring started')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to start iOS background monitoring'),
),
);
}
}
}
Future<void> _triggerOta() async {
if (!_isConnected) return;
try {
// Mock OTA file for testing
final mockFile = File('mock_dfu.bin');
if (!await mockFile.exists()) {
await mockFile.writeAsBytes(List.generate(1024, (i) => i % 256));
}
final fileBytes = await mockFile.readAsBytes();
final dfuInfo = await FeasyBeacon.checkDfuFile(fileBytes);
if (dfuInfo != null) {
final success = await FeasyBeacon.startOtaUpdate(fileBytes);
if (success) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('OTA update started')));
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to start OTA update')),
);
}
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Invalid DFU file')));
}
} catch (e) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('OTA error: $e')));
}
}
Widget _buildDeviceList() {
return ListView.builder(
itemCount: _scannedDevices.length,
itemBuilder: (context, index) {
final address = _scannedDevices.keys.elementAt(index);
final device = _scannedDevices[address]!;
final isFavorite = _favoriteDevices.containsKey(address);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: Icon(
isFavorite ? Icons.favorite : Icons.bluetooth,
color: isFavorite ? Colors.red : Colors.blue,
),
title: Text(device['name'] ?? 'Unknown Device'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('MAC: $address'),
Text('RSSI: ${device['rssi'] ?? 'N/A'}'),
if (device['beaconTypes'] != null)
Text('Types: ${(device['beaconTypes'] as List).join(', ')}'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isConnected && _connectedAddress == address)
const Icon(Icons.link, color: Colors.green)
else if (!_isConnected)
IconButton(
icon: const Icon(Icons.link),
onPressed: () => _connectToDevice(address),
tooltip: 'Connect',
),
IconButton(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : null,
),
onPressed: () {
if (isFavorite) {
_removeFromFavorites(address);
} else {
_addToFavorites(address, device);
}
},
tooltip: isFavorite
? 'Remove from favorites'
: 'Add to favorites',
),
],
),
isThreeLine: true,
),
);
},
);
}
Widget _buildFavoritesList() {
return ListView.builder(
itemCount: _favoriteDevices.length,
itemBuilder: (context, index) {
final address = _favoriteDevices.keys.elementAt(index);
final device = _favoriteDevices[address]!;
return Dismissible(
key: Key(address),
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
direction: DismissDirection.endToStart,
onDismissed: (direction) => _removeFromFavorites(address),
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
leading: const Icon(Icons.favorite, color: Colors.red),
title: Text(device['name'] ?? 'Unknown Device'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('MAC: $address'),
Text('RSSI: ${device['rssi'] ?? 'N/A'}'),
if (device['beaconTypes'] != null)
Text(
'Types: ${(device['beaconTypes'] as List).join(', ')}',
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isConnected && _connectedAddress == address)
const Icon(Icons.link, color: Colors.green)
else if (!_isConnected)
IconButton(
icon: const Icon(Icons.link),
onPressed: () => _connectToDevice(address),
tooltip: 'Connect',
),
],
),
isThreeLine: true,
),
),
);
},
);
}
Widget _buildBackgroundMonitoringTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Background Monitoring Configuration',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
controller: _uuidController,
decoration: const InputDecoration(
labelText: 'iBeacon UUID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _majorController,
decoration: const InputDecoration(
labelText: 'Major (optional)',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 8),
TextField(
controller: _thresholdController,
decoration: const InputDecoration(
labelText:
'Threshold (ms) - Fast interval = button pressed',
border: OutlineInputBorder(),
helperText:
'Intervals ≤ threshold = button pressed, > threshold = normal',
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Use Wrap for better button layout on rotation
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Expanded(
child: ElevatedButton(
onPressed: _toggleBackgroundMonitoring,
style: ElevatedButton.styleFrom(
backgroundColor: _isBackgroundMonitoring
? Colors.red
: Colors.green,
),
child: Text(
_isBackgroundMonitoring
? 'Stop Android BG'
: 'Start Android BG',
),
),
),
Expanded(
child: ElevatedButton(
onPressed: _toggleIosBackgroundMonitoring,
style: ElevatedButton.styleFrom(
backgroundColor: _isIosBackgroundMonitoring
? Colors.red
: Colors.green,
),
child: Text(
_isIosBackgroundMonitoring
? 'Stop iOS BG'
: 'Start iOS BG',
),
),
),
],
),
const SizedBox(height: 16),
// Show favorite devices info
if (_favoriteDevices.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Monitoring Favorite Devices:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
...(_favoriteDevices.keys
.take(3)
.map(
(address) => Text(
'• ${_favoriteDevices[address]?['name'] ?? 'Unknown'} ($address)',
),
)),
if (_favoriteDevices.length > 3)
Text('... and ${_favoriteDevices.length - 3} more'),
],
),
),
] else ...[
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'⚠️ Add devices to favorites first to enable background monitoring',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
],
],
),
),
),
const SizedBox(height: 16),
// Background Events with proper height constraint
SizedBox(
height: 300, // Fixed height for better layout
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Background Events',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: _backgroundEvents.isEmpty
? const Center(child: Text('No background events yet'))
: ListView.builder(
itemCount: _backgroundEvents.length,
itemBuilder: (context, index) {
final event = _backgroundEvents[index];
final eventType =
event['eventType'] as String? ?? 'unknown';
final intervalMs =
event['intervalMs'] as int? ?? -1;
final address =
event['address'] as String? ?? 'N/A';
final name =
event['name'] as String? ?? 'Unknown';
// Determine icon and color based on event type
IconData icon;
Color color;
String title;
switch (eventType) {
case 'beacon_turned_on':
icon = Icons.power;
color = Colors.green;
title = 'Beacon Turned On';
break;
case 'button_pressed':
icon = Icons.touch_app;
color = Colors.orange;
title = 'Button Pressed!';
break;
case 'beacon_normal':
icon = Icons.bluetooth;
color = Colors.blue;
title = 'Beacon Normal';
break;
case 'connection_failed':
icon = Icons.error;
color = Colors.red;
title = 'Connection Failed';
break;
case 'interval_read_failed':
icon = Icons.warning;
color = Colors.amber;
title = 'Interval Read Failed';
break;
default:
icon = Icons.info;
color = Colors.grey;
title = 'Event: $eventType';
}
return ListTile(
leading: Icon(icon, color: color),
title: Text(title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Device: $name ($address)'),
if (intervalMs >= 0)
Text('Interval: ${intervalMs}ms'),
if (event['timestamp'] != null)
Text(
'Time: ${DateTime.fromMillisecondsSinceEpoch(event['timestamp'] as int).toString().substring(11, 19)}',
),
],
),
isThreeLine: true,
);
},
),
),
],
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Adonna Example'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.bluetooth_searching), text: 'Devices'),
Tab(icon: Icon(Icons.favorite), text: 'Favorites'),
Tab(icon: Icon(Icons.settings), text: 'Background'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildDeviceList(),
_buildFavoritesList(),
_buildBackgroundMonitoringTab(),
],
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: _toggleScan,
tooltip: _isScanning ? 'Stop Scan' : 'Start Scan',
child: Icon(_isScanning ? Icons.stop : Icons.search),
),
const SizedBox(height: 8),
if (_isConnected) ...[
FloatingActionButton(
onPressed: _disconnect,
tooltip: 'Disconnect',
backgroundColor: Colors.red,
child: const Icon(Icons.link_off),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: _getDeviceInfo,
tooltip: 'Get Device Info',
backgroundColor: Colors.orange,
child: const Icon(Icons.info),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: _triggerOta,
tooltip: 'Trigger OTA',
backgroundColor: Colors.purple,
child: const Icon(Icons.system_update),
),
],
],
),
bottomNavigationBar: _isConnected
? Container(
padding: const EdgeInsets.all(16),
color: Colors.green.shade100,
child: Row(
children: [
const Icon(Icons.link, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Connected to: $_connectedName',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text('MAC: $_connectedAddress'),
],
),
),
if (_deviceInfo != null)
IconButton(
icon: const Icon(Icons.expand_more),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Device Info'),
content: SingleChildScrollView(
child: Text(
const JsonEncoder.withIndent(
' ',
).convert(_deviceInfo),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
},
),
],
),
)
: null,
);
}
}