initial
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import 'package:path/path.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../models/registered_device.dart';
|
||||
|
||||
const _tableName = 'siab_devices';
|
||||
|
||||
class SiabDatabase {
|
||||
static Database? _db;
|
||||
|
||||
static Future<Database> get database async {
|
||||
if (_db != null) return _db!;
|
||||
_db = await _init();
|
||||
return _db!;
|
||||
}
|
||||
|
||||
static Future<Database> _init() async {
|
||||
final dbPath = await getDatabasesPath();
|
||||
final path = join(dbPath, 'siab_devices.db');
|
||||
return openDatabase(
|
||||
path,
|
||||
version: 2,
|
||||
onCreate: (db, version) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
// Simple destructive migration: drop old table and recreate with new schema.
|
||||
if (oldVersion < 2) {
|
||||
await db.execute('DROP TABLE IF EXISTS $_tableName');
|
||||
await db.execute('''
|
||||
CREATE TABLE $_tableName (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> insert(RegisteredDevice device) async {
|
||||
final db = await database;
|
||||
return db.insert(
|
||||
_tableName,
|
||||
device.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RegisteredDevice>> getAll() async {
|
||||
final db = await database;
|
||||
final maps = await db.query(_tableName, orderBy: 'label ASC');
|
||||
return maps.map(RegisteredDevice.fromMap).toList();
|
||||
}
|
||||
|
||||
Future<RegisteredDevice?> getByUuid(String uuid) async {
|
||||
final db = await database;
|
||||
final maps = await db.query(
|
||||
_tableName,
|
||||
where: 'uuid = ?',
|
||||
whereArgs: [uuid],
|
||||
);
|
||||
if (maps.isEmpty) return null;
|
||||
return RegisteredDevice.fromMap(maps.first);
|
||||
}
|
||||
|
||||
Future<int> update(RegisteredDevice device) async {
|
||||
if (device.id == null) return 0;
|
||||
final db = await database;
|
||||
return db.update(
|
||||
_tableName,
|
||||
device.toMap(),
|
||||
where: 'id = ?',
|
||||
whereArgs: [device.id],
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> delete(int id) async {
|
||||
final db = await database;
|
||||
return db.delete(_tableName, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
Future<int> deleteByUuid(String uuid) async {
|
||||
final db = await database;
|
||||
return db.delete(_tableName, where: 'uuid = ?', whereArgs: [uuid]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'services/ble_service.dart';
|
||||
import 'screens/main_shell.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const SIABApp());
|
||||
}
|
||||
|
||||
class SIABApp extends StatelessWidget {
|
||||
const SIABApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => BLEService(),
|
||||
child: MaterialApp(
|
||||
title: 'SIAB Bluetooth',
|
||||
theme: ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MainShell(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// A SIAB device registered in local SQLite with a user-assigned label.
|
||||
/// Stores only a user label (\"Label ID\") and the device UUID.
|
||||
class RegisteredDevice {
|
||||
final int? id;
|
||||
final String uuid;
|
||||
final String label;
|
||||
|
||||
RegisteredDevice({
|
||||
this.id,
|
||||
required this.uuid,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'uuid': uuid,
|
||||
'label': label,
|
||||
};
|
||||
}
|
||||
|
||||
static RegisteredDevice fromMap(Map<String, dynamic> map) {
|
||||
return RegisteredDevice(
|
||||
id: map['id'] as int?,
|
||||
uuid: map['uuid'] as String,
|
||||
label: map['label'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
RegisteredDevice copyWith({
|
||||
int? id,
|
||||
String? uuid,
|
||||
String? label,
|
||||
}) {
|
||||
return RegisteredDevice(
|
||||
id: id ?? this.id,
|
||||
uuid: uuid ?? this.uuid,
|
||||
label: label ?? this.label,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/ble_service.dart';
|
||||
|
||||
class CLIScreen extends StatefulWidget {
|
||||
const CLIScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CLIScreen> createState() => _CLIScreenState();
|
||||
}
|
||||
|
||||
class _CLIScreenState extends State<CLIScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<String> _logLines = [];
|
||||
Timer? _statusTimer;
|
||||
bool _autoScroll = true;
|
||||
int _lastProcessedPacket = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_addHeader();
|
||||
_startStatusTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_statusTimer?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _repeatChar(String char, int count) {
|
||||
return List.filled(count, char).join();
|
||||
}
|
||||
|
||||
void _addHeader() {
|
||||
_addLogLine(_repeatChar('=', 70));
|
||||
_addLogLine('Enhanced with automatic reconnection');
|
||||
_addLogLine(_repeatChar('=', 70));
|
||||
_addLogLine('');
|
||||
}
|
||||
|
||||
void _addLogLine(String line) {
|
||||
setState(() {
|
||||
_logLines.add(line);
|
||||
// Keep last 5000 lines
|
||||
if (_logLines.length > 5000) {
|
||||
_logLines.removeAt(0);
|
||||
}
|
||||
});
|
||||
|
||||
if (_autoScroll) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startStatusTimer() {
|
||||
_statusTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (mounted) {
|
||||
final ble = Provider.of<BLEService>(context, listen: false);
|
||||
if (ble.isConnected) {
|
||||
_updateStatus(ble);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _updateStatus(BLEService ble) {
|
||||
final stats = _getStatistics(ble);
|
||||
final elapsed = stats['elapsed'] as double;
|
||||
final packets = stats['packets'] as int;
|
||||
final rate = stats['rate'] as double;
|
||||
final connStatus = ble.isConnected ? '✓' : '✗';
|
||||
|
||||
// Remove old status line if exists (lines ending with specific pattern)
|
||||
if (_logLines.isNotEmpty && _logLines.last.startsWith('[STATUS]')) {
|
||||
_logLines.removeLast();
|
||||
}
|
||||
|
||||
if (packets > 0) {
|
||||
_addLogLine(
|
||||
'[STATUS] Packets: ${packets.toString().padLeft(5)} | '
|
||||
'Rate: ${rate.toStringAsFixed(2).padLeft(5)} pkt/s | '
|
||||
'Time: ${elapsed.toStringAsFixed(1).padLeft(7)}s | '
|
||||
'Conn: $connStatus',
|
||||
);
|
||||
} else {
|
||||
_addLogLine('[STATUS] Waiting for data... (${elapsed.toStringAsFixed(1)}s)');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getStatistics(BLEService ble) {
|
||||
final now = DateTime.now();
|
||||
final startTime = ble.streamingDuration != null
|
||||
? now.subtract(ble.streamingDuration!)
|
||||
: now;
|
||||
final elapsed = now.difference(startTime).inMilliseconds / 1000.0;
|
||||
final packets = ble.packetCount;
|
||||
final rate = elapsed > 0 ? packets / elapsed : 0.0;
|
||||
|
||||
return {
|
||||
'elapsed': elapsed,
|
||||
'packets': packets,
|
||||
'rate': rate,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime dt) {
|
||||
return '${dt.hour.toString().padLeft(2, '0')}:'
|
||||
'${dt.minute.toString().padLeft(2, '0')}:'
|
||||
'${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void _onDataReceived(DataPacket packet) {
|
||||
final timestamp = _formatTimestamp(packet.timestamp);
|
||||
final hexData = packet.hexString;
|
||||
final packetNum = packet.packetNumber;
|
||||
final bytes = packet.data.length;
|
||||
|
||||
_addLogLine('');
|
||||
_addLogLine('[$timestamp] Packet #${packetNum.toString().padLeft(5, '0')}: $hexData ($bytes bytes)');
|
||||
|
||||
// Parse data formats (matching Python example)
|
||||
if (packet.data.length >= 4) {
|
||||
try {
|
||||
final valueU32 = packet.value32BitUnsigned;
|
||||
final valueS32 = packet.value32BitSigned;
|
||||
final valueU16 = packet.value16BitUnsigned;
|
||||
final valueS16 = packet.value16BitSigned;
|
||||
|
||||
// Commented out like in Python example
|
||||
// _addLogLine(' → 32-bit unsigned: ${valueU32.toString().padLeft(10)} | 32-bit signed: ${valueS32.toString().padLeft(10)}');
|
||||
// _addLogLine(' → 16-bit unsigned: ${valueU16.toString().padLeft(10)} | 16-bit signed: ${valueS16.toString().padLeft(10)}');
|
||||
|
||||
if (valueU32 != null && valueU32 > 0) {
|
||||
final floatVal = valueU32 / 100.0;
|
||||
// _addLogLine(' → As float (÷100): ${floatVal.toStringAsFixed(2).padLeft(12)}');
|
||||
}
|
||||
} catch (e) {
|
||||
_addLogLine(' → Parse error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Show weight as 'timbang' in KG (matching Python example)
|
||||
final weightKg = packet.weightKg;
|
||||
if (weightKg != null) {
|
||||
_addLogLine(' → timbang : ${weightKg.toStringAsFixed(1)} KG');
|
||||
} else {
|
||||
// Fallback to showing raw bytes in decimal
|
||||
final byteStr = packet.data.map((b) => b.toString().padLeft(2, '0')).join(' ');
|
||||
_addLogLine(' → timbang : $byteStr');
|
||||
}
|
||||
|
||||
// Try to decode as ASCII if printable
|
||||
final asciiStr = packet.asciiString;
|
||||
if (asciiStr != null && asciiStr.isNotEmpty) {
|
||||
_addLogLine(" → ASCII: '$asciiStr'");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: const Text('SIAB Bluetooth CLI', style: TextStyle(color: Colors.green)),
|
||||
backgroundColor: Colors.grey[900],
|
||||
iconTheme: const IconThemeData(color: Colors.green),
|
||||
actions: [
|
||||
Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
if (ble.isConnected) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.green),
|
||||
tooltip: 'Clear Log',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_logLines.clear();
|
||||
_addHeader();
|
||||
});
|
||||
ble.clearData();
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center,
|
||||
color: Colors.green,
|
||||
),
|
||||
tooltip: _autoScroll ? 'Disable Auto-scroll' : 'Enable Auto-scroll',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_autoScroll = !_autoScroll;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
// Process new data packets
|
||||
if (ble.dataPackets.isNotEmpty) {
|
||||
final newPackets = ble.dataPackets
|
||||
.where((p) => p.packetNumber > _lastProcessedPacket)
|
||||
.toList();
|
||||
|
||||
for (final packet in newPackets) {
|
||||
_onDataReceived(packet);
|
||||
_lastProcessedPacket = packet.packetNumber;
|
||||
}
|
||||
} else {
|
||||
_lastProcessedPacket = 0;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Connection status bar (terminal style)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Colors.grey[900],
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
ble.isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
|
||||
color: ble.isConnected ? Colors.green : Colors.red,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
ble.isConnected
|
||||
? 'Connected: ${ble.deviceName ?? ble.deviceAddress ?? "Unknown"}'
|
||||
: 'Disconnected',
|
||||
style: const TextStyle(
|
||||
color: Colors.green,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (ble.isScanning || ble.isConnecting)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.green),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Terminal output area
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
_logLines.join('\n'),
|
||||
style: const TextStyle(
|
||||
color: Colors.green,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom action bar
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Colors.grey[900],
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
if (ble.isConnecting || ble.isScanning)
|
||||
const Text(
|
||||
'Connecting...',
|
||||
style: TextStyle(color: Colors.yellow, fontFamily: 'monospace'),
|
||||
)
|
||||
else if (ble.isConnected)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
_addLogLine('\n⏹ Disconnecting...');
|
||||
await ble.disconnect();
|
||||
ble.clearData();
|
||||
_addLogLine('✅ Disconnected\n');
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth_disabled, size: 18),
|
||||
label: const Text('Disconnect'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[900],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
)
|
||||
else
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
_addLogLine('\n[1/3] Connecting to SIAB device...');
|
||||
final success = await ble.autoConnect();
|
||||
if (success) {
|
||||
_addLogLine('✅ Connected successfully!');
|
||||
if (ble.deviceAddress != null) {
|
||||
_addLogLine(' Device address: ${ble.deviceAddress}');
|
||||
}
|
||||
_addLogLine('\n[2/3] Enabling streaming data notifications...');
|
||||
_addLogLine('✅ Notifications enabled!');
|
||||
_addLogLine('\n[3/3] Receiving streaming data...');
|
||||
_addLogLine(_repeatChar('=', 70));
|
||||
_addLogLine('Waiting for data packets...');
|
||||
_addLogLine('');
|
||||
} else {
|
||||
_addLogLine('❌ Failed to connect!');
|
||||
_addLogLine('\nTroubleshooting:');
|
||||
_addLogLine(' • Make sure SIAB device is powered on');
|
||||
_addLogLine(' • Check Bluetooth is enabled');
|
||||
_addLogLine(' • Verify device name is \'SIAB\'');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth_searching, size: 18),
|
||||
label: const Text('Connect'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green[900],
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../database/siab_database.dart';
|
||||
import '../models/registered_device.dart';
|
||||
import '../services/ble_service.dart';
|
||||
import '../siab_config.dart';
|
||||
|
||||
class DevicesScreen extends StatefulWidget {
|
||||
const DevicesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DevicesScreen> createState() => _DevicesScreenState();
|
||||
}
|
||||
|
||||
class _DevicesScreenState extends State<DevicesScreen> {
|
||||
final _db = SiabDatabase();
|
||||
List<RegisteredDevice> _saved = [];
|
||||
List<SiabScanResult> _scanResults = [];
|
||||
bool _loading = false;
|
||||
|
||||
Future<void> _loadSaved() async {
|
||||
setState(() => _loading = true);
|
||||
_saved = await _db.getAll();
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
|
||||
Future<void> _scan() async {
|
||||
final ble = context.read<BLEService>();
|
||||
setState(() => _scanResults = []);
|
||||
final list = await ble.scanForAllSiabDevices();
|
||||
setState(() => _scanResults = list);
|
||||
}
|
||||
|
||||
Future<void> _register( SiabScanResult result, String label) async {
|
||||
await _db.insert(RegisteredDevice(
|
||||
uuid: result.address,
|
||||
label: label.isEmpty ? result.address : label,
|
||||
));
|
||||
await _loadSaved();
|
||||
}
|
||||
|
||||
Future<void> _connectToScanResult(SiabScanResult result) async {
|
||||
final ble = context.read<BLEService>();
|
||||
final ok = await ble.connectToDevice(result.device);
|
||||
if (mounted && !ok) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Connection failed'), backgroundColor: Colors.red),
|
||||
);
|
||||
} else if (mounted && ok) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted && ble.packetCount == 0) ble.tryStartCommands();
|
||||
await _loadSaved();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Connected'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectToSaved(RegisteredDevice device) async {
|
||||
final ble = context.read<BLEService>();
|
||||
final ok = await ble.connectToAddress(device.uuid);
|
||||
if (mounted && !ok) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Device not found or connection failed'), backgroundColor: Colors.red),
|
||||
);
|
||||
} else if (mounted && ok) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted && ble.packetCount == 0) ble.tryStartCommands();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Connected'), backgroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unregisterDevice(RegisteredDevice device) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Unregister device'),
|
||||
content: Text(
|
||||
'Remove \"${device.label}\" (${device.uuid}) from registered devices?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Unregister'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
if (device.id != null) {
|
||||
await _db.delete(device.id!);
|
||||
} else {
|
||||
await _db.deleteByUuid(device.uuid);
|
||||
}
|
||||
await _loadSaved();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSaved();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Timbangan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loading ? null : _loadSaved,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Scan section at top
|
||||
const Text('Daftar Timbangan (scan)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: ble.isScanning ? null : () async { await _scan(); },
|
||||
icon: ble.isScanning
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_searching, size: 20),
|
||||
label: Text(ble.isScanning ? 'Scanning...' : 'Scan Timbangan'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_scanResults.isEmpty && !ble.isScanning)
|
||||
const Card(child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Tap \"Scan Timbangan\" lihat melihat timbangan di sekitar.'),
|
||||
))
|
||||
else
|
||||
..._scanResults.map((r) {
|
||||
RegisteredDevice? registered;
|
||||
for (final d in _saved) {
|
||||
if (d.uuid == r.address) { registered = d; break; }
|
||||
}
|
||||
final labelId = registered?.label;
|
||||
final hasLabel = labelId != null && labelId.isNotEmpty;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ListRow(label: 'NAMA', value: labelId ?? '—'),
|
||||
_ListRow(label: 'ID', value: r.device.remoteId.toString()),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (!hasLabel)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showRegisterDialog(r),
|
||||
icon: const Icon(Icons.app_registration, size: 18),
|
||||
label: const Text('Register'),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: ble.isConnecting ? null : () => _connectToScanResult(r),
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Registered devices below scan
|
||||
const Text('Registered devices (saved)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
if (_loading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (_saved.isEmpty)
|
||||
const Card(child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('No registered devices. Scan and register above.'),
|
||||
))
|
||||
else
|
||||
..._saved.map((d) => Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ListRow(label: 'NAMA', value: d.label),
|
||||
_ListRow(label: 'ID', value: d.uuid),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _unregisterDevice(d),
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Unregister'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (ble.isConnected && ble.deviceAddress == d.uuid)
|
||||
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 28)
|
||||
else
|
||||
TextButton.icon(
|
||||
onPressed: ble.isConnecting ? null : () => _connectToSaved(d),
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Connect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRegisterDialog(SiabScanResult result) {
|
||||
final controller = TextEditingController(
|
||||
text: result.name.isNotEmpty ? result.name : SiabConfig.deviceName,
|
||||
);
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Register device'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nama',
|
||||
hintText: 'contoh : Scale 1, TIMBANGAN 1',
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
final label = controller.text.trim();
|
||||
await _register(result, label.isEmpty ? result.address : label);
|
||||
if (mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ListRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _ListRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/ble_service.dart';
|
||||
import '../widgets/data_packet_card.dart';
|
||||
import '../widgets/statistics_card.dart';
|
||||
import '../widgets/connection_card.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Timbangan Bluetooh'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: [
|
||||
Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
if (ble.isConnected) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Clear Data',
|
||||
onPressed: () {
|
||||
ble.clearData();
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
return Column(
|
||||
children: [
|
||||
// Connection Card
|
||||
ConnectionCard(ble: ble),
|
||||
|
||||
// Statistics Card
|
||||
if (ble.isConnected) StatisticsCard(ble: ble),
|
||||
|
||||
// Data Packets List
|
||||
Expanded(
|
||||
child: ble.isConnected && ble.dataPackets.isNotEmpty
|
||||
? ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: ble.dataPackets.length,
|
||||
reverse: true, // Show newest first
|
||||
itemBuilder: (context, index) {
|
||||
final packet = ble.dataPackets[ble.dataPackets.length - 1 - index];
|
||||
return DataPacketCard(packet: packet);
|
||||
},
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
ble.isConnected
|
||||
? Icons.bluetooth_connected
|
||||
: Icons.bluetooth_disabled,
|
||||
size: 64,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
ble.isConnected
|
||||
? 'Waiting for data...'
|
||||
: 'Not connected',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
if (ble.isConnected) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Data packets will appear here',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
if (ble.isConnecting || ble.isScanning) {
|
||||
return const FloatingActionButton(
|
||||
onPressed: null,
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
if (ble.isConnected) {
|
||||
return FloatingActionButton(
|
||||
onPressed: () async {
|
||||
await ble.disconnect();
|
||||
ble.clearData();
|
||||
},
|
||||
backgroundColor: Colors.red,
|
||||
child: const Icon(Icons.bluetooth_disabled),
|
||||
);
|
||||
}
|
||||
|
||||
return FloatingActionButton(
|
||||
onPressed: () async {
|
||||
final success = await ble.autoConnect();
|
||||
if (!success && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Koneksi ke timbangan gagal'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.bluetooth_searching),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'devices_screen.dart';
|
||||
import 'timbang_screen.dart';
|
||||
|
||||
/// Root scaffold with bottom nav: Devices and Timbang (scale display).
|
||||
class MainShell extends StatefulWidget {
|
||||
const MainShell({super.key});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
int _index = 0;
|
||||
|
||||
static const _tabs = [
|
||||
(label: 'Devices', icon: Icons.list_alt),
|
||||
(label: 'Timbang', icon: Icons.monitor_weight),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [
|
||||
DevicesScreen(),
|
||||
TimbangScreen(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _index,
|
||||
onTap: (i) => setState(() => _index = i),
|
||||
items: _tabs
|
||||
.map((t) => BottomNavigationBarItem(
|
||||
icon: Icon(t.icon),
|
||||
label: t.label,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../database/siab_database.dart';
|
||||
import '../services/ble_service.dart';
|
||||
|
||||
/// Screen that shows the current timbang (weight) value on a scale-style
|
||||
/// display matching the SIAB scale design (dark frame, glowing red digits).
|
||||
/// When connected, title shows "TIMBANGAN [label] SIAB" from registered device.
|
||||
class TimbangScreen extends StatelessWidget {
|
||||
const TimbangScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Consumer<BLEService>(
|
||||
builder: (context, ble, _) {
|
||||
final weightKg = ble.lastWeightKg;
|
||||
final displayValue = weightKg != null
|
||||
? weightKg.toStringAsFixed(1)
|
||||
: '0.0';
|
||||
final isConnected = ble.isConnected;
|
||||
final address = ble.deviceAddress;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Outer frame with decorative corners
|
||||
Stack(
|
||||
children: [
|
||||
// Outer dark grey/blue frame
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2A2A3A), // Dark grey/blue
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Inner border (lighter grey/blue line)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF4A4A5A),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: // Deep black display area
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 40,
|
||||
horizontal: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Large glowing red weight value
|
||||
Text(
|
||||
displayValue,
|
||||
style: TextStyle(
|
||||
fontSize: 72,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.red.shade400,
|
||||
fontFamily: 'monospace',
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
],
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.red.shade400
|
||||
.withOpacity(0.8),
|
||||
blurRadius: 20,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.red.shade400
|
||||
.withOpacity(0.5),
|
||||
blurRadius: 40,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Smaller glowing red "kg" unit
|
||||
Text(
|
||||
'kg',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.red.shade400,
|
||||
fontFamily: 'monospace',
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.red.shade400
|
||||
.withOpacity(0.8),
|
||||
blurRadius: 15,
|
||||
),
|
||||
Shadow(
|
||||
color: Colors.red.shade400
|
||||
.withOpacity(0.5),
|
||||
blurRadius: 30,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Corner decorative elements (rivets/screws)
|
||||
Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: _CornerRivet(),
|
||||
),
|
||||
Positioned(
|
||||
top: 2,
|
||||
right: 2,
|
||||
child: _CornerRivet(),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
child: _CornerRivet(),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
child: _CornerRivet(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// "TIMBANGAN [label] SIAB" when connected, else "TIMBANGAN SIAB"
|
||||
FutureBuilder<String>(
|
||||
future: address != null
|
||||
? SiabDatabase().getByUuid(address).then((d) => d?.label ?? '')
|
||||
: Future.value(''),
|
||||
builder: (context, snapshot) {
|
||||
final label = snapshot.data?.trim() ?? '';
|
||||
final title = isConnected && label.isNotEmpty
|
||||
? 'TIMBANGAN $label SIAB'
|
||||
: 'TIMBANGAN SIAB';
|
||||
return Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade400,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Connection status + actions
|
||||
if (!isConnected)
|
||||
Text(
|
||||
'Connect scale in Devices tab',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bluetooth_connected,
|
||||
size: 20,
|
||||
color: Colors.green.shade300,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await ble.disconnect();
|
||||
},
|
||||
icon: const Icon(Icons.power_settings_new, size: 18),
|
||||
label: const Text('Disconnect'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red.shade300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: ble.hasStableWeight
|
||||
? () async {
|
||||
final ok = await ble.saveStableMeasurement();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(ok
|
||||
? 'Stable weight saved to file'
|
||||
: 'No stable value to save yet'),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Save stable value'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final file = await ble.getTodayMeasurementFile();
|
||||
final exists = await file.exists();
|
||||
if (!exists || await file.length() == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No measurements file for today yet. Save a value first.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Share.shareXFiles(
|
||||
[
|
||||
XFile(
|
||||
file.path,
|
||||
mimeType: 'text/csv',
|
||||
name: p.basename(file.path),
|
||||
),
|
||||
],
|
||||
subject: 'SIAB Laporan hasil Timbang ',
|
||||
text:
|
||||
'SIAB Laporan hasil Timbang harian.',
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('gagal mengirim file: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.email),
|
||||
label: const Text('email terkirim'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decorative corner rivet/screw element
|
||||
class _CornerRivet extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade500,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../database/siab_database.dart';
|
||||
import '../siab_config.dart';
|
||||
|
||||
class BLEService extends ChangeNotifier {
|
||||
// Device info
|
||||
BluetoothDevice? _device;
|
||||
BluetoothCharacteristic? _readCharacteristic;
|
||||
BluetoothCharacteristic? _writeCharacteristic;
|
||||
|
||||
// Connection state
|
||||
bool _isConnected = false;
|
||||
bool _isScanning = false;
|
||||
bool _isConnecting = false;
|
||||
|
||||
// Streamed data
|
||||
final List<DataPacket> _dataPackets = [];
|
||||
int _packetCount = 0;
|
||||
DateTime? _startTime;
|
||||
DateTime? _lastDataTime;
|
||||
|
||||
// Getters
|
||||
bool get isConnected => _isConnected;
|
||||
bool get isScanning => _isScanning;
|
||||
bool get isConnecting => _isConnecting;
|
||||
List<DataPacket> get dataPackets => List.unmodifiable(_dataPackets);
|
||||
int get packetCount => _packetCount;
|
||||
String? get deviceAddress => _device?.remoteId.toString();
|
||||
String? get deviceName => _device?.platformName;
|
||||
|
||||
double get packetsPerSecond {
|
||||
if (_startTime == null || _packetCount == 0) return 0.0;
|
||||
final duration = DateTime.now().difference(_startTime!);
|
||||
if (duration.inSeconds == 0) return 0.0;
|
||||
return _packetCount / duration.inSeconds;
|
||||
}
|
||||
|
||||
Duration? get streamingDuration {
|
||||
if (_startTime == null) return null;
|
||||
return DateTime.now().difference(_startTime!);
|
||||
}
|
||||
|
||||
/// Latest weight (timbang) in kg from the most recent data packet.
|
||||
double? get lastWeightKg {
|
||||
if (_dataPackets.isEmpty) return null;
|
||||
return _dataPackets.last.weightKg;
|
||||
}
|
||||
|
||||
// Stable value tracking for manual save
|
||||
double? _currentStableCandidate;
|
||||
DateTime? _lastWeightChangeTime;
|
||||
|
||||
/// True when the current weight has been stable for at least 2 seconds.
|
||||
bool get hasStableWeight {
|
||||
final candidate = _currentStableCandidate;
|
||||
final lastChange = _lastWeightChangeTime;
|
||||
if (candidate == null || lastChange == null) return false;
|
||||
return DateTime.now().difference(lastChange) >= const Duration(seconds: 2);
|
||||
}
|
||||
|
||||
/// Latest stable weight in kg, or null if not stable yet.
|
||||
double? get stableWeightKg => hasStableWeight ? _currentStableCandidate : null;
|
||||
|
||||
// Check if Bluetooth is available
|
||||
Future<bool> checkBluetooth() async {
|
||||
try {
|
||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
||||
return adapterState == BluetoothAdapterState.on;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Turn on Bluetooth if off
|
||||
Future<void> turnOnBluetooth() async {
|
||||
try {
|
||||
await FlutterBluePlus.turnOn();
|
||||
} catch (e) {
|
||||
debugPrint('Error turning on Bluetooth: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a single SIAB device found during scan.
|
||||
static SiabScanResult scanResultFrom(ScanResult result) {
|
||||
return SiabScanResult(
|
||||
address: result.device.remoteId.toString(),
|
||||
name: result.device.platformName,
|
||||
device: result.device,
|
||||
);
|
||||
}
|
||||
|
||||
/// Scan and return all SIAB devices found (does not connect).
|
||||
/// Uses listen + delay because scanResults stream never completes.
|
||||
/// Extra hardening: matches device name by strict (case-insensitive) equality.
|
||||
Future<List<SiabScanResult>> scanForAllSiabDevices({
|
||||
String deviceName = SiabConfig.deviceName,
|
||||
int timeoutSeconds = 12,
|
||||
}) async {
|
||||
if (_isScanning) return [];
|
||||
_isScanning = true;
|
||||
notifyListeners();
|
||||
final list = <SiabScanResult>[];
|
||||
final seen = <String>{};
|
||||
StreamSubscription<List<ScanResult>>? sub;
|
||||
try {
|
||||
if (!await checkBluetooth()) {
|
||||
await turnOnBluetooth();
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
await FlutterBluePlus.startScan(timeout: Duration(seconds: timeoutSeconds));
|
||||
sub = FlutterBluePlus.scanResults.listen((scanResult) {
|
||||
for (final result in scanResult) {
|
||||
final name = result.device.platformName;
|
||||
if (name.isNotEmpty &&
|
||||
name.toLowerCase() == deviceName.toLowerCase()) {
|
||||
final address = result.device.remoteId.toString();
|
||||
if (!seen.contains(address)) {
|
||||
seen.add(address);
|
||||
list.add(scanResultFrom(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await Future.delayed(Duration(seconds: timeoutSeconds));
|
||||
} catch (e) {
|
||||
debugPrint('Scan error: $e');
|
||||
} finally {
|
||||
await sub?.cancel();
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
}
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Scan and return Bluetooth devices found.
|
||||
///
|
||||
/// Extra hardening: by default, only returns devices whose platform name
|
||||
/// strictly equals [SiabConfig.deviceName] (case-insensitive).
|
||||
/// Uses listen + delay because scanResults stream never completes.
|
||||
Future<List<SiabScanResult>> scanForAllBluetoothDevices({
|
||||
int timeoutSeconds = 12,
|
||||
bool restrictToSiab = true,
|
||||
String deviceName = SiabConfig.deviceName,
|
||||
}) async {
|
||||
if (_isScanning) return [];
|
||||
_isScanning = true;
|
||||
notifyListeners();
|
||||
final list = <SiabScanResult>[];
|
||||
final seen = <String>{};
|
||||
StreamSubscription<List<ScanResult>>? sub;
|
||||
try {
|
||||
if (!await checkBluetooth()) {
|
||||
await turnOnBluetooth();
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
await FlutterBluePlus.startScan(timeout: Duration(seconds: timeoutSeconds));
|
||||
sub = FlutterBluePlus.scanResults.listen((scanResult) {
|
||||
for (final result in scanResult) {
|
||||
final name = result.device.platformName;
|
||||
if (restrictToSiab &&
|
||||
(name.isEmpty ||
|
||||
name.toLowerCase() != deviceName.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
final address = result.device.remoteId.toString();
|
||||
if (!seen.contains(address)) {
|
||||
seen.add(address);
|
||||
list.add(scanResultFrom(result));
|
||||
}
|
||||
}
|
||||
});
|
||||
await Future.delayed(Duration(seconds: timeoutSeconds));
|
||||
} catch (e) {
|
||||
debugPrint('Scan error: $e');
|
||||
} finally {
|
||||
await sub?.cancel();
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
}
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
return list;
|
||||
}
|
||||
|
||||
// Scan for SIAB device (first match only, used by autoConnect).
|
||||
// Uses listen + completer because scanResults stream never completes.
|
||||
// Extra hardening: matches device name by strict (case-insensitive) equality.
|
||||
Future<bool> scanForDevice({
|
||||
String deviceName = SiabConfig.deviceName,
|
||||
int timeoutSeconds = 10,
|
||||
}) async {
|
||||
if (_isScanning) return false;
|
||||
_isScanning = true;
|
||||
notifyListeners();
|
||||
final completer = Completer<bool>();
|
||||
StreamSubscription<List<ScanResult>>? sub;
|
||||
try {
|
||||
if (!await checkBluetooth()) {
|
||||
await turnOnBluetooth();
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
await FlutterBluePlus.startScan(timeout: Duration(seconds: timeoutSeconds));
|
||||
sub = FlutterBluePlus.scanResults.listen((scanResult) {
|
||||
if (completer.isCompleted) return;
|
||||
for (final result in scanResult) {
|
||||
final name = result.device.platformName;
|
||||
if (name.isNotEmpty &&
|
||||
name.toLowerCase() == deviceName.toLowerCase()) {
|
||||
_device = result.device;
|
||||
if (!completer.isCompleted) completer.complete(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
Future.delayed(Duration(seconds: timeoutSeconds), () {
|
||||
if (!completer.isCompleted) completer.complete(false);
|
||||
});
|
||||
final found = await completer.future;
|
||||
await sub.cancel();
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
return found;
|
||||
} catch (e) {
|
||||
debugPrint('Scan error: $e');
|
||||
if (!completer.isCompleted) completer.complete(false);
|
||||
await sub?.cancel();
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a specific device (e.g. from scan list or after resolving address).
|
||||
Future<bool> connectToDevice(BluetoothDevice device) async {
|
||||
if (_isConnecting || _isConnected) return false;
|
||||
_device = device;
|
||||
return connect();
|
||||
}
|
||||
|
||||
/// Connect to a device by address (scans all Bluetooth briefly to resolve device).
|
||||
Future<bool> connectToAddress(String address) async {
|
||||
if (_isConnecting || _isConnected) return false;
|
||||
final list = await scanForAllBluetoothDevices(timeoutSeconds: 10);
|
||||
SiabScanResult? match;
|
||||
for (final r in list) {
|
||||
if (r.address == address) {
|
||||
match = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match == null) return false;
|
||||
return connectToDevice(match.device);
|
||||
}
|
||||
|
||||
// Connect to device
|
||||
Future<bool> connect() async {
|
||||
if (_device == null || _isConnecting || _isConnected) return false;
|
||||
|
||||
_isConnecting = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// Connect to device
|
||||
await _device!.connect(timeout: const Duration(seconds: 15));
|
||||
|
||||
// Discover services
|
||||
final services = await _device!.discoverServices();
|
||||
|
||||
// Find read and write characteristics
|
||||
for (final service in services) {
|
||||
for (final characteristic in service.characteristics) {
|
||||
// Check if it's a notify characteristic
|
||||
if (characteristic.properties.notify || characteristic.properties.indicate) {
|
||||
_readCharacteristic = characteristic;
|
||||
}
|
||||
// Check if it's a write characteristic
|
||||
if (characteristic.properties.write || characteristic.properties.writeWithoutResponse) {
|
||||
_writeCharacteristic = characteristic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found, try to find by UUID (Nordic UART Service)
|
||||
if (_readCharacteristic == null || _writeCharacteristic == null) {
|
||||
for (final service in services) {
|
||||
for (final characteristic in service.characteristics) {
|
||||
final uuid = characteristic.uuid.toString().toLowerCase();
|
||||
// Nordic UART Service characteristic
|
||||
if (uuid.contains('8ec90001') || uuid.contains('6e400003') || uuid.contains('6e400002')) {
|
||||
if (characteristic.properties.notify || characteristic.properties.indicate) {
|
||||
_readCharacteristic = characteristic;
|
||||
}
|
||||
if (characteristic.properties.write || characteristic.properties.writeWithoutResponse) {
|
||||
_writeCharacteristic = characteristic;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enable notifications
|
||||
if (_readCharacteristic != null) {
|
||||
await _readCharacteristic!.setNotifyValue(true);
|
||||
|
||||
// Listen to notifications
|
||||
_readCharacteristic!.lastValueStream.listen((value) {
|
||||
_onDataReceived(value);
|
||||
});
|
||||
}
|
||||
|
||||
_isConnected = true;
|
||||
_isConnecting = false;
|
||||
_startTime = DateTime.now();
|
||||
notifyListeners();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Connection error: $e');
|
||||
_isConnecting = false;
|
||||
_isConnected = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect
|
||||
Future<void> disconnect() async {
|
||||
try {
|
||||
if (_readCharacteristic != null) {
|
||||
await _readCharacteristic!.setNotifyValue(false);
|
||||
}
|
||||
if (_device != null) {
|
||||
await _device!.disconnect();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Disconnect error: $e');
|
||||
}
|
||||
|
||||
_isConnected = false;
|
||||
_isConnecting = false;
|
||||
_readCharacteristic = null;
|
||||
_writeCharacteristic = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Send command
|
||||
Future<bool> sendCommand(List<int> command) async {
|
||||
if (!_isConnected || _writeCharacteristic == null) return false;
|
||||
|
||||
try {
|
||||
if (_writeCharacteristic!.properties.writeWithoutResponse) {
|
||||
await _writeCharacteristic!.write(command, withoutResponse: true);
|
||||
} else {
|
||||
await _writeCharacteristic!.write(command);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Send command error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Try common start commands
|
||||
Future<void> tryStartCommands() async {
|
||||
final commands = [
|
||||
[0x01],
|
||||
[0x02],
|
||||
[0xFF],
|
||||
'START'.codeUnits,
|
||||
'START\n'.codeUnits,
|
||||
'ENABLE'.codeUnits,
|
||||
'ON'.codeUnits,
|
||||
];
|
||||
|
||||
for (final cmd in commands) {
|
||||
await sendCommand(cmd);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (_packetCount > 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle received data
|
||||
void _onDataReceived(List<int> data) {
|
||||
_packetCount++;
|
||||
_lastDataTime = DateTime.now();
|
||||
|
||||
final packet = DataPacket(
|
||||
data: List.from(data),
|
||||
timestamp: DateTime.now(),
|
||||
packetNumber: _packetCount,
|
||||
);
|
||||
|
||||
_dataPackets.add(packet);
|
||||
|
||||
// Keep only last 1000 packets
|
||||
if (_dataPackets.length > 1000) {
|
||||
_dataPackets.removeAt(0);
|
||||
}
|
||||
|
||||
// Detect stable weight (used for manual saving)
|
||||
final weight = packet.weightKg;
|
||||
if (weight != null) {
|
||||
final now = DateTime.now();
|
||||
if (_currentStableCandidate == null ||
|
||||
(weight - _currentStableCandidate!).abs() > 0.05) {
|
||||
_currentStableCandidate = weight;
|
||||
_lastWeightChangeTime = now;
|
||||
} else {
|
||||
// Weight hasn't changed significantly; just keep last change time
|
||||
// to measure stability duration via hasStableWeight getter.
|
||||
_lastWeightChangeTime ??= now;
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _saveStableMeasurement(double weightKg) async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final file = await _getMeasurementFile(now);
|
||||
|
||||
// Determine running number ("no") based on existing data lines.
|
||||
int no = 1;
|
||||
if (await file.exists()) {
|
||||
final lines = await file.readAsLines();
|
||||
final dataLines = lines.where(
|
||||
(l) => l.trim().isNotEmpty && !l.trim().startsWith('#'),
|
||||
);
|
||||
no = dataLines.length + 1;
|
||||
} else {
|
||||
// New file: write header first.
|
||||
await file.writeAsString(
|
||||
'no,timestamp,label_id,weight_kg\n',
|
||||
mode: FileMode.write,
|
||||
flush: true,
|
||||
);
|
||||
}
|
||||
|
||||
final ts = now.toIso8601String();
|
||||
final uuid = deviceAddress ?? '';
|
||||
|
||||
// Look up human-friendly label from SQLite; fall back to UUID.
|
||||
String labelId = uuid;
|
||||
if (uuid.isNotEmpty) {
|
||||
try {
|
||||
final device = await SiabDatabase().getByUuid(uuid);
|
||||
labelId = device?.label ?? uuid;
|
||||
} catch (_) {
|
||||
// Ignore DB errors; keep labelId as UUID.
|
||||
}
|
||||
}
|
||||
|
||||
final line = '$no,$ts,$labelId,$weightKg\n';
|
||||
await file.writeAsString(line, mode: FileMode.append, flush: true);
|
||||
debugPrint('Saved stable measurement: $line');
|
||||
} catch (e) {
|
||||
debugPrint('Error saving stable measurement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Public API: save current stable measurement when user presses a button.
|
||||
/// Returns true if a stable value existed and was saved.
|
||||
Future<bool> saveStableMeasurement() async {
|
||||
final weight = stableWeightKg;
|
||||
if (weight == null) {
|
||||
return false;
|
||||
}
|
||||
await _saveStableMeasurement(weight);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<File> _getMeasurementFile(DateTime now) async {
|
||||
// Prefer Downloads folder; fall back to app documents if unavailable.
|
||||
Directory dir;
|
||||
try {
|
||||
final downloads = await getDownloadsDirectory();
|
||||
dir = downloads ?? await getApplicationDocumentsDirectory();
|
||||
} catch (_) {
|
||||
dir = await getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
final datePart =
|
||||
'${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
return File(p.join(dir.path, 'siab_measurements_$datePart.csv'));
|
||||
}
|
||||
|
||||
/// Public helper: get today's measurement CSV file (may not exist yet).
|
||||
Future<File> getTodayMeasurementFile() {
|
||||
return _getMeasurementFile(DateTime.now());
|
||||
}
|
||||
|
||||
// Clear data
|
||||
void clearData() {
|
||||
_dataPackets.clear();
|
||||
_packetCount = 0;
|
||||
_startTime = null;
|
||||
_lastDataTime = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Auto connect
|
||||
Future<bool> autoConnect({String deviceName = SiabConfig.deviceName, int retries = 3}) async {
|
||||
for (int i = 0; i < retries; i++) {
|
||||
if (await scanForDevice(deviceName: deviceName)) {
|
||||
if (await connect()) {
|
||||
// Try start commands if no data after 2 seconds
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (_packetCount == 0) {
|
||||
await tryStartCommands();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (i < retries - 1) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class SiabScanResult {
|
||||
final String address;
|
||||
final String name;
|
||||
final BluetoothDevice device;
|
||||
|
||||
SiabScanResult({
|
||||
required this.address,
|
||||
required this.name,
|
||||
required this.device,
|
||||
});
|
||||
}
|
||||
|
||||
// Data packet model
|
||||
class DataPacket {
|
||||
final List<int> data;
|
||||
final DateTime timestamp;
|
||||
final int packetNumber;
|
||||
|
||||
DataPacket({
|
||||
required this.data,
|
||||
required this.timestamp,
|
||||
required this.packetNumber,
|
||||
});
|
||||
|
||||
String get hexString {
|
||||
return data.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
String get bytesString {
|
||||
// Match Python 'timbang' view: decimal bytes with zero padding
|
||||
return data.map((b) => b.toString().padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
// Parse as 32-bit integer (big endian)
|
||||
int? get value32BitUnsigned {
|
||||
if (data.length < 4) return null;
|
||||
return (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3];
|
||||
}
|
||||
|
||||
int? get value32BitSigned {
|
||||
final unsigned = value32BitUnsigned;
|
||||
if (unsigned == null) return null;
|
||||
if (unsigned > 0x7FFFFFFF) {
|
||||
return unsigned - 0x100000000;
|
||||
}
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
// Parse as 16-bit integer (big endian)
|
||||
int? get value16BitUnsigned {
|
||||
if (data.length < 2) return null;
|
||||
return (data[0] << 8) | data[1];
|
||||
}
|
||||
|
||||
int? get value16BitSigned {
|
||||
final unsigned = value16BitUnsigned;
|
||||
if (unsigned == null) return null;
|
||||
if (unsigned > 0x7FFF) {
|
||||
return unsigned - 0x10000;
|
||||
}
|
||||
return unsigned;
|
||||
}
|
||||
|
||||
String? get asciiString {
|
||||
try {
|
||||
// Web's utf8.decode does not support allowInvalid; use default behavior here.
|
||||
final str = utf8.decode(data);
|
||||
if (str.codeUnits.every((c) => c >= 32 && c < 127)) {
|
||||
return str;
|
||||
}
|
||||
} catch (e) {
|
||||
// Not ASCII
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get weight in KG (from 3rd byte, divided by 10)
|
||||
// Matching Python example: raw_weight = data[2]; weight_kg = raw_weight / 10.0
|
||||
double? get weightKg {
|
||||
if (data.length >= 3) {
|
||||
return data[2] / 10.0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/// Central configuration for SIAB-related constants.
|
||||
///
|
||||
/// The [deviceName] can be overridden at build time using:
|
||||
/// `--dart-define=SIAB_DEVICE_NAME=YourDeviceName`.
|
||||
class SiabConfig {
|
||||
const SiabConfig._();
|
||||
|
||||
/// Logical Bluetooth name to search for when scanning SIAB devices.
|
||||
static const String deviceName = String.fromEnvironment(
|
||||
'SIAB_DEVICE_NAME',
|
||||
defaultValue: 'SIAB',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/ble_service.dart';
|
||||
import '../siab_config.dart';
|
||||
|
||||
class ConnectionCard extends StatelessWidget {
|
||||
final BLEService ble;
|
||||
|
||||
const ConnectionCard({super.key, required this.ble});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
ble.isConnected
|
||||
? Icons.bluetooth_connected
|
||||
: Icons.bluetooth_disabled,
|
||||
color: ble.isConnected ? Colors.green : Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
ble.isConnected ? 'Connected' : 'Disconnected',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (ble.isScanning || ble.isConnecting)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (ble.isConnected) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (ble.deviceName != null)
|
||||
Text('Device: ${ble.deviceName}'),
|
||||
if (ble.deviceAddress != null)
|
||||
Text(
|
||||
'Address: ${ble.deviceAddress}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (!ble.isConnected && !ble.isScanning && !ble.isConnecting) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final success = await ble.autoConnect();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to find ${SiabConfig.deviceName} device',
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Scan & Connect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/ble_service.dart';
|
||||
|
||||
class DataPacketCard extends StatelessWidget {
|
||||
final DataPacket packet;
|
||||
|
||||
const DataPacketCard({super.key, required this.packet});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timeStr = '${packet.timestamp.hour.toString().padLeft(2, '0')}:'
|
||||
'${packet.timestamp.minute.toString().padLeft(2, '0')}:'
|
||||
'${packet.timestamp.second.toString().padLeft(2, '0')}';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: ExpansionTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
child: Text(
|
||||
'#${packet.packetNumber}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
packet.hexString,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||
),
|
||||
subtitle: Text('$timeStr • ${packet.data.length} bytes'),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSection('Hex', packet.hexString),
|
||||
const SizedBox(height: 8),
|
||||
// 'timbang' style: bytes shown in decimal (matching Python output)
|
||||
_buildSection('timbang', packet.bytesString),
|
||||
const SizedBox(height: 16),
|
||||
if (packet.value32BitUnsigned != null) ...[
|
||||
_buildValueRow(
|
||||
'32-bit Unsigned (BE)',
|
||||
packet.value32BitUnsigned.toString(),
|
||||
),
|
||||
_buildValueRow(
|
||||
'32-bit Signed (BE)',
|
||||
packet.value32BitSigned.toString(),
|
||||
),
|
||||
if (packet.value32BitUnsigned! > 0) ...[
|
||||
_buildValueRow(
|
||||
'As Float (÷100)',
|
||||
(packet.value32BitUnsigned! / 100.0).toStringAsFixed(2),
|
||||
),
|
||||
_buildValueRow(
|
||||
'As Float (÷1000)',
|
||||
(packet.value32BitUnsigned! / 1000.0).toStringAsFixed(3),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (packet.value16BitUnsigned != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildValueRow(
|
||||
'16-bit Unsigned (BE)',
|
||||
packet.value16BitUnsigned.toString(),
|
||||
),
|
||||
_buildValueRow(
|
||||
'16-bit Signed (BE)',
|
||||
packet.value16BitSigned.toString(),
|
||||
),
|
||||
],
|
||||
if (packet.asciiString != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildSection('ASCII', packet.asciiString!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
value,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValueRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/ble_service.dart';
|
||||
|
||||
class StatisticsCard extends StatelessWidget {
|
||||
final BLEService ble;
|
||||
|
||||
const StatisticsCard({super.key, required this.ble});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final duration = ble.streamingDuration;
|
||||
final durationStr = duration != null
|
||||
? '${duration.inMinutes}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'
|
||||
: '0:00';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_StatItem(
|
||||
icon: Icons.data_usage,
|
||||
label: 'Packets',
|
||||
value: '${ble.packetCount}',
|
||||
),
|
||||
_StatItem(
|
||||
icon: Icons.speed,
|
||||
label: 'Rate',
|
||||
value: '${ble.packetsPerSecond.toStringAsFixed(2)}/s',
|
||||
),
|
||||
_StatItem(
|
||||
icon: Icons.timer,
|
||||
label: 'Duration',
|
||||
value: durationStr,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _StatItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 24, color: Theme.of(context).primaryColor),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user