Files
SIAB-Scale/lib/screens/timbang_screen.dart
T

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,
),
),
),
],
);
}
}