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