321 lines
11 KiB
Dart
321 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:mobile_monitoring/core/constants/constants.dart';
|
|
import 'package:mobile_monitoring/ui/widgets/graph_component_service.dart';
|
|
|
|
class GraphWidget extends StatefulWidget {
|
|
final String? userId;
|
|
const GraphWidget({super.key, this.userId});
|
|
|
|
@override
|
|
State<GraphWidget> createState() => _GraphWidgetState();
|
|
}
|
|
|
|
class _GraphWidgetState extends State<GraphWidget> {
|
|
final _graphService = GraphComponentService();
|
|
|
|
Widget _buildEmptyState({
|
|
required IconData icon,
|
|
required String title,
|
|
required String subtitle,
|
|
Color? iconColor,
|
|
VoidCallback? onRetry,
|
|
}) {
|
|
return SizedBox(
|
|
height: 400,
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, color: iconColor ?? AppColors.textSecondary, size: 50),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
subtitle,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (onRetry != null) ...[
|
|
const SizedBox(height: 16),
|
|
ElevatedButton.icon(
|
|
onPressed: onRetry,
|
|
icon: const Icon(Icons.refresh, size: 18),
|
|
label: const Text(AppStrings.retry),
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 20,
|
|
vertical: 10,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (widget.userId == null || widget.userId!.isEmpty) {
|
|
return _buildEmptyState(
|
|
icon: Icons.error_outline,
|
|
title: AppStrings.pleaseLogin,
|
|
subtitle: 'Sesi Anda telah berakhir, login kembali',
|
|
iconColor: AppColors.error,
|
|
);
|
|
}
|
|
|
|
return Center(
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: SizedBox(
|
|
width: 320,
|
|
child: StreamBuilder<Map<String, dynamic>?>(
|
|
stream: _graphService.cycleStream(widget.userId ?? ''),
|
|
builder: (context, cycleSnapshot) {
|
|
final startDate = _graphService.extractStartDate(
|
|
cycleSnapshot.data,
|
|
);
|
|
|
|
return StreamBuilder<List<Map<String, dynamic>>>(
|
|
stream: _graphService.historyStream(
|
|
limit: 500,
|
|
userId: widget.userId,
|
|
),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasError) {
|
|
final isAuthError = _graphService.isAuthError(
|
|
snapshot.error!,
|
|
);
|
|
return _buildEmptyState(
|
|
icon: Icons.error_outline,
|
|
title: isAuthError
|
|
? AppStrings.pleaseLogin
|
|
: AppStrings.failedToLoadData,
|
|
subtitle: isAuthError
|
|
? 'Sesi Anda telah berakhir, login kembali'
|
|
: 'Terjadi kesalahan saat mengambil data grafik',
|
|
iconColor: isAuthError ? AppColors.error : AppColors.warning,
|
|
onRetry: () => setState(() {}),
|
|
);
|
|
}
|
|
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const SizedBox(
|
|
height: 400,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
final docs = snapshot.data ?? [];
|
|
if (docs.isEmpty) {
|
|
return _buildEmptyState(
|
|
icon: Icons.agriculture,
|
|
title: AppStrings.startPlantingFirst,
|
|
subtitle: 'Mulai monitoring untuk melihat grafik data',
|
|
iconColor: AppColors.primary,
|
|
);
|
|
}
|
|
|
|
final prepared = _graphService.prepareGraphData(
|
|
docs,
|
|
startDate,
|
|
);
|
|
if (!prepared.success || prepared.data == null) {
|
|
return _buildEmptyState(
|
|
icon: Icons.calendar_today,
|
|
title: AppStrings.noDataAvailable,
|
|
subtitle:
|
|
'Data akan tampil setelah Anda memulai monitoring',
|
|
);
|
|
}
|
|
|
|
final graphData = prepared.data!;
|
|
final phSpots = List.generate(
|
|
graphData.phValues.length,
|
|
(i) => FlSpot(i.toDouble(), graphData.phValues[i]),
|
|
);
|
|
final nutrientSpots = List.generate(
|
|
graphData.nutrientValues.length,
|
|
(i) => FlSpot(i.toDouble(), graphData.nutrientValues[i]),
|
|
);
|
|
|
|
return Column(
|
|
children: [
|
|
_GraphCard(
|
|
title: 'Grafik pH 7 Hari Terakhir',
|
|
color: AppColors.secondaryDark,
|
|
spots: phSpots,
|
|
minY: 0.0,
|
|
maxY: 8.0,
|
|
yLabel: 'pH',
|
|
xLabels: graphData.dayLabels,
|
|
yDecimal: true,
|
|
),
|
|
const SizedBox(height: 24),
|
|
_GraphCard(
|
|
title: 'Grafik Nutrisi (TDS) 7 Hari Terakhir',
|
|
color: AppColors.info,
|
|
spots: nutrientSpots,
|
|
minY: 0,
|
|
maxY: 1400,
|
|
yLabel: 'ppm',
|
|
xLabels: graphData.dayLabels,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GraphCard extends StatelessWidget {
|
|
final String title;
|
|
final Color color;
|
|
final List<FlSpot> spots;
|
|
final double minY, maxY;
|
|
final String yLabel;
|
|
final List<String> xLabels;
|
|
final bool yDecimal;
|
|
|
|
const _GraphCard({
|
|
required this.title,
|
|
required this.color,
|
|
required this.spots,
|
|
required this.minY,
|
|
required this.maxY,
|
|
required this.yLabel,
|
|
required this.xLabels,
|
|
this.yDecimal = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.white,
|
|
borderRadius: BorderRadius.circular(30),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: AppColors.darkGray.withValues(alpha: 0.1),
|
|
spreadRadius: 2,
|
|
blurRadius: 10,
|
|
),
|
|
],
|
|
),
|
|
height: 280,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 20),
|
|
Expanded(
|
|
child: LineChart(
|
|
LineChartData(
|
|
gridData: FlGridData(
|
|
show: true,
|
|
drawVerticalLine: false,
|
|
horizontalInterval: yDecimal ? 0.5 : (maxY - minY) / 5,
|
|
getDrawingHorizontalLine: (_) =>
|
|
const FlLine(color: Color(0xffe7e8ec), strokeWidth: 1),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
reservedSize: 45,
|
|
interval: 1,
|
|
getTitlesWidget: (value, meta) {
|
|
final idx = value.toInt();
|
|
return SideTitleWidget(
|
|
axisSide: meta.axisSide,
|
|
child: idx >= 0 && idx < xLabels.length
|
|
? Text(
|
|
xLabels[idx],
|
|
style: const TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xff68737d),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
)
|
|
: const SizedBox.shrink(),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
interval: yDecimal ? 0.5 : (maxY - minY) / 5,
|
|
reservedSize: 36,
|
|
getTitlesWidget: (value, meta) {
|
|
return value >= minY && value <= maxY
|
|
? Text(
|
|
yDecimal
|
|
? value.toStringAsFixed(1)
|
|
: value.toStringAsFixed(0),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xff67727d),
|
|
),
|
|
)
|
|
: const SizedBox.shrink();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
minX: 0,
|
|
maxX: (xLabels.length - 1).toDouble(),
|
|
minY: minY,
|
|
maxY: maxY,
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: spots,
|
|
isCurved: true,
|
|
color: color,
|
|
barWidth: 3,
|
|
isStrokeCapRound: true,
|
|
dotData: const FlDotData(show: false),
|
|
belowBarData: BarAreaData(show: false),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|