diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..6bb5e2c --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/app_view.dart b/lib/app_view.dart index 0320ddb..f8b4763 100644 --- a/lib/app_view.dart +++ b/lib/app_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:klimatologiot/blocs/authentication_bloc/authentication_bloc.dart'; - +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:klimatologiot/screens/auth/views/welcome_screen.dart'; import 'package:klimatologiot/screens/home/views/home_screen.dart'; @@ -13,6 +13,15 @@ class MyAppView extends StatelessWidget { return MaterialApp( title: 'Klimatologi', debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('id', 'ID'), + Locale('en'), + ], theme: ThemeData( colorScheme: ColorScheme.light( surface: Colors.grey.shade100, diff --git a/lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart b/lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart new file mode 100644 index 0000000..e0e9b16 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/excel_saver_mobile.dart @@ -0,0 +1,19 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Mobile (Android/iOS): simpan ke temp dir lalu buka share sheet +Future saveAndShareExcel(Uint8List bytes, String fileName) async { + final dir = await getTemporaryDirectory(); + final file = File('${dir.path}/$fileName'); + await file.writeAsBytes(bytes, flush: true); + + await Share.shareXFiles( + [XFile( + file.path, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + )], + subject: fileName, + ); +} diff --git a/lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart b/lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart new file mode 100644 index 0000000..8c86ee5 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/excel_saver_stub.dart @@ -0,0 +1,6 @@ +import 'dart:typed_data'; + +// Stub — tidak pernah dipanggil langsung, digantikan web atau mobile +Future saveAndShareExcel(Uint8List bytes, String fileName) async { + throw UnsupportedError('Platform tidak didukung'); +} diff --git a/lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart b/lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart new file mode 100644 index 0000000..eb0bc20 --- /dev/null +++ b/lib/screens/monitoring/shared/utils/excel/excel_saver_web.dart @@ -0,0 +1,16 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html; +import 'dart:typed_data'; + +/// Web: langsung trigger download lewat browser +Future saveAndShareExcel(Uint8List bytes, String fileName) async { + final blob = html.Blob( + [bytes], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); +} diff --git a/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart b/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart index 1ec30f0..445f846 100644 --- a/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart +++ b/lib/screens/monitoring/shared/utils/excel/wind_speed_excel_service.dart @@ -8,10 +8,12 @@ // share_plus: ^10.0.2 // =========================================================== -import 'dart:io'; +import 'dart:typed_data'; import 'package:excel/excel.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:intl/intl.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; +import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart index 9a995a7..e618a2c 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_bloc.dart @@ -19,38 +19,32 @@ class WindSpeedBloc extends Bloc { final NotificationBloc _notificationBloc; StreamSubscription? _subscription; - WindSpeedBloc( - {required MonitoringRepository repository, - required NotificationBloc notificationBloc}) - : _repository = repository, + WindSpeedBloc({ + required MonitoringRepository repository, + required NotificationBloc notificationBloc, + }) : _repository = repository, _notificationBloc = notificationBloc, super(const WindSpeedState()) { - /// 🔥 START MONITORING on(_onStarted, transformer: restartable()); - - /// 🔥 REALTIME UPDATE on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated); - - /// 🔥 CHANGE PERIOD on(_onPeriodChanged); + on(_onDateFilterChanged); // ← baru } - /// ========================= - /// 🚀 START - /// ========================= + // ════════════════════════════════════════════════════════════ + // START + // ════════════════════════════════════════════════════════════ Future _onStarted( WatchWindSpeedStarted event, Emitter emit, ) async { emit(state.copyWith(isLoading: true)); - /// 1. Ambil history SEKALI final history = await _repository.getSensorHistory( 'anemometer/history', (json) => MyWindSpeed.fromJson(json), ); - // ✅ Ini saja yang benar final dailyGraph = TimeSeriesMapper.smooth( TimeSeriesMapper.toDaily( data: history, @@ -58,7 +52,6 @@ class WindSpeedBloc extends Bloc { getValue: (e) => e.speed, ), ); - final weekly = TimeSeriesMapper.smooth( TimeSeriesMapper.toWeekly( data: history, @@ -66,7 +59,6 @@ class WindSpeedBloc extends Bloc { getValue: (e) => e.speed, ), ); - final monthly = TimeSeriesMapper.smooth( TimeSeriesMapper.toMonthly( data: history, @@ -75,25 +67,22 @@ class WindSpeedBloc extends Bloc { ), ); - // Cek kondisi awal dari history if (history.isNotEmpty) { _emitWindAlert(history.last.speed); } emit(state.copyWith( history: history, + filteredHistory: history, // awal = semua data dailySpeeds: dailyGraph, weeklySpeeds: weekly, monthlySpeeds: monthly, isLoading: false, - alertLevel: history.isNotEmpty // ← tambah ini - ? _getAlertLevel(history.last.speed) - : "Normal", + alertLevel: + history.isNotEmpty ? _getAlertLevel(history.last.speed) : 'Normal', )); - /// 3. Start realtime stream (manual subscription) await _subscription?.cancel(); - _subscription = _repository .getSensorStream( 'anemometer/realtime', @@ -104,9 +93,9 @@ class WindSpeedBloc extends Bloc { }); } - /// ========================= - /// ⚡ REALTIME UPDATE - /// ========================= + // ════════════════════════════════════════════════════════════ + // REALTIME UPDATE + // ════════════════════════════════════════════════════════════ void _onRealtimeUpdated( _WindSpeedRealtimeUpdated event, Emitter emit, @@ -117,13 +106,11 @@ class WindSpeedBloc extends Bloc { if (index < updated.length) { final lastValue = updated[index]; - - /// 🔥 ANTI SPIKE + SMOOTHING if (lastValue != 0) { if ((newValue - lastValue).abs() > 15) { - newValue = lastValue; // buang spike + newValue = lastValue; } else { - newValue = (lastValue + newValue) / 2; // smoothing + newValue = (lastValue + newValue) / 2; } } updated[index] = newValue.clamp(0, 100); @@ -138,26 +125,24 @@ class WindSpeedBloc extends Bloc { )); } - /// ========================= - /// 📊 CHANGE PERIOD - /// ========================= + // ════════════════════════════════════════════════════════════ + // PERIOD CHANGED + // ════════════════════════════════════════════════════════════ Future _onPeriodChanged( WindSpeedPeriodChanged event, Emitter emit, ) async { emit(state.copyWith(selectedPeriod: event.period)); - final history = state.history; List raw; - - if (event.period == "Minggu Ini") { + if (event.period == 'Minggu Ini') { raw = TimeSeriesMapper.toWeekly( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.speed, ); - } else if (event.period == "Bulan Ini") { + } else if (event.period == 'Bulan Ini') { raw = TimeSeriesMapper.toMonthly( data: history, getTime: (e) => e.timestamp, @@ -171,35 +156,60 @@ class WindSpeedBloc extends Bloc { ); } - /// 🔥 baru di-smooth final updatedGraph = TimeSeriesMapper.smooth(raw); emit(state.copyWith( dailySpeeds: - event.period == "Hari Ini" ? updatedGraph : state.dailySpeeds, + event.period == 'Hari Ini' ? updatedGraph : state.dailySpeeds, weeklySpeeds: - event.period == "Minggu Ini" ? updatedGraph : state.weeklySpeeds, + event.period == 'Minggu Ini' ? updatedGraph : state.weeklySpeeds, monthlySpeeds: - event.period == "Bulan Ini" ? updatedGraph : state.monthlySpeeds, + event.period == 'Bulan Ini' ? updatedGraph : state.monthlySpeeds, isLoading: false, )); } - @override - Future close() async { - await _subscription?.cancel(); - return super.close(); + // ════════════════════════════════════════════════════════════ + // DATE FILTER CHANGED ← baru + // ════════════════════════════════════════════════════════════ + void _onDateFilterChanged( + WindSpeedDateFilterChanged event, + Emitter emit, + ) { + final date = event.date; + final allHistory = state.history; + + if (date == null) { + // Reset: tampilkan semua + emit(state.copyWith( + filteredHistory: allHistory, + clearSelectedDate: true, + )); + return; + } + + // Filter data yang tanggalnya sama dengan [date] + final filtered = allHistory.where((item) { + return item.timestamp.year == date.year && + item.timestamp.month == date.month && + item.timestamp.day == date.day; + }).toList(); + + emit(state.copyWith( + filteredHistory: filtered, + selectedDate: date, + )); } + // ════════════════════════════════════════════════════════════ + // HELPERS + // ════════════════════════════════════════════════════════════ String _getAlertLevel(double speed) { - if (speed >= _kWindDanger) return "Bahaya"; - if (speed >= _kWindWarning) return "Waspada"; - return "Normal"; + if (speed >= _kWindDanger) return 'Bahaya'; + if (speed >= _kWindWarning) return 'Waspada'; + return 'Normal'; } -// ========================= - // HELPER: kirim alert ke NotificationBloc - // ========================= void _emitWindAlert(double speed) { final AlertSeverity severity; final String message; @@ -211,7 +221,7 @@ class WindSpeedBloc extends Bloc { severity = AlertSeverity.warning; message = 'Kecepatan angin ${speed.toStringAsFixed(1)} m/s — waspada'; } else { - severity = AlertSeverity.info; // normal → bersihkan alert + severity = AlertSeverity.info; message = ''; } @@ -225,16 +235,19 @@ class WindSpeedBloc extends Bloc { ), )); } + + @override + Future close() async { + await _subscription?.cancel(); + return super.close(); + } } -/// ========================= -/// 🔒 INTERNAL EVENT (PRIVATE) -/// ========================= +/// Internal event — tidak diekspos ke luar class _WindSpeedRealtimeUpdated extends WindSpeedEvent { final MyWindSpeed data; - const _WindSpeedRealtimeUpdated(this.data); @override - List get props => [data]; + List get props => [data]; } diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart index e966119..38430af 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_event.dart @@ -1,16 +1,32 @@ part of 'wind_speed_bloc.dart'; -sealed class WindSpeedEvent extends Equatable { +abstract class WindSpeedEvent extends Equatable { const WindSpeedEvent(); + @override - List get props => []; + List get props => []; } -// Perintah untuk mulai dengerin data Firebase -class WatchWindSpeedStarted extends WindSpeedEvent {} +/// Mulai watch stream realtime + ambil history +class WatchWindSpeedStarted extends WindSpeedEvent { + const WatchWindSpeedStarted(); +} -// Perintah kalau user ganti filter (Hari Ini, Minggu Ini, dll) +/// Ganti periode grafik: "Hari Ini" | "Minggu Ini" | "Bulan Ini" class WindSpeedPeriodChanged extends WindSpeedEvent { final String period; const WindSpeedPeriodChanged(this.period); + + @override + List get props => [period]; +} + +/// Filter history berdasarkan tanggal. +/// Kirim [date] = null untuk reset (tampilkan semua) +class WindSpeedDateFilterChanged extends WindSpeedEvent { + final DateTime? date; + const WindSpeedDateFilterChanged(this.date); + + @override + List get props => [date]; } diff --git a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart index 7ff5572..218aa97 100644 --- a/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart +++ b/lib/screens/monitoring/wind_speed/blocs/wind_speed_state.dart @@ -1,57 +1,73 @@ part of 'wind_speed_bloc.dart'; class WindSpeedState extends Equatable { + final bool isLoading; final double currentSpeed; + final String alertLevel; final String selectedPeriod; + + // ── Graf ───────────────────────────────────────────────────── final List dailySpeeds; final List weeklySpeeds; final List monthlySpeeds; - final bool isLoading; - final List history; - final String alertLevel; // "Normal" | "Waspada" | "Bahaya" + + // ── History ────────────────────────────────────────────────── + final List history; // semua data mentah + final List filteredHistory; // setelah difilter tanggal + final DateTime? selectedDate; // null = tampilkan semua const WindSpeedState({ + this.isLoading = false, this.currentSpeed = 0.0, - this.selectedPeriod = "Hari Ini", + this.alertLevel = 'Normal', + this.selectedPeriod = 'Hari Ini', this.dailySpeeds = const [], - this.isLoading = true, - this.history = const [], - this.monthlySpeeds = const [], this.weeklySpeeds = const [], - this.alertLevel = "Normal", + this.monthlySpeeds = const [], + this.history = const [], + this.filteredHistory = const [], + this.selectedDate, }); WindSpeedState copyWith({ + bool? isLoading, double? currentSpeed, + String? alertLevel, String? selectedPeriod, List? dailySpeeds, List? weeklySpeeds, List? monthlySpeeds, - bool? isLoading, List? history, - String? alertLevel, + List? filteredHistory, + DateTime? selectedDate, + bool clearSelectedDate = false, }) { return WindSpeedState( + isLoading: isLoading ?? this.isLoading, currentSpeed: currentSpeed ?? this.currentSpeed, + alertLevel: alertLevel ?? this.alertLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, dailySpeeds: dailySpeeds ?? this.dailySpeeds, weeklySpeeds: weeklySpeeds ?? this.weeklySpeeds, monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds, - isLoading: isLoading ?? this.isLoading, history: history ?? this.history, - alertLevel: alertLevel ?? this.alertLevel, + filteredHistory: filteredHistory ?? this.filteredHistory, + selectedDate: + clearSelectedDate ? null : (selectedDate ?? this.selectedDate), ); } @override - List get props => [ + List get props => [ + isLoading, currentSpeed, + alertLevel, selectedPeriod, dailySpeeds, weeklySpeeds, monthlySpeeds, - isLoading, history, - alertLevel, + filteredHistory, + selectedDate, ]; } diff --git a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart index f741f4c..bdd1518 100644 --- a/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart +++ b/lib/screens/monitoring/wind_speed/views/wind_speed_screen.dart @@ -1,23 +1,121 @@ +// =========================================================== +// wind_speed_screen.dart +// Lokasi: lib/screens/monitoring/wind_speed/views/ +// =========================================================== + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; import '../blocs/wind_speed_bloc.dart'; -import './widgets/wind_speed_chart_widget.dart'; +import 'widgets/wind_speed_chart_widget.dart'; import 'widgets/period_selector.dart'; - -import '../../shared/utils/pdf/pdf_export_service.dart'; -import '../../shared/widgets/export_pdf_button.dart'; import '../../device_setup/views/device_setup_screen.dart'; +import '../../shared/utils/excel/wind_speed_excel_service.dart'; -class WindSpeedScreen extends StatelessWidget { +class WindSpeedScreen extends StatefulWidget { const WindSpeedScreen({super.key}); + @override + State createState() => _WindSpeedScreenState(); +} + +class _WindSpeedScreenState extends State { + // ── Date picker ───────────────────────────────────────────── + Future _pickDate(BuildContext context, WindSpeedState 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 picked = await showDatePicker( + context: context, + initialDate: state.selectedDate ?? lastDate, + firstDate: firstDate, + lastDate: lastDate, + locale: const Locale('id', 'ID'), + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.blue.shade700, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ), + ); + + if (picked != null && context.mounted) { + context.read().add(WindSpeedDateFilterChanged(picked)); + } + } + + // ── Export Excel ───────────────────────────────────────────── + Future _exportExcel(BuildContext context, WindSpeedState state) async { + final messenger = ScaffoldMessenger.of(context); + 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 WindSpeedExcelService.export( + currentSpeed: state.currentSpeed, + alertLevel: state.alertLevel, + period: state.selectedPeriod, + history: state.history, + filterDate: state.selectedDate, + ); + + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(SnackBar( + content: const Row(children: [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 8), + Text('File Excel 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( backgroundColor: Colors.grey.shade100, appBar: AppBar( title: const Text( - "Kecepatan Angin", + 'Kecepatan Angin', style: TextStyle(fontWeight: FontWeight.bold), ), centerTitle: true, @@ -25,79 +123,70 @@ class WindSpeedScreen extends StatelessWidget { backgroundColor: Colors.transparent, foregroundColor: Colors.black, actions: [ + BlocBuilder( + builder: (context, state) => IconButton( + tooltip: 'Export Excel', + icon: const Icon(Icons.file_download_outlined), + onPressed: state.history.isEmpty + ? null + : () => _exportExcel(context, state), + ), + ), IconButton( tooltip: 'Pengaturan Perangkat', icon: const Icon(Icons.settings_rounded), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const DeviceSetupScreen(), - ), - ); - }, + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const DeviceSetupScreen()), + ), ), ], ), - - /// === Body area, lokasi dan tata letak Widgets === /// body: BlocBuilder( builder: (context, state) { if (state.isLoading) { return const Center(child: CircularProgressIndicator()); } - // Pilih data sesuai periode final List data = switch (state.selectedPeriod) { - "Minggu Ini" => state.weeklySpeeds, - "Bulan Ini" => state.monthlySpeeds, + 'Minggu Ini' => state.weeklySpeeds, + 'Bulan Ini' => state.monthlySpeeds, _ => state.dailySpeeds, }; - // Konversi history MyWindSpeed → Map (untuk tabel PDF) - final historyMaps = state.history - .map((e) => { - 'timestamp': e.timestamp, - 'speed': e.speed, - }) - .toList(); - return SingleChildScrollView( padding: const EdgeInsets.all(20.0), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// ==== 1. Tampilan Angka Utama kecepatan angin (Hero Widget look) ==== /// - _buildMainSpeedDisplay(state.currentSpeed), - const SizedBox(height: 30), - - const Text("Tren Kecepatan", - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 15), - const PeriodSelector(), - const SizedBox(height: 15), - - WindSpeedChartWidget( - dailySpeeds: data, - period: state.selectedPeriod, - ), - - const SizedBox(height: 30), - - // 3. Info Tambahan (Status/Periode) - _buildDetailRow(state.selectedPeriod, state.alertLevel), + // ── 1. Hero kecepatan ──────────────────────────── + _buildMainSpeedDisplay(state.currentSpeed, state.alertLevel), const SizedBox(height: 24), - // ← Tombol Export PDF - ExportPdfButton( - onExport: () => PdfExportService.windSpeed( - currentSpeed: state.currentSpeed, - period: state.selectedPeriod, - speeds: data, - timestamp: DateTime.now(), - historyData: historyMaps.isNotEmpty ? historyMaps : null, - ), + // ── 2. Tren grafik ─────────────────────────────── + const Text( + 'Tren Kecepatan', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), + const SizedBox(height: 12), + const PeriodSelector(), + const SizedBox(height: 12), + if (data.isEmpty) + _buildEmptyChart() + else + WindSpeedChartWidget( + dailySpeeds: data, + period: state.selectedPeriod, + ), + const SizedBox(height: 20), + + // ── 3. Info status + periode ───────────────────── + _buildDetailRow(state.selectedPeriod, state.alertLevel), + const SizedBox(height: 28), + + // ── 4. Riwayat data ────────────────────────────── + _buildHistorySection(context, state), + const SizedBox(height: 20), ], ), ); @@ -106,10 +195,19 @@ class WindSpeedScreen extends StatelessWidget { ); } - Widget _buildMainSpeedDisplay(double speed) { + // ════════════════════════════════════════════════════════════ + // Hero kecepatan + // ════════════════════════════════════════════════════════════ + Widget _buildMainSpeedDisplay(double speed, String alertLevel) { + final (badgeColor, badgeIcon) = switch (alertLevel) { + 'Bahaya' => (Colors.red.shade400, Icons.warning_rounded), + 'Waspada' => (Colors.orange.shade400, Icons.info_outline_rounded), + _ => (Colors.green.shade400, Icons.check_circle_outline_rounded), + }; + return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 40), + padding: const EdgeInsets.symmetric(vertical: 36), decoration: BoxDecoration( gradient: LinearGradient( colors: [Colors.blue.shade400, Colors.blue.shade800], @@ -122,38 +220,73 @@ class WindSpeedScreen extends StatelessWidget { color: Colors.blue.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10), - ) + ), ], ), child: Column( children: [ - const Icon(Icons.air, color: Colors.white, size: 50), - const SizedBox( - height: 10, - ), + const Icon(Icons.air, color: Colors.white, size: 46), + const SizedBox(height: 8), Text( - "${speed.toStringAsFixed(1)}", + speed.toStringAsFixed(1), style: const TextStyle( - fontSize: 80, fontWeight: FontWeight.bold, color: Colors.white), + fontSize: 80, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1, + ), + ), + const Text('m/s', + style: TextStyle(fontSize: 20, color: Colors.white70)), + const SizedBox(height: 4), + Text( + '${(speed * 3.6).toStringAsFixed(1)} km/h', + style: const TextStyle(fontSize: 14, color: Colors.white54), + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 7), + decoration: BoxDecoration( + color: badgeColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: badgeColor.withValues(alpha: 0.7)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(badgeIcon, color: badgeColor, size: 15), + const SizedBox(width: 6), + Text( + alertLevel, + style: TextStyle( + color: badgeColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ], + ), ), - const Text("m/s", - style: TextStyle(fontSize: 20, color: Colors.white70)) ], ), ); } + // ════════════════════════════════════════════════════════════ + // Info baris: status + periode + // ════════════════════════════════════════════════════════════ Widget _buildDetailRow(String period, String alertLevel) { final (icon, color) = switch (alertLevel) { - "Bahaya" => (Icons.warning_rounded, Colors.red), - "Waspada" => (Icons.info_outline, Colors.orange), + 'Bahaya' => (Icons.warning_rounded, Colors.red), + 'Waspada' => (Icons.info_outline, Colors.orange), _ => (Icons.check_circle, Colors.green), }; + return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _miniInfoCard("Status", alertLevel, icon, color), - _miniInfoCard("Periode", period, Icons.timer, Colors.orange), + _miniInfoCard('Status', alertLevel, icon, color), + _miniInfoCard('Periode', period, Icons.timer, Colors.orange), ], ); } @@ -165,19 +298,340 @@ class WindSpeedScreen extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 6), + ], ), child: Row( children: [ - Icon(icon, color: color, size: 30), + Icon(icon, color: color, size: 28), const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: const TextStyle(color: Colors.grey, fontSize: 12)), - Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), - ], - ) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle(color: Colors.grey, fontSize: 11)), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + // ════════════════════════════════════════════════════════════ + // Placeholder chart kosong — cegah crash saat data belum ada + // ════════════════════════════════════════════════════════════ + Widget _buildEmptyChart() { + return Container( + height: 160, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bar_chart_rounded, size: 42, color: Colors.grey.shade300), + const SizedBox(height: 8), + Text( + 'Data grafik belum tersedia', + style: TextStyle(color: Colors.grey.shade400, fontSize: 13), + ), + ], + ), + ); + } + + // ════════════════════════════════════════════════════════════ + // Section riwayat + // ════════════════════════════════════════════════════════════ + Widget _buildHistorySection(BuildContext context, WindSpeedState state) { + // Urutkan terbaru di atas + final sorted = [...state.filteredHistory] + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header judul + tombol filter + Row( + children: [ + const Text( + 'Riwayat Data', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const Spacer(), + state.selectedDate != null + ? _FilterChip( + label: DateFormat('dd MMM yyyy', 'id_ID') + .format(state.selectedDate!), + icon: Icons.close_rounded, + onTap: () => context + .read() + .add(const WindSpeedDateFilterChanged(null)), + ) + : _FilterChip( + label: 'Filter Tanggal', + icon: Icons.calendar_month_rounded, + onTap: () => _pickDate(context, state), + ), + ], + ), + + if (state.selectedDate != null) ...[ + const SizedBox(height: 4), + Text( + '${sorted.length} data pada ${DateFormat('dd MMMM yyyy', 'id_ID').format(state.selectedDate!)}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ] else ...[ + const SizedBox(height: 4), + Text( + '${sorted.length} data tersimpan', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ], + + const SizedBox(height: 12), + + // List dengan batas tinggi 420px — di dalamnya bisa di-scroll + sorted.isEmpty + ? _buildEmptyHistory(state.selectedDate != null) + : ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: ListView.separated( + shrinkWrap: false, + padding: EdgeInsets.zero, + itemCount: sorted.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey.shade100), + itemBuilder: (context, i) => + _HistoryTile(item: sorted[i], index: i), + ), + ), + ), + ), + ], + ); + } + + Widget _buildEmptyHistory(bool hasFilter) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + hasFilter ? Icons.search_off_rounded : Icons.inbox_rounded, + size: 44, + color: Colors.grey.shade300, + ), + const SizedBox(height: 10), + Text( + hasFilter + ? 'Tidak ada data untuk tanggal ini' + : 'Belum ada data history', + style: TextStyle(color: Colors.grey.shade400, fontSize: 13), + ), + ], + ), + ); + } +} + +// ════════════════════════════════════════════════════════════ +// Chip filter tanggal +// ════════════════════════════════════════════════════════════ +class _FilterChip extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _FilterChip( + {required this.label, required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + final color = Colors.blue.shade700; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, color: color, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ); + } +} + +// ════════════════════════════════════════════════════════════ +// Satu baris data riwayat +// ════════════════════════════════════════════════════════════ +class _HistoryTile extends StatelessWidget { + final MyWindSpeed item; + final int index; + + const _HistoryTile({required this.item, required this.index}); + + String get _alertLevel { + if (item.speed >= 12.5) return 'Bahaya'; + if (item.speed >= 8.0) return 'Waspada'; + return 'Normal'; + } + + Color get _alertColor { + switch (_alertLevel) { + case 'Bahaya': + return Colors.red.shade600; + case 'Waspada': + return Colors.orange.shade700; + default: + return Colors.green.shade600; + } + } + + IconData get _alertIcon { + switch (_alertLevel) { + case 'Bahaya': + return Icons.warning_rounded; + case 'Waspada': + return Icons.info_outline_rounded; + default: + return Icons.check_circle_outline_rounded; + } + } + + @override + Widget build(BuildContext context) { + final kmh = item.speed * 3.6; + final bg = index.isEven ? Colors.grey.shade50 : Colors.white; + + return Container( + color: bg, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // Nomor + SizedBox( + width: 28, + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade400, + fontWeight: FontWeight.w600), + ), + ), + + // Tanggal + jam + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('dd MMM yyyy', 'id_ID').format(item.timestamp), + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w600), + ), + Text( + DateFormat('HH:mm:ss').format(item.timestamp), + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontFamily: 'monospace'), + ), + ], + ), + ), + + // Kecepatan + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${item.speed.toStringAsFixed(3)} m/s', + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.bold), + ), + Text( + '${kmh.toStringAsFixed(2)} km/h', + style: TextStyle(fontSize: 11, color: Colors.grey.shade400), + ), + ], + ), + ), + + const SizedBox(width: 10), + + // Badge status + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: _alertColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _alertColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_alertIcon, size: 11, color: _alertColor), + const SizedBox(width: 3), + Text( + _alertLevel, + style: TextStyle( + fontSize: 10, + color: _alertColor, + fontWeight: FontWeight.w700), + ), + ], + ), + ), ], ), ); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2dccc22..190186a 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_localization_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterLocalizationPlugin"); + flutter_localization_plugin_register_with_registrar(flutter_localization_registrar); g_autoptr(FlPluginRegistrar) printing_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); printing_plugin_register_with_registrar(printing_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 50a5a3a..35578a2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_localization printing url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index e527c3b..acc9d4a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,9 +10,11 @@ import cloud_firestore import firebase_auth import firebase_core import firebase_database +import flutter_localization import google_sign_in_ios import printing import share_plus +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppSettingsPlugin.register(with: registry.registrar(forPlugin: "AppSettingsPlugin")) @@ -20,7 +22,9 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) + FlutterLocalizationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalizationPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 66251e9..d428630 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -334,6 +334,19 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_localization: + dependency: "direct main" + description: + name: flutter_localization + sha256: a670baf52f4c99859c07293092b9fbc6508235fc88232f5cbb772f2687610a32 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -735,6 +748,62 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" simple_gesture_detector: dependency: transitive description: @@ -820,6 +889,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" url_launcher_linux: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e2e2237..d71431c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: flutter: sdk: flutter flutter_bloc: ^8.1.0 + flutter_localization: ^0.4.0 google_fonts: ^8.0.2 google_sign_in: ^6.3.0 intl: ^0.20.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 41b50d5..bdf7389 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterLocalizationPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterLocalizationPluginCApi")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index fbda3a5..32a0f2c 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST cloud_firestore firebase_auth firebase_core + flutter_localization printing share_plus url_launcher_windows