699 lines
21 KiB
Dart
699 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:provider/provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
|
|
import '../database/siab_database.dart';
|
|
import '../services/ble_service.dart';
|
|
|
|
/// Screen that shows the current timbang (weight) value on a light,
|
|
/// futuristic dashboard.
|
|
/// When connected, title shows "TIMBANGAN [label] SIAB" from registered device.
|
|
class TimbangScreen extends StatelessWidget {
|
|
const TimbangScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final timbangTheme = Theme.of(context).copyWith(
|
|
brightness: Brightness.light,
|
|
colorScheme: ColorScheme.fromSeed(
|
|
seedColor: const Color(0xFF2563EB),
|
|
brightness: Brightness.light,
|
|
),
|
|
scaffoldBackgroundColor: const Color(0xFFF4F8FF),
|
|
);
|
|
|
|
return Theme(
|
|
data: timbangTheme,
|
|
child: Scaffold(
|
|
backgroundColor: const Color(0xFFF4F8FF),
|
|
body: 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 _TimbangBackground(
|
|
child: SafeArea(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 136),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: MediaQuery.sizeOf(context).height -
|
|
MediaQuery.paddingOf(context).vertical -
|
|
160,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
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
|
|
? 'SIAB SCALE - $label'
|
|
: 'SIAB SCALE';
|
|
|
|
return _HeaderSection(
|
|
title: title,
|
|
isConnected: isConnected,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
_WeightDisplayCard(
|
|
displayValue: displayValue,
|
|
isConnected: isConnected,
|
|
),
|
|
const SizedBox(height: 20),
|
|
_ActionPanel(
|
|
ble: ble,
|
|
isConnected: isConnected,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimbangBackground extends StatelessWidget {
|
|
const _TimbangBackground({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Color(0xFFFFFFFF),
|
|
Color(0xFFEFF8FF),
|
|
Color(0xFFF7FBFF),
|
|
Color(0xFFEAF1FF),
|
|
],
|
|
),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
const Positioned(
|
|
top: -90,
|
|
right: -80,
|
|
child: _GlowOrb(
|
|
size: 220,
|
|
color: Color(0x663AB7FF),
|
|
),
|
|
),
|
|
const Positioned(
|
|
bottom: 80,
|
|
left: -100,
|
|
child: _GlowOrb(
|
|
size: 240,
|
|
color: Color(0x553D5AFE),
|
|
),
|
|
),
|
|
const Positioned(
|
|
top: 120,
|
|
left: 28,
|
|
right: 28,
|
|
child: _LightBeam(),
|
|
),
|
|
const Positioned(
|
|
bottom: 188,
|
|
right: 32,
|
|
child: _SignalDash(),
|
|
),
|
|
Positioned.fill(child: child),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LightBeam extends StatelessWidget {
|
|
const _LightBeam();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(999),
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Colors.transparent,
|
|
const Color(0xFF38BDF8).withValues(alpha: 0.18),
|
|
const Color(0xFF6366F1).withValues(alpha: 0.12),
|
|
Colors.transparent,
|
|
],
|
|
),
|
|
),
|
|
child: const SizedBox(height: 2),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SignalDash extends StatelessWidget {
|
|
const _SignalDash();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.58),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.82)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.12),
|
|
blurRadius: 18,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: const SizedBox(width: 72, height: 8),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GlowOrb extends StatelessWidget {
|
|
const _GlowOrb({
|
|
required this.size,
|
|
required this.color,
|
|
});
|
|
|
|
final double size;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color,
|
|
blurRadius: 80,
|
|
spreadRadius: 40,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HeaderSection extends StatelessWidget {
|
|
const _HeaderSection({
|
|
required this.title,
|
|
required this.isConnected,
|
|
});
|
|
|
|
final String title;
|
|
final bool isConnected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.68),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.88)),
|
|
),
|
|
child: const Text(
|
|
'LIVE WEIGHING CONSOLE',
|
|
style: TextStyle(
|
|
color: Color(0xFF2563EB),
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 1.2,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: Color(0xFF1B2B4A),
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 1.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_StatusBadge(isConnected: isConnected),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusBadge extends StatelessWidget {
|
|
const _StatusBadge({required this.isConnected});
|
|
|
|
final bool isConnected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color =
|
|
isConnected ? const Color(0xFF00A878) : const Color(0xFFE45555);
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.78),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: color.withValues(alpha: 0.28)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withValues(alpha: 0.12),
|
|
blurRadius: 18,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
|
|
color: color,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
isConnected ? 'Scale connected' : 'Connect scale in Devices tab',
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WeightDisplayCard extends StatelessWidget {
|
|
const _WeightDisplayCard({
|
|
required this.displayValue,
|
|
required this.isConnected,
|
|
});
|
|
|
|
final String displayValue;
|
|
final bool isConnected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Color(0xFFFFFFFF),
|
|
Color(0xFFE2F4FF),
|
|
Color(0xFFF8FBFF),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(34),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.16),
|
|
blurRadius: 42,
|
|
offset: const Offset(0, 20),
|
|
),
|
|
BoxShadow(
|
|
color: const Color(0xFF22D3EE).withValues(alpha: 0.12),
|
|
blurRadius: 34,
|
|
offset: const Offset(0, -8),
|
|
),
|
|
],
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isCompact = constraints.maxWidth < 340;
|
|
|
|
return Container(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isCompact ? 18 : 24,
|
|
vertical: isCompact ? 28 : 36,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
borderRadius: BorderRadius.circular(30),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.spaceBetween,
|
|
children: [
|
|
const _MetricChip(
|
|
icon: Icons.speed,
|
|
label: 'Live load',
|
|
color: Color(0xFF2563EB),
|
|
),
|
|
_MetricChip(
|
|
icon: isConnected ? Icons.sensors : Icons.sensors_off,
|
|
label: isConnected ? 'Streaming' : 'Idle',
|
|
color: isConnected
|
|
? const Color(0xFF00A878)
|
|
: const Color(0xFFE45555),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: isCompact ? 22 : 30),
|
|
const Text(
|
|
'CURRENT WEIGHT',
|
|
style: TextStyle(
|
|
color: Color(0xFF64748B),
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: 2.0,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
displayValue,
|
|
style: TextStyle(
|
|
color: const Color(0xFF0F1B33),
|
|
fontSize: isCompact ? 78 : 94,
|
|
height: 0.9,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: -4,
|
|
fontFeatures: const [FontFeature.tabularFigures()],
|
|
shadows: const [
|
|
Shadow(
|
|
color: Color(0x5522D3EE),
|
|
blurRadius: 28,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: isCompact ? 7 : 10),
|
|
child: const Text(
|
|
'kg',
|
|
style: TextStyle(
|
|
color: Color(0xFF2563EB),
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w900,
|
|
letterSpacing: -0.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SizedBox(height: isCompact ? 22 : 28),
|
|
Container(
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFE0F2FE),
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: FractionallySizedBox(
|
|
alignment: Alignment.centerLeft,
|
|
widthFactor: isConnected ? 0.78 : 0.34,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(999),
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
const Color(0xFF22D3EE).withValues(alpha: 0.72),
|
|
const Color(0xFF2563EB).withValues(
|
|
alpha: isConnected ? 0.9 : 0.48,
|
|
),
|
|
const Color(0xFF6366F1).withValues(
|
|
alpha: isConnected ? 0.78 : 0.36,
|
|
),
|
|
],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF22D3EE).withValues(
|
|
alpha: isConnected ? 0.28 : 0.12,
|
|
),
|
|
blurRadius: 16,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MetricChip extends StatelessWidget {
|
|
const _MetricChip({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.color,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: color.withValues(alpha: 0.16)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: color, size: 16),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.2,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActionPanel extends StatelessWidget {
|
|
const _ActionPanel({
|
|
required this.ble,
|
|
required this.isConnected,
|
|
});
|
|
|
|
final BLEService ble;
|
|
final bool isConnected;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.8),
|
|
borderRadius: BorderRadius.circular(28),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.92)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF1D4ED8).withValues(alpha: 0.1),
|
|
blurRadius: 30,
|
|
offset: const Offset(0, 16),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (isConnected) ...[
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.spaceBetween,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
const Text(
|
|
'Ready to capture stable measurements',
|
|
style: TextStyle(
|
|
color: Color(0xFF334155),
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () async {
|
|
await ble.disconnect();
|
|
},
|
|
icon: const Icon(Icons.power_settings_new, size: 18),
|
|
label: const Text('Disconnect'),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: const Color(0xFFE45555),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
ElevatedButton.icon(
|
|
onPressed: ble.hasStableWeight
|
|
? () async {
|
|
final ok = await ble.saveStableMeasurement();
|
|
if (!context.mounted) return;
|
|
|
|
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_rounded),
|
|
label: const Text('Save stable value'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: const Color(0xFF2563EB),
|
|
foregroundColor: Colors.white,
|
|
disabledBackgroundColor: const Color(0xFFE2E8F0),
|
|
disabledForegroundColor: const Color(0xFF94A3B8),
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
try {
|
|
final file = await ble.getTodayMeasurementFile();
|
|
final exists = await file.exists();
|
|
if (!context.mounted) return;
|
|
|
|
if (!exists || await file.length() == 0) {
|
|
if (!context.mounted) return;
|
|
|
|
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) {
|
|
if (!context.mounted) return;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('gagal mengirim file: $e'),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
icon: const Icon(Icons.ios_share_rounded),
|
|
label: const Text('Share today CSV'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: const Color(0xFF2563EB),
|
|
side: const BorderSide(color: Color(0x663B82F6)),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
),
|
|
),
|
|
] else
|
|
const _DisconnectedHint(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DisconnectedHint extends StatelessWidget {
|
|
const _DisconnectedHint();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Row(
|
|
children: [
|
|
Icon(
|
|
Icons.info_outline_rounded,
|
|
color: Color(0xFF64748B),
|
|
),
|
|
SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'Open the Devices tab, scan nearby scales, then connect to start live weighing.',
|
|
style: TextStyle(
|
|
color: Color(0xFF475569),
|
|
fontSize: 13,
|
|
height: 1.35,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|