From aa530a5d094b4b0de4fddc42db6326d75b352565 Mon Sep 17 00:00:00 2001 From: kleponijo <158453666+kleponijo@users.noreply.github.com> Date: Sat, 23 May 2026 06:55:35 +0700 Subject: [PATCH] oke --- .firebase/hosting.YnVpbGRcd2Vi.cache | 64 +-- .../evaporasi/views/evaporasi_screen.dart | 298 ++++++++++++- .../utils/excel/evaporasi_excel_service.dart | 419 ++++++++++++++++++ 3 files changed, 748 insertions(+), 33 deletions(-) create mode 100644 lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart diff --git a/.firebase/hosting.YnVpbGRcd2Vi.cache b/.firebase/hosting.YnVpbGRcd2Vi.cache index 08127be..1d8517b 100644 --- a/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -1,32 +1,44 @@ -version.json,1770352057364,f7f931727903f67c4b2ec2c38d4c5c4e2ec1be0c03071b8bff0c27f5be3b9e6e manifest.json,1768956954743,5edb32bebce763b2b7e55774ae4c5c4354a44df248387cc3c174e8710e85aaa1 -index.html,1770352031176,1fbdf325dceff31189cd7bcddde37e4eef1758ca2babb5cb593af432bd607799 -flutter_bootstrap.js,1770352031163,ed17fea025df6287ef713e3df7b02d14f66b937811e9ae377701382e28cfd589 flutter.js,1767911992319,970356511bee637e57348b9e01b3218a3d1d6c4add478fdc306033b4b3e21ef4 favicon.png,1768956954741,19880e004e8357a51f6a64f5d1fdc8828a6432fe9c781f8acb6b730eced2cd13 -flutter_service_worker.js,1770352060714,32aece2e5d9468eccf8a5a82c52aeabcea5af4ddffb3a2401fb1bf1f302ce9e6 -icons/Icon-512.png,1768956954742,1de493563deb283e6ad70f0d5a89cfd61e37343df3b919c9862c5a41699ac5bc -icons/Icon-maskable-192.png,1768956954742,f7ac56197638aec92f195c5344d15395c2cbf8a5ae74e846c5b4156b64f05e38 -icons/Icon-192.png,1768956954741,5fd961c51d8eb53baacac01f8e53743a76f66a43390a11f5df042e8c618ad64a -assets/FontManifest.json,1770352057804,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/shaders/stretch_effect.frag,1770352057964,586ff23669838dd33070077916f0b971a8800f67093ae8d6eed47e6cea50926f -assets/AssetManifest.bin.json,1770352057804,6763aaa55146ec9534c671aaf5a6db93265eaaa8082767ea02eb2ac9b09cbdaa -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1770352059003,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/AssetManifest.bin,1770352057804,93e425eb6f3c87a588cd708f748fc5bb80fa28bf34c6d814b2bd76daa6a6ed27 -assets/fonts/MaterialIcons-Regular.otf,1770352059003,400581a54bcbf9b63f6d7afd915d4713ce89ccf00ed00ac0edf5268f0de803a1 -assets/shaders/ink_sparkle.frag,1770352057964,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 icons/Icon-maskable-512.png,1768956954742,ea58e5c49bc4dcdc5db0a9fe2f25b2a3152f85ef9094f0d020de0aa318451a1d -canvaskit/skwasm_heavy.js,1767911992288,2e3aab5e61c0276a6704938362b4b3a4dc32bbb6d17d601e975e7ed03897b224 -canvaskit/skwasm.js,1767911992256,6f325d22924e678a9417a777104985babcee8f99080bf90855f26bfa9829a5ea -canvaskit/canvaskit.js,1767911992193,777116800268972381a4832500bb0aafd96ab2546075bd4d96a9025feb5c7555 -canvaskit/chromium/canvaskit.js,1767911992241,e0f423bfd63202fb53a4dd5cffe330143bfb9eabf5e4e8198bb605442a49d3e7 -assets/NOTICES,1770352057804,b877416ca37cc2a29c1969535c12cb9f74e9d950b21c916d75918e99ec0da3ed -canvaskit/chromium/canvaskit.js.symbols,1767911992241,4a8d181b0622d7a4b4254deb591c43f48dd89623c621dec6f84d0a0b2b8f77a7 -canvaskit/canvaskit.js.symbols,1767911992193,b1e22d35cbc9e3b4b89516a8462c9f1f73d86e6debc790833396dff78ad698a7 -canvaskit/skwasm.js.symbols,1767911992272,5d3dd3374664b262880564a3e59d64b150f4c632248fb93f913b4eebdeffca83 -canvaskit/skwasm_heavy.js.symbols,1767911992288,1259742821cf62bece358301f311840038fc2a0352b0c5827f8d2137d6197d08 -main.dart.js,1770352050513,8c2d615d9f867aa7edc73fc0f4d4b63eaeef9f3f3cd26b583e7938bbcc1b2e72 -canvaskit/skwasm.wasm,1767911992288,4776f3bfb61a7534e2260954b88b177c11a7c73644cd536c47fcaabe5d7dc79b -canvaskit/chromium/canvaskit.wasm,1767911992256,06f70f0703803c7329be30338b79530564ddc1ab2c3789d608431697a9a30ba2 +icons/Icon-maskable-192.png,1768956954742,f7ac56197638aec92f195c5344d15395c2cbf8a5ae74e846c5b4156b64f05e38 +icons/Icon-512.png,1768956954742,1de493563deb283e6ad70f0d5a89cfd61e37343df3b919c9862c5a41699ac5bc +icons/Icon-192.png,1768956954741,5fd961c51d8eb53baacac01f8e53743a76f66a43390a11f5df042e8c618ad64a canvaskit/skwasm_heavy.wasm,1767911992319,aa021afcfd815b925d5bad0debf468bef50192c458573e4570d6da4e12c2e186 +canvaskit/skwasm_heavy.js.symbols,1767911992288,1259742821cf62bece358301f311840038fc2a0352b0c5827f8d2137d6197d08 +canvaskit/skwasm_heavy.js,1767911992288,2e3aab5e61c0276a6704938362b4b3a4dc32bbb6d17d601e975e7ed03897b224 +canvaskit/skwasm.wasm,1767911992288,4776f3bfb61a7534e2260954b88b177c11a7c73644cd536c47fcaabe5d7dc79b +canvaskit/skwasm.js.symbols,1767911992272,5d3dd3374664b262880564a3e59d64b150f4c632248fb93f913b4eebdeffca83 +canvaskit/skwasm.js,1767911992256,6f325d22924e678a9417a777104985babcee8f99080bf90855f26bfa9829a5ea canvaskit/canvaskit.wasm,1767911992241,ac942f1920618b74351bc74bf1cf4ca078bde56e11692c0bd611e906bfc2d279 +canvaskit/canvaskit.js.symbols,1767911992193,b1e22d35cbc9e3b4b89516a8462c9f1f73d86e6debc790833396dff78ad698a7 +canvaskit/canvaskit.js,1767911992193,777116800268972381a4832500bb0aafd96ab2546075bd4d96a9025feb5c7555 +canvaskit/chromium/canvaskit.wasm,1767911992256,06f70f0703803c7329be30338b79530564ddc1ab2c3789d608431697a9a30ba2 +canvaskit/chromium/canvaskit.js.symbols,1767911992241,4a8d181b0622d7a4b4254deb591c43f48dd89623c621dec6f84d0a0b2b8f77a7 +canvaskit/chromium/canvaskit.js,1767911992241,e0f423bfd63202fb53a4dd5cffe330143bfb9eabf5e4e8198bb605442a49d3e7 +version.json,1779493407408,f7f931727903f67c4b2ec2c38d4c5c4e2ec1be0c03071b8bff0c27f5be3b9e6e +index.html,1779493279226,3ffbcfc0c9d82b851ce84d2b1bfc85cea0a34b077bdad955e7cf52643a3cf434 +flutter_bootstrap.js,1779493279204,ac46b098b9b222a709fa5ec23d1808db44a01d9b73d454c9a583d07a29cea348 +flutter_service_worker.js,1779493415284,df198e5b7cc03c86b760a3ec2fe6f78f064d02cf545c4fb4b4a6a4ae142c3020 +splash/img/light-1x.png,1779442242116,c4d52b9b4b1cfdd74194c0192c462e0f14934e1428ab7ff99165f08dc17f9010 +splash/img/dark-1x.png,1779442242114,c4d52b9b4b1cfdd74194c0192c462e0f14934e1428ab7ff99165f08dc17f9010 +assets/AssetManifest.bin.json,1779493407814,e23e10111498cc3b8b40e6549dcde13cffa6f45dc78e88194f4a7e284bb68294 +assets/FontManifest.json,1779493407814,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +assets/AssetManifest.bin,1779493407814,fe7319a65158e186661b7edfcc1c2b6c0a6bc6823b923a13e1e932cd66407fa2 +assets/shaders/stretch_effect.frag,1779493407940,586ff23669838dd33070077916f0b971a8800f67093ae8d6eed47e6cea50926f +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779493409456,85140ea453cd3e02bd93650fe8add6a724ae8c0ac73d0c491085b301e7bd21d2 +assets/shaders/ink_sparkle.frag,1779493407944,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/images/windy.png,1776157890991,b9d34ed004fff60a4fea365c23508823943f3f0793375f34ca9202069d9c9955 +assets/images/atmosfer.png,1776157890990,c3a9ddfa5676b5f0e1cf833f73c0f09c1dfb9d2e202906c1afcdd7a50dcf2506 +assets/fonts/MaterialIcons-Regular.otf,1779493409477,1c7547588fe9c202b730590aab269e1bae9988240caeba41da28091251a87dbe +assets/images/google-icon-logo.png,1776157890990,333cfafcd5c87738a4098359e18e02ff5848b2b1b819ec8aff29424c3f82d32b +splash/img/light-2x.png,1779442242116,e8cc8f611a58a4926632edfcd3cb7d95925b6895791632ea1c1739c4f454c8fa +splash/img/dark-2x.png,1779442242115,e8cc8f611a58a4926632edfcd3cb7d95925b6895791632ea1c1739c4f454c8fa +splash/img/light-3x.png,1779442242118,a1a4d566718037d913ad3287e9b7d1e6d0df8569c285ce3d3680db82b6a96812 +splash/img/dark-3x.png,1779442242115,a1a4d566718037d913ad3287e9b7d1e6d0df8569c285ce3d3680db82b6a96812 +splash/img/light-4x.png,1779442242118,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578 +assets/images/logo_klimatologi.png,1777103040742,e042ff556e356926fcd5ff92555519a569a71316ab2f7074f86f4ea4a215f325 +splash/img/dark-4x.png,1779442242116,09ff8858378bb70d5f4b0381eb98dc9a889e541aeb2dd7576da7d62f205de578 +assets/NOTICES,1779493407816,62963000bb4f8b466d5b97db77a0f9563f047723e134974be6bf170da0f821b2 +main.dart.js,1779493365556,91f95baa75b37e85885a3fc9b0fc953ed2245277abde8ca4682b0707131a2056 diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 4f87856..6a798a2 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -10,6 +10,8 @@ import '../../shared/widgets/export_pdf_button.dart'; import 'widgets/evaporasi_chart_widget.dart'; import 'widgets/evaporasi_range_selector.dart'; import 'widgets/evaporasi_history_list.dart'; +// TAMBAH baris ini setelah import yang sudah ada: +import '../../shared/utils/excel/evaporasi_excel_service.dart'; class EvaporasiScreen extends StatefulWidget { const EvaporasiScreen({super.key}); @@ -19,6 +21,280 @@ class EvaporasiScreen extends StatefulWidget { } class _EvaporasiScreenState extends State { +// ── Dialog export: nama file + date range ─────────────────── + Future _showExportDialog( + BuildContext context, EvaporasiState state) async { + DateTime firstDate = DateTime.now().subtract(const Duration(days: 365)); + DateTime lastDate = DateTime.now(); + if (state.history.isNotEmpty) { + final sorted = [...state.history] + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + firstDate = DateTime(sorted.first.timestamp.year, + sorted.first.timestamp.month, sorted.first.timestamp.day); + lastDate = DateTime(sorted.last.timestamp.year, + sorted.last.timestamp.month, sorted.last.timestamp.day); + } + + final nameController = TextEditingController( + text: 'evaporasi_${DateFormat('ddMMyyyy').format(DateTime.now())}', + ); + DateTime? dateFrom; + DateTime? dateTo; + + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) { + final fmt = DateFormat('dd MMM yyyy', 'id_ID'); + + Future pickRange() async { + final range = await showDateRangePicker( + context: ctx, + firstDate: firstDate, + lastDate: lastDate, + initialDateRange: dateFrom != null && dateTo != null + ? DateTimeRange(start: dateFrom!, end: dateTo!) + : null, + locale: const Locale('id', 'ID'), + builder: (ctx, child) => Theme( + data: Theme.of(ctx).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.blue.shade700, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ), + ); + if (range != null) { + setDialogState(() { + dateFrom = range.start; + dateTo = range.end; + }); + } + } + + return AlertDialog( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Icon(Icons.file_download_outlined, color: Colors.blue.shade700), + const SizedBox(width: 10), + const Text('Export Excel', + style: + TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), + ], + ), + content: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Nama file ────────────────────────────── + const Text('Nama file', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black54)), + const SizedBox(height: 6), + TextField( + controller: nameController, + decoration: InputDecoration( + hintText: 'nama_file', + suffixText: '.xlsx', + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: + BorderSide(color: Colors.blue.shade600, width: 1.5), + ), + ), + ), + const SizedBox(height: 20), + + // ── Range tanggal ────────────────────────── + const Text('Filter rentang tanggal', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.black54)), + const SizedBox(height: 6), + InkWell( + onTap: pickRange, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + Icon(Icons.date_range_rounded, + size: 18, color: Colors.blue.shade600), + const SizedBox(width: 10), + Expanded( + child: Text( + dateFrom != null && dateTo != null + ? '${fmt.format(dateFrom!)} → ${fmt.format(dateTo!)}' + : 'Semua data (tanpa filter)', + style: TextStyle( + fontSize: 13, + color: dateFrom != null + ? Colors.black87 + : Colors.grey.shade500, + ), + ), + ), + if (dateFrom != null) + GestureDetector( + onTap: () => setDialogState(() { + dateFrom = null; + dateTo = null; + }), + child: Icon(Icons.close_rounded, + size: 16, color: Colors.grey.shade500), + ), + ], + ), + ), + ), + if (dateFrom != null) ...[ + const SizedBox(height: 6), + Text( + '${state.history.where((e) { + final d = DateTime(e.timestamp.year, e.timestamp.month, + e.timestamp.day); + final from = DateTime( + dateFrom!.year, dateFrom!.month, dateFrom!.day); + final to = + DateTime(dateTo!.year, dateTo!.month, dateTo!.day); + return !d.isBefore(from) && !d.isAfter(to); + }).length} data dalam rentang ini', + style: + TextStyle(fontSize: 11, color: Colors.blue.shade600), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('Batal', + style: TextStyle(color: Colors.grey.shade600)), + ), + ElevatedButton.icon( + onPressed: () { + Navigator.pop(ctx); + _doExport( + context, + state, + nameController.text.trim(), + dateFrom, + dateTo, + ); + }, + icon: const Icon(Icons.download_rounded, size: 16), + label: const Text('Export'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ], + ); + }, + ), + ); + + nameController.dispose(); + } + +// ── Proses export setelah dialog ditutup ──────────────────── + Future _doExport( + BuildContext context, + EvaporasiState state, + String customName, + DateTime? dateFrom, + DateTime? dateTo, + ) async { + final messenger = ScaffoldMessenger.of(context); + final fileName = customName.isEmpty + ? 'evaporasi_${DateFormat('ddMMyyyy').format(DateTime.now())}' + : customName; + + try { + messenger.showSnackBar( + const SnackBar( + content: Row(children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)), + SizedBox(width: 12), + Text('Membuat file Excel...'), + ]), + duration: Duration(seconds: 30), + behavior: SnackBarBehavior.floating, + ), + ); + + await EvaporasiExcelService.export( + currentValue: state.currentValue, + temperature: state.temperature, + waterLevel: state.waterLevel, + acuanPagi: state.currentData?.acuanPagi ?? 0.0, + weatherStatus: state.weatherStatus, + history: state.history, + fileName: fileName, + dateFrom: dateFrom, + dateTo: dateTo, + ); + + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: Row(children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 8), + Expanded(child: Text('$fileName.xlsx berhasil dibuat!')), + ]), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + duration: const Duration(seconds: 3), + )); + } catch (e) { + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: Text('Gagal: $e'), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + )); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -32,6 +308,17 @@ class _EvaporasiScreenState extends State { backgroundColor: Colors.transparent, elevation: 0, foregroundColor: Colors.black, + actions: [ + BlocBuilder( + builder: (context, state) => IconButton( + tooltip: 'Export Excel', + icon: const Icon(Icons.file_download_outlined), + onPressed: state.history.isEmpty + ? null + : () => _showExportDialog(context, state), + ), + ), + ], ), body: BlocBuilder( builder: (context, state) { @@ -68,8 +355,7 @@ class _EvaporasiScreenState extends State { // ── Range Selector ────────────────────────── const Text( 'Tren Evaporasi & Suhu', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.bold), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), const EvaporasiRangeSelector(), @@ -249,8 +535,7 @@ class _EvaporasiScreenState extends State { case 'Normal': statusColor = Colors.orange; statusIcon = Icons.warning_amber_rounded; - warningText = - 'Sedang — evaporasi dalam batas normal, pantau kondisi.'; + warningText = 'Sedang — evaporasi dalam batas normal, pantau kondisi.'; break; case 'Tinggi': statusColor = Colors.red; @@ -282,8 +567,7 @@ class _EvaporasiScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Status Evaporasi', - style: - TextStyle(fontSize: 12, color: Colors.grey)), + style: TextStyle(fontSize: 12, color: Colors.grey)), const SizedBox(height: 4), Text( state.weatherStatus, @@ -307,4 +591,4 @@ class _EvaporasiScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart b/lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart new file mode 100644 index 0000000..e9393ee --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/evaporasi_excel_service.dart @@ -0,0 +1,419 @@ +// =========================================================== +// evaporasi_excel_service.dart +// Lokasi: lib/screens/monitoring/shared/utils/excel/ +// =========================================================== + +import 'dart:typed_data'; +import 'package:excel/excel.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +import 'excel_saver_stub.dart' + if (dart.library.html) 'excel_saver_web.dart' + if (dart.library.io) 'excel_saver_mobile.dart'; + +class EvaporasiExcelService { + // ── Format helper ────────────────────────────────────────── + static final _dateFmt = DateFormat('dd/MM/yyyy HH:mm:ss', 'id_ID'); + static final _headerFmt = DateFormat('dd MMMM yyyy, HH:mm', 'id_ID'); + + // ── Warna tema ───────────────────────────────────────────── + static const _colorHeader = '1A4A8C'; // biru tua + static const _colorSubHead = '2E75B6'; // biru medium + static const _colorNormal = 'E8F4FD'; // biru sangat muda + static const _colorTinggi = 'F8D7DA'; // merah muda + static const _colorRendah = 'D1ECF1'; // biru muda + static const _colorWhite = 'FFFFFF'; + static const _colorWaspada = 'FFF3CD'; // kuning muda + + /// Export data evaporasi ke file Excel dan buka share sheet + static Future export({ + required double currentValue, + required double temperature, + required double waterLevel, + required double acuanPagi, + required String weatherStatus, + required List history, + required String fileName, + DateTime? dateFrom, + DateTime? dateTo, + }) async { + final excel = Excel.createExcel(); + excel.delete('Sheet1'); + + _buildSummarySheet( + excel, + currentValue, + temperature, + waterLevel, + acuanPagi, + weatherStatus, + history, + dateFrom, + dateTo, + ); + _buildHistorySheet(excel, history, dateFrom, dateTo); + + // ── Simpan file ───────────────────────────────────────── + final bytes = excel.save(); + if (bytes == null) throw Exception('Gagal membuat file Excel'); + + final safeName = fileName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + await saveAndShareExcel(Uint8List.fromList(bytes), '$safeName.xlsx'); + } + + // ════════════════════════════════════════════════════════════ + // SHEET 1: Ringkasan + // ════════════════════════════════════════════════════════════ + static void _buildSummarySheet( + Excel excel, + double currentValue, + double temperature, + double waterLevel, + double acuanPagi, + String weatherStatus, + List history, + DateTime? dateFrom, + DateTime? dateTo, + ) { + final sheet = excel['Ringkasan']; + + // -- Judul -- + _setCell(sheet, 0, 0, 'DATA EVAPORASI – WEATHER STATION', + bold: true, + fontSize: 14, + bgColor: _colorHeader, + fontColor: _colorWhite); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0), + ); + + // -- Metadata -- + _setCell(sheet, 1, 0, 'Diekspor pada', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell(sheet, 1, 1, _headerFmt.format(DateTime.now()), + bgColor: _colorNormal); + + if (dateFrom != null && dateTo != null) { + _setCell(sheet, 2, 0, 'Filter tanggal', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + _setCell( + sheet, + 2, + 1, + '${DateFormat('dd MMMM yyyy', 'id_ID').format(dateFrom)} → ${DateFormat('dd MMMM yyyy', 'id_ID').format(dateTo)}', + bgColor: _colorNormal, + ); + } + + // -- Nilai saat ini -- + _setCell(sheet, 4, 0, 'DATA SAAT INI', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 4), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 4), + ); + + _setCell(sheet, 5, 0, 'Evaporasi (mm)', bold: true); + _setCellDouble(sheet, 5, 1, currentValue); + + _setCell(sheet, 6, 0, 'Suhu Air (°C)', bold: true); + if (temperature < 0) { + _setCell(sheet, 6, 1, '-'); + } else { + _setCellDouble(sheet, 6, 1, temperature); + } + + _setCell(sheet, 7, 0, 'Tinggi Air (cm)', bold: true); + _setCellDouble(sheet, 7, 1, waterLevel); + + _setCell(sheet, 8, 0, 'Acuan Air Pagi (cm)', bold: true); + _setCellDouble(sheet, 8, 1, acuanPagi); + + _setCell(sheet, 9, 0, 'Status', bold: true); + _setCell(sheet, 9, 1, weatherStatus, + bgColor: _statusBgColor(weatherStatus)); + + // -- Statistik ringkasan -- + if (history.isNotEmpty) { + final evapValues = history.map((e) => e.evaporasi).toList(); + final tempValues = history + .map((e) => e.suhu) + .where((s) => s >= 0) + .toList(); + + final evapAvg = evapValues.reduce((a, b) => a + b) / evapValues.length; + final evapMax = evapValues.reduce((a, b) => a > b ? a : b); + final evapMin = evapValues.reduce((a, b) => a < b ? a : b); + + _setCell(sheet, 11, 0, 'STATISTIK HISTORY', + bold: true, bgColor: _colorSubHead, fontColor: _colorWhite); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 11), + CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 11), + ); + + _setCell(sheet, 12, 0, 'Jumlah data', bold: true); + _setCellInt(sheet, 12, 1, history.length); + + _setCell(sheet, 13, 0, 'Rata-rata evaporasi (mm)', bold: true); + _setCellDouble(sheet, 13, 1, evapAvg); + + _setCell(sheet, 14, 0, 'Evaporasi maksimum (mm)', bold: true); + _setCellDouble(sheet, 14, 1, evapMax); + + _setCell(sheet, 15, 0, 'Evaporasi minimum (mm)', bold: true); + _setCellDouble(sheet, 15, 1, evapMin); + + if (tempValues.isNotEmpty) { + final tempAvg = + tempValues.reduce((a, b) => a + b) / tempValues.length; + final tempMax = tempValues.reduce((a, b) => a > b ? a : b); + final tempMin = tempValues.reduce((a, b) => a < b ? a : b); + + _setCell(sheet, 16, 0, 'Rata-rata suhu (°C)', bold: true); + _setCellDouble(sheet, 16, 1, tempAvg); + + _setCell(sheet, 17, 0, 'Suhu maksimum (°C)', bold: true); + _setCellDouble(sheet, 17, 1, tempMax); + + _setCell(sheet, 18, 0, 'Suhu minimum (°C)', bold: true); + _setCellDouble(sheet, 18, 1, tempMin); + } + } + + // Set lebar kolom + sheet.setColumnWidth(0, 26); + sheet.setColumnWidth(1, 22); + sheet.setColumnWidth(2, 18); + sheet.setColumnWidth(3, 18); + } + + // ════════════════════════════════════════════════════════════ + // SHEET 2: Data History + // ════════════════════════════════════════════════════════════ + static void _buildHistorySheet( + Excel excel, + List history, + DateTime? dateFrom, + DateTime? dateTo, + ) { + final sheet = excel['Data History']; + + // -- Header kolom -- + final headers = [ + 'No', + 'Tanggal', + 'Waktu', + 'Evaporasi (mm)', + 'Tinggi Air (cm)', + 'Suhu (°C)', + 'Acuan Pagi (cm)', + 'Status', + ]; + for (var i = 0; i < headers.length; i++) { + _setCell(sheet, 0, i, headers[i], + bold: true, + bgColor: _colorHeader, + fontColor: _colorWhite, + centered: true); + } + + // Filter berdasarkan rentang tanggal (inklusif kedua ujung) + final data = (dateFrom != null && dateTo != null) + ? history.where((e) { + final d = DateTime( + e.timestamp.year, e.timestamp.month, e.timestamp.day); + final from = + DateTime(dateFrom.year, dateFrom.month, dateFrom.day); + final to = DateTime(dateTo.year, dateTo.month, dateTo.day); + return !d.isBefore(from) && !d.isAfter(to); + }).toList() + : history; + + // Urutkan terbaru di atas + final sorted = [...data] + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + // -- Isi baris data -- + for (var i = 0; i < sorted.length; i++) { + final item = sorted[i]; + final rowIdx = i + 1; + final status = _getStatus(item.evaporasi); + final bgColor = i.isEven ? _colorNormal : _colorWhite; + final statusBg = _statusBgColor(status); + + _setCellInt(sheet, rowIdx, 0, i + 1, + bgColor: bgColor, centered: true); + _setCell( + sheet, + rowIdx, + 1, + DateFormat('dd/MM/yyyy', 'id_ID').format(item.timestamp), + bgColor: bgColor); + _setCell( + sheet, + rowIdx, + 2, + DateFormat('HH:mm:ss').format(item.timestamp), + bgColor: bgColor, + centered: true); + _setCellDouble(sheet, rowIdx, 3, item.evaporasi, + bgColor: bgColor, centered: true); + _setCellDouble(sheet, rowIdx, 4, item.tinggiAir, + bgColor: bgColor, centered: true); + // Suhu: tampilkan '-' jika sensor error (< 0) + if (item.suhu < 0) { + _setCell(sheet, rowIdx, 5, '-', bgColor: bgColor, centered: true); + } else { + _setCellDouble(sheet, rowIdx, 5, item.suhu, + bgColor: bgColor, centered: true); + } + _setCellDouble(sheet, rowIdx, 6, item.acuanPagi, + bgColor: bgColor, centered: true); + _setCell(sheet, rowIdx, 7, status, + bgColor: statusBg, centered: true, bold: true); + } + + // -- Footer jika kosong -- + if (sorted.isEmpty) { + _setCell(sheet, 1, 0, 'Tidak ada data untuk tanggal yang dipilih', + bgColor: _colorWaspada, centered: true); + sheet.merge( + CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 1), + CellIndex.indexByColumnRow(columnIndex: 7, rowIndex: 1), + ); + } + + // Set lebar kolom + sheet.setColumnWidth(0, 6); + sheet.setColumnWidth(1, 14); + sheet.setColumnWidth(2, 12); + sheet.setColumnWidth(3, 18); + sheet.setColumnWidth(4, 18); + sheet.setColumnWidth(5, 14); + sheet.setColumnWidth(6, 18); + sheet.setColumnWidth(7, 12); + } + + // ════════════════════════════════════════════════════════════ + // HELPER CELLS + // ════════════════════════════════════════════════════════════ + static void _setCell( + Sheet sheet, + int row, + int col, + String value, { + bool bold = false, + double fontSize = 11, + String bgColor = _colorWhite, + String fontColor = '000000', + bool centered = false, + }) { + final cell = sheet + .cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = TextCellValue(value); + cell.cellStyle = CellStyle( + bold: bold, + fontSize: fontSize.toInt(), + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + fontColorHex: ExcelColor.fromHexString('#$fontColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Left, + verticalAlign: VerticalAlign.Center, + textWrapping: TextWrapping.WrapText, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static void _setCellDouble( + Sheet sheet, + int row, + int col, + double value, { + String bgColor = _colorWhite, + bool centered = false, + }) { + final cell = sheet + .cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = DoubleCellValue(double.parse(value.toStringAsFixed(4))); + cell.cellStyle = CellStyle( + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Right, + verticalAlign: VerticalAlign.Center, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static void _setCellInt( + Sheet sheet, + int row, + int col, + int value, { + String bgColor = _colorWhite, + bool centered = false, + }) { + final cell = sheet + .cell(CellIndex.indexByColumnRow(columnIndex: col, rowIndex: row)); + cell.value = IntCellValue(value); + cell.cellStyle = CellStyle( + backgroundColorHex: ExcelColor.fromHexString('#$bgColor'), + horizontalAlign: + centered ? HorizontalAlign.Center : HorizontalAlign.Right, + verticalAlign: VerticalAlign.Center, + leftBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + rightBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + topBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + bottomBorder: Border( + borderStyle: BorderStyle.Thin, + borderColorHex: ExcelColor.fromHexString('#CCCCCC')), + ); + } + + static String _statusBgColor(String status) { + switch (status) { + case 'Tinggi': + return _colorTinggi; + case 'Normal': + return _colorWaspada; + default: // Rendah + return _colorRendah; + } + } + + static String _getStatus(double evaporasi) { + if (evaporasi > 10.0) return 'Tinggi'; + if (evaporasi >= 2.0) return 'Normal'; + return 'Rendah'; + } +}