Merge branch 'branch_percobaan'
This commit is contained in:
commit
0cce4da44b
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "klimatologi",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "klimatologi (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "klimatologi (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
},
|
||||
{
|
||||
"name": "monitoring_repository",
|
||||
"cwd": "packages\\monitoring_repository",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "monitoring_repository (profile mode)",
|
||||
"cwd": "packages\\monitoring_repository",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "monitoring_repository (release mode)",
|
||||
"cwd": "packages\\monitoring_repository",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
},
|
||||
{
|
||||
"name": "user_repository",
|
||||
"cwd": "packages\\user_repository",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "user_repository (profile mode)",
|
||||
"cwd": "packages\\user_repository",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "user_repository (release mode)",
|
||||
"cwd": "packages\\user_repository",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -14,6 +14,14 @@
|
|||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "219079385989-qtvssir2q3c4lo6ijn3p07kl77ukdme2.apps.googleusercontent.com",
|
||||
"client_type": 1,
|
||||
"android_info": {
|
||||
"package_name": "com.example.klimatologiot",
|
||||
"certificate_hash": "f5be897daa6ad87f2bd9c04560f67ef379eac6fe"
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_id": "219079385989-8hkp1kg2pprs5k269s4c08r6rpdj1gjd.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
|
|
@ -0,0 +1,117 @@
|
|||
class TimeSeriesMapper {
|
||||
/// =========================
|
||||
/// 📅 DAILY (24 JAM)
|
||||
/// =========================
|
||||
static List<double> toDaily<T>({
|
||||
required List<T> data,
|
||||
required DateTime Function(T) getTime,
|
||||
required double Function(T) getValue,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
|
||||
final sums = List<double>.filled(24, 0.0);
|
||||
final counts = List<int>.filled(24, 0);
|
||||
|
||||
for (final item in data) {
|
||||
final time = getTime(item);
|
||||
|
||||
if (_isSameDay(time, now)) {
|
||||
final hour = time.hour;
|
||||
sums[hour] += getValue(item);
|
||||
counts[hour]++;
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(24, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
return sums[i] / counts[i]; // 🔥 average, bukan overwrite
|
||||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📅 WEEKLY (7 HARI)
|
||||
/// =========================
|
||||
static List<double> toWeekly<T>({
|
||||
required List<T> data,
|
||||
required DateTime Function(T) getTime,
|
||||
required double Function(T) getValue,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
|
||||
final sums = List<double>.filled(7, 0.0);
|
||||
final counts = List<int>.filled(7, 0);
|
||||
|
||||
DateTime startOfWeek = now.subtract(Duration(days: now.weekday - 1));
|
||||
startOfWeek =
|
||||
DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
|
||||
|
||||
for (final item in data) {
|
||||
final time = getTime(item);
|
||||
|
||||
if (time.isAfter(startOfWeek)) {
|
||||
final index = time.weekday - 1;
|
||||
sums[index] += getValue(item);
|
||||
counts[index]++;
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(7, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
return sums[i] / counts[i];
|
||||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📅 MONTHLY
|
||||
/// =========================
|
||||
static List<double> toMonthly<T>({
|
||||
required List<T> data,
|
||||
required DateTime Function(T) getTime,
|
||||
required double Function(T) getValue,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final daysInMonth = DateTime(now.year, now.month + 1, 0).day;
|
||||
|
||||
final sums = List<double>.filled(daysInMonth, 0.0);
|
||||
final counts = List<int>.filled(daysInMonth, 0);
|
||||
|
||||
for (final item in data) {
|
||||
final time = getTime(item);
|
||||
|
||||
if (time.month == now.month && time.year == now.year) {
|
||||
final index = time.day - 1;
|
||||
sums[index] += getValue(item);
|
||||
counts[index]++;
|
||||
}
|
||||
}
|
||||
|
||||
return List.generate(daysInMonth, (i) {
|
||||
if (counts[i] == 0) return 0;
|
||||
return sums[i] / counts[i];
|
||||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🧠 HELPER
|
||||
/// =========================
|
||||
static bool _isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
static List<double> smooth(List<double> data) {
|
||||
if (data.length < 3) return data;
|
||||
|
||||
List<double> result = [];
|
||||
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
if (i == 0 || i == data.length - 1) {
|
||||
result.add(data[i]);
|
||||
} else {
|
||||
final avg = (data[i - 1] + data[i] + data[i + 1]) / 3;
|
||||
result.add(avg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,14 @@ import 'package:klimatologiot/app.dart';
|
|||
import 'package:user_repository/user_repository.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
await initializeDateFormatting('id_ID', null);
|
||||
Bloc.observer = SimpleBlocObserver();
|
||||
runApp(MyApp(
|
||||
FirebaseUserRepo(),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
|||
emit(SignInProcess());
|
||||
try {
|
||||
await _userRepository.signInWithGoogle();
|
||||
emit(SignInSuccess());
|
||||
} catch (e) {
|
||||
emit(SignInFailure());
|
||||
emit(SignInInitial());
|
||||
|
|
|
|||
|
|
@ -27,16 +27,12 @@ class HomeScreen extends StatelessWidget {
|
|||
title: Row(
|
||||
children: [
|
||||
// Ganti dengan Image.asset jika sudah ada logo
|
||||
const Icon(Icons.cloud_queue, color: Colors.blue, size: 28),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
"Klimatologi",
|
||||
style: GoogleFonts.rubik(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
Image.asset(
|
||||
'images/logo_klimatologi.png',
|
||||
height: 90,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:klimatologiot/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
||||
import '../../monitoring/wind_speed/views/wind_speed_screen.dart';
|
||||
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
|
||||
import '../../monitoring/evaporasi/views/evaporasi_screen.dart';
|
||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||
import '../../monitoring/atmospheric_conditions/views/atmospheric_screen.dart';
|
||||
|
||||
class MainDrawer extends StatelessWidget {
|
||||
const MainDrawer({super.key});
|
||||
|
|
@ -68,7 +72,20 @@ class MainDrawer extends StatelessWidget {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.water_drop),
|
||||
title: const Text("Evaporasi"),
|
||||
onTap: () {},
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider<EvaporasiBloc>(
|
||||
create: (context) => EvaporasiBloc(
|
||||
repository: context.read<MonitoringRepository>(),
|
||||
)..add(WatchEvaporasiStarted()),
|
||||
child: const EvaporasiScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Image.asset(
|
||||
|
|
@ -76,8 +93,21 @@ class MainDrawer extends StatelessWidget {
|
|||
height: 20,
|
||||
width: 20,
|
||||
),
|
||||
title: const Text("Tekanan Udara"),
|
||||
onTap: () {},
|
||||
title: const Text("Kondisi Atmosfer"),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => BlocProvider<AtmosphericConditionsBloc>(
|
||||
create: (context) => AtmosphericConditionsBloc(
|
||||
repository: context.read<MonitoringRepository>(),
|
||||
)..add(WatchAtmosphericConditionsStarted()),
|
||||
child: AtmosphericScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(), // Dorong menu logout ke paling bawah
|
||||
const Divider(),
|
||||
|
|
|
|||
|
|
@ -1,784 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'shared/widgets/monitoring_shared.dart';
|
||||
|
||||
/// AIR QUALITY MONITORING SCREEN
|
||||
class AirQualityMonitoringScreen extends StatefulWidget {
|
||||
const AirQualityMonitoringScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AirQualityMonitoringScreen> createState() =>
|
||||
_AirQualityMonitoringScreenState();
|
||||
}
|
||||
|
||||
class _AirQualityMonitoringScreenState
|
||||
extends State<AirQualityMonitoringScreen> {
|
||||
String _selectedPeriod = "Hari Ini";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Monitoring Kualitas Udara"),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
/// GRAFIK MONITORING
|
||||
_buildMonitoringGraphBlock(),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
/// PENJELASAN
|
||||
_buildAirQualityExplanation(),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
/// STATISTIK TAHUNAN
|
||||
_buildAnnualAirQualityGraphSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// EXPORT FUNCTIONS
|
||||
void _exportDailyAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - HARIAN (24 JAM)");
|
||||
csvContent.writeln("Tanggal: ${DateTime.now().toString().split(' ')[0]}");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Jam,AQI (Air Quality Index)");
|
||||
|
||||
final dailyData = [
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
80.0,
|
||||
78.0,
|
||||
75.0,
|
||||
72.0,
|
||||
70.0,
|
||||
68.0,
|
||||
65.0,
|
||||
60.0,
|
||||
58.0,
|
||||
60.0,
|
||||
62.0,
|
||||
65.0,
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
76.0,
|
||||
74.0
|
||||
];
|
||||
for (int i = 0; i < dailyData.length; i++) {
|
||||
csvContent.writeln("$i:00,${dailyData[i]}");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Statistik Harian");
|
||||
csvContent.writeln("Total: 1709 AQI");
|
||||
csvContent.writeln("Rata-rata: 71.2 AQI");
|
||||
csvContent.writeln("Maksimal: 80 AQI");
|
||||
csvContent.writeln("Minimal: 58 AQI");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Harian Kualitas Udara");
|
||||
}
|
||||
|
||||
void _exportWeeklyAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - MINGGUAN");
|
||||
csvContent.writeln("Tanggal: ${DateTime.now().toString().split(' ')[0]}");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Hari,Rata-rata AQI");
|
||||
|
||||
final days = [
|
||||
'Senin',
|
||||
'Selasa',
|
||||
'Rabu',
|
||||
'Kamis',
|
||||
'Jumat',
|
||||
'Sabtu',
|
||||
'Minggu'
|
||||
];
|
||||
final weeklyData = [68.0, 72.0, 75.0, 78.0, 72.0, 68.0, 62.0];
|
||||
|
||||
for (int i = 0; i < days.length; i++) {
|
||||
csvContent.writeln("${days[i]},${weeklyData[i]}");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Statistik Mingguan");
|
||||
csvContent.writeln("Total: 495 AQI");
|
||||
csvContent.writeln("Rata-rata: 70.7 AQI");
|
||||
csvContent.writeln("Maksimal: 78 AQI");
|
||||
csvContent.writeln("Minimal: 62 AQI");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Mingguan Kualitas Udara");
|
||||
}
|
||||
|
||||
void _exportMonthlyAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - BULANAN");
|
||||
csvContent.writeln(
|
||||
"Bulan: ${DateTime.now().toString().split(' ')[0].substring(0, 7)}");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Tanggal,Rata-rata AQI");
|
||||
|
||||
for (int i = 1; i <= 28; i++) {
|
||||
final value = (70.0 + (i % 6) * 2.5).toStringAsFixed(1);
|
||||
csvContent.writeln("$i,$value");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Statistik Bulanan");
|
||||
csvContent.writeln("Total: 1990 AQI");
|
||||
csvContent.writeln("Rata-rata: 71.1 AQI");
|
||||
csvContent.writeln("Maksimal: 82 AQI");
|
||||
csvContent.writeln("Minimal: 55 AQI");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Bulanan Kualitas Udara");
|
||||
}
|
||||
|
||||
void _exportAnnualAirQualityData() {
|
||||
final StringBuffer csvContent = StringBuffer();
|
||||
csvContent.writeln("DATA MONITORING KUALITAS UDARA - TAHUNAN");
|
||||
csvContent.writeln("Tahun: 2025");
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("Bulan,Rata-rata AQI,AQI Max");
|
||||
|
||||
final months = [
|
||||
'Januari',
|
||||
'Februari',
|
||||
'Maret',
|
||||
'April',
|
||||
'Mei',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'Agustus',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Desember'
|
||||
];
|
||||
final averages = [
|
||||
68.0,
|
||||
70.0,
|
||||
72.5,
|
||||
75.0,
|
||||
78.0,
|
||||
80.0,
|
||||
82.0,
|
||||
80.0,
|
||||
75.0,
|
||||
70.0,
|
||||
68.0,
|
||||
65.0
|
||||
];
|
||||
final maxAqi = [
|
||||
85.0,
|
||||
88.0,
|
||||
90.0,
|
||||
95.0,
|
||||
98.0,
|
||||
100.0,
|
||||
102.0,
|
||||
100.0,
|
||||
95.0,
|
||||
90.0,
|
||||
85.0,
|
||||
80.0
|
||||
];
|
||||
|
||||
for (int i = 0; i < months.length; i++) {
|
||||
csvContent.writeln("${months[i]},${averages[i]},${maxAqi[i]}");
|
||||
}
|
||||
|
||||
csvContent.writeln("");
|
||||
csvContent.writeln("STATISTIK TAHUNAN");
|
||||
csvContent.writeln("Rata-rata Tahunan: 74.2 AQI");
|
||||
csvContent.writeln("AQI Max Teringgi: 102.0");
|
||||
csvContent.writeln("AQI Min Terendah: 65.0");
|
||||
|
||||
showExportPreview(
|
||||
context, csvContent.toString(), "Data Tahunan Kualitas Udara");
|
||||
}
|
||||
|
||||
/// DATA GENERATORS
|
||||
List<FlSpot> _getAirQualityPeriodData() {
|
||||
switch (_selectedPeriod) {
|
||||
case "Hari Ini":
|
||||
return List.generate(24, (i) {
|
||||
final values = [
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
80.0,
|
||||
78.0,
|
||||
75.0,
|
||||
72.0,
|
||||
70.0,
|
||||
68.0,
|
||||
65.0,
|
||||
60.0,
|
||||
58.0,
|
||||
60.0,
|
||||
62.0,
|
||||
65.0,
|
||||
68.0,
|
||||
70.0,
|
||||
72.0,
|
||||
75.0,
|
||||
78.0,
|
||||
76.0,
|
||||
74.0
|
||||
];
|
||||
return FlSpot(i.toDouble(), values[i]);
|
||||
});
|
||||
case "Minggu Ini":
|
||||
return [
|
||||
const FlSpot(0, 68.0),
|
||||
const FlSpot(1, 72.0),
|
||||
const FlSpot(2, 75.0),
|
||||
const FlSpot(3, 78.0),
|
||||
const FlSpot(4, 72.0),
|
||||
const FlSpot(5, 68.0),
|
||||
const FlSpot(6, 62.0),
|
||||
];
|
||||
case "Bulan Ini":
|
||||
return [
|
||||
const FlSpot(0, 70.0),
|
||||
const FlSpot(1, 72.5),
|
||||
const FlSpot(2, 75.0),
|
||||
const FlSpot(3, 77.5),
|
||||
const FlSpot(4, 80.0),
|
||||
const FlSpot(5, 82.0),
|
||||
const FlSpot(6, 80.0),
|
||||
const FlSpot(7, 78.0),
|
||||
const FlSpot(8, 75.0),
|
||||
const FlSpot(9, 72.0),
|
||||
const FlSpot(10, 70.0),
|
||||
const FlSpot(11, 68.0),
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// UI BUILDERS
|
||||
Widget _buildMonitoringGraphBlock() {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
"Status Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Text(
|
||||
"Normal",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
/// PERIOD SELECTOR + EXPORT
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children:
|
||||
["Hari Ini", "Minggu Ini", "Bulan Ini"].map((period) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(period),
|
||||
selected: _selectedPeriod == period,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
_selectedPeriod = period;
|
||||
});
|
||||
},
|
||||
selectedColor: Colors.orange,
|
||||
labelStyle: TextStyle(
|
||||
color: _selectedPeriod == period
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
if (_selectedPeriod == "Hari Ini") {
|
||||
_exportDailyAirQualityData();
|
||||
} else if (_selectedPeriod == "Minggu Ini") {
|
||||
_exportWeeklyAirQualityData();
|
||||
} else if (_selectedPeriod == "Bulan Ini") {
|
||||
_exportMonthlyAirQualityData();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text("Export"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
/// GRAPH
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawHorizontalLine: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 1,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: Colors.grey.shade300,
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: getPeriodInterval(_selectedPeriod),
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
getPeriodLabel(_selectedPeriod, value),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
value.toInt().toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 28,
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: _getAirQualityPeriodData(),
|
||||
isCurved: true,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Colors.orange,
|
||||
strokeWidth: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
color: Colors.orange,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAirQualityExplanation() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Penjelasan Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
"Kualitas udara diukur menggunakan Air Quality Index (AQI) yang menunjukkan tingkat polusi udara. AQI berkisar dari 0-500, dengan nilai lebih rendah menunjukkan udara lebih bersih. Monitoring kualitas udara penting untuk kesehatan masyarakat, perencanaan lingkungan, dan identifikasi sumber polusi. Kategori: 0-50 (Baik), 51-100 (Wajar), 101-150 (Tidak Sehat), 151+ (Sangat Tidak Sehat).",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1.6,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnnualAirQualityGraphSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Statistik Tahunan Kualitas Udara",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
/// STATS
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: "Rata-rata Tahunan",
|
||||
value: "74.2 AQI",
|
||||
color: Colors.orange,
|
||||
icon: Icons.cloud,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: StatCard(
|
||||
title: "AQI Maksimal",
|
||||
value: "102.0",
|
||||
color: Colors.red,
|
||||
icon: Icons.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
/// CHART
|
||||
const Text(
|
||||
"Grafik Tahunan - Rata-rata & AQI Maksimal",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
height: 320,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawHorizontalLine: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 5,
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: Colors.grey.shade300,
|
||||
strokeWidth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'Mei',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Agu',
|
||||
'Sep',
|
||||
'Okt',
|
||||
'Nov',
|
||||
'Des'
|
||||
];
|
||||
return Text(
|
||||
months[value.toInt()],
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 30,
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
return Text(
|
||||
'${value.toInt()} AQI',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
reservedSize: 50,
|
||||
),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
/// Rata-rata
|
||||
LineChartBarData(
|
||||
spots: const [
|
||||
FlSpot(0, 68.0),
|
||||
FlSpot(1, 70.0),
|
||||
FlSpot(2, 72.5),
|
||||
FlSpot(3, 75.0),
|
||||
FlSpot(4, 78.0),
|
||||
FlSpot(5, 80.0),
|
||||
FlSpot(6, 82.0),
|
||||
FlSpot(7, 80.0),
|
||||
FlSpot(8, 75.0),
|
||||
FlSpot(9, 70.0),
|
||||
FlSpot(10, 68.0),
|
||||
FlSpot(11, 65.0),
|
||||
],
|
||||
isCurved: true,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Colors.orange,
|
||||
strokeWidth: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
color: Colors.orange,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.orange.withOpacity(0.1),
|
||||
),
|
||||
),
|
||||
|
||||
/// AQI Max
|
||||
LineChartBarData(
|
||||
spots: const [
|
||||
FlSpot(0, 85.0),
|
||||
FlSpot(1, 88.0),
|
||||
FlSpot(2, 90.0),
|
||||
FlSpot(3, 95.0),
|
||||
FlSpot(4, 98.0),
|
||||
FlSpot(5, 100.0),
|
||||
FlSpot(6, 102.0),
|
||||
FlSpot(7, 100.0),
|
||||
FlSpot(8, 95.0),
|
||||
FlSpot(9, 90.0),
|
||||
FlSpot(10, 85.0),
|
||||
FlSpot(11, 80.0),
|
||||
],
|
||||
isCurved: true,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Colors.red,
|
||||
strokeWidth: 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
/// LEGEND
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"Rata-rata AQI",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
"AQI Maksimal",
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
Center(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _exportAnnualAirQualityData(),
|
||||
icon: const Icon(Icons.file_download),
|
||||
label: const Text("Download Excel Tahunan"),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
|
||||
part 'atmospheric_conditions_event.dart';
|
||||
part 'atmospheric_conditions_state.dart';
|
||||
|
||||
class AtmosphericConditionsBloc
|
||||
extends Bloc<AtmosphericConditionsEvent, AtmosphericConditionsState> {
|
||||
final MonitoringRepository _repository;
|
||||
StreamSubscription<AtmosphericConditions>? _subscription;
|
||||
|
||||
AtmosphericConditionsBloc({required MonitoringRepository repository})
|
||||
: _repository = repository,
|
||||
super(const AtmosphericConditionsState()) {
|
||||
on<WatchAtmosphericConditionsStarted>(_onStarted);
|
||||
on<_AtmosphericConditionsUpdated>(_onUpdated);
|
||||
}
|
||||
|
||||
/// 🚀 START
|
||||
Future<void> _onStarted(
|
||||
WatchAtmosphericConditionsStarted event,
|
||||
Emitter<AtmosphericConditionsState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
await _subscription?.cancel();
|
||||
|
||||
_subscription = _repository
|
||||
.getSensorStream(
|
||||
'sensor/latest',
|
||||
(json) => AtmosphericConditions.fromJson(json),
|
||||
)
|
||||
.listen((data) {
|
||||
add(_AtmosphericConditionsUpdated(data));
|
||||
});
|
||||
}
|
||||
|
||||
/// ⚡ REALTIME UPDATE
|
||||
void _onUpdated(
|
||||
_AtmosphericConditionsUpdated event,
|
||||
Emitter<AtmosphericConditionsState> emit,
|
||||
) {
|
||||
emit(state.copyWith(
|
||||
temperature: event.data.temperature,
|
||||
humidity: event.data.humidity,
|
||||
pressure: event.data.pressure,
|
||||
altitude: event.data.altitude,
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _subscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// INTERNAL EVENT
|
||||
class _AtmosphericConditionsUpdated extends AtmosphericConditionsEvent {
|
||||
final AtmosphericConditions data;
|
||||
|
||||
const _AtmosphericConditionsUpdated(this.data);
|
||||
|
||||
@override
|
||||
List<Object> get props => [data];
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
part of 'atmospheric_conditions_bloc.dart';
|
||||
|
||||
abstract class AtmosphericConditionsEvent extends Equatable {
|
||||
const AtmosphericConditionsEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class WatchAtmosphericConditionsStarted extends AtmosphericConditionsEvent {}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
part of 'atmospheric_conditions_bloc.dart';
|
||||
|
||||
class AtmosphericConditionsState extends Equatable {
|
||||
final double temperature;
|
||||
final double humidity;
|
||||
final double pressure;
|
||||
final double altitude;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
const AtmosphericConditionsState({
|
||||
this.temperature = 0.0,
|
||||
this.humidity = 0.0,
|
||||
this.pressure = 0.0,
|
||||
this.altitude = 0.0,
|
||||
this.isLoading = true,
|
||||
});
|
||||
|
||||
AtmosphericConditionsState copyWith({
|
||||
double? temperature,
|
||||
double? humidity,
|
||||
double? pressure,
|
||||
double? altitude,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return AtmosphericConditionsState(
|
||||
temperature: temperature ?? this.temperature,
|
||||
humidity: humidity ?? this.humidity,
|
||||
pressure: pressure ?? this.pressure,
|
||||
altitude: altitude ?? this.altitude,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [
|
||||
temperature,
|
||||
humidity,
|
||||
pressure,
|
||||
altitude,
|
||||
isLoading,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../blocs/atmospheric_conditions_bloc.dart';
|
||||
import '../../shared/utils/pdf/pdf_export_service.dart';
|
||||
import '../../shared/widgets/export_pdf_button.dart';
|
||||
|
||||
class AtmosphericScreen extends StatelessWidget {
|
||||
const AtmosphericScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"Kondisi Atmosfer",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
body: BlocBuilder<AtmosphericConditionsBloc, AtmosphericConditionsState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
_mainTemperature(state),
|
||||
const SizedBox(height: 25),
|
||||
_gridInfo(state),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// ← Tombol Export PDF
|
||||
ExportPdfButton(
|
||||
onExport: () => PdfExportService.atmospheric(
|
||||
temperature: state.temperature,
|
||||
humidity: state.humidity,
|
||||
pressure: state.pressure,
|
||||
altitude: state.altitude,
|
||||
timestamp: DateTime.now(),
|
||||
// atmospheric state belum punya history,
|
||||
// kalau nanti ditambah tinggal isi historyData di sini
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🌡️ TEMPERATURE (HERO CARD)
|
||||
/// =========================
|
||||
Widget _mainTemperature(AtmosphericConditionsState state) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color.fromARGB(255, 38, 255, 222),
|
||||
const Color.fromARGB(255, 53, 132, 229)
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color.fromARGB(255, 0, 191, 255).withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.thermostat, color: Colors.white, size: 50),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
state.temperature.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 70,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
"°C",
|
||||
style: TextStyle(color: Colors.white70, fontSize: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📊 INFO GRID
|
||||
/// =========================
|
||||
Widget _gridInfo(AtmosphericConditionsState state) {
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 15,
|
||||
crossAxisSpacing: 15,
|
||||
childAspectRatio: 1.6,
|
||||
children: [
|
||||
_infoCard(
|
||||
"Kelembapan",
|
||||
"${state.humidity.toStringAsFixed(1)} %",
|
||||
Icons.water_drop,
|
||||
Colors.blue,
|
||||
),
|
||||
_infoCard(
|
||||
"Tekanan",
|
||||
"${state.pressure.toStringAsFixed(1)} hPa",
|
||||
Icons.speed,
|
||||
Colors.green,
|
||||
),
|
||||
_infoCard(
|
||||
"Ketinggian",
|
||||
"${state.altitude.toStringAsFixed(1)} m",
|
||||
Icons.terrain,
|
||||
Colors.brown,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard(String title, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 30),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
|
||||
import '../../../../core/utils/time_series_mapper.dart';
|
||||
|
||||
part 'evaporasi_event.dart';
|
||||
part 'evaporasi_state.dart';
|
||||
|
||||
class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||
final MonitoringRepository _repository;
|
||||
StreamSubscription<Evaporasi>? _subscription;
|
||||
|
||||
EvaporasiBloc({required MonitoringRepository repository})
|
||||
: _repository = repository,
|
||||
super(const EvaporasiState()) {
|
||||
on<WatchEvaporasiStarted>(_onStarted);
|
||||
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
|
||||
on<EvaporasiPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🚀 START
|
||||
/// =========================
|
||||
Future<void> _onStarted(
|
||||
WatchEvaporasiStarted event,
|
||||
Emitter<EvaporasiState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
final history = await _repository.getSensorHistory(
|
||||
'evaporasi/history',
|
||||
(json) => Evaporasi.fromJson(json),
|
||||
);
|
||||
|
||||
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field
|
||||
);
|
||||
|
||||
emit(state.copyWith(
|
||||
history: history,
|
||||
dailyValues: dailyGraph,
|
||||
isLoading: false,
|
||||
));
|
||||
|
||||
await _subscription?.cancel();
|
||||
|
||||
_subscription = _repository
|
||||
.getSensorStream(
|
||||
'Monitoring',
|
||||
(json) => Evaporasi.fromJson(json),
|
||||
)
|
||||
.listen((data) {
|
||||
add(_EvaporasiRealtimeUpdated(data));
|
||||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// ⚡ REALTIME
|
||||
/// =========================
|
||||
void _onRealtimeUpdated(
|
||||
_EvaporasiRealtimeUpdated event,
|
||||
Emitter<EvaporasiState> emit,
|
||||
) {
|
||||
final updated = List<double>.from(state.dailyValues);
|
||||
|
||||
final index = DateTime.now().hour;
|
||||
|
||||
if (index < updated.length) {
|
||||
updated[index] = event.data.evaporasi; // ⚠️ sesuaikan field
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
currentValue: event.data.evaporasi,
|
||||
temperature: event.data.suhu,
|
||||
waterLevel: event.data.tinggiAir,
|
||||
dailyValues: updated,
|
||||
));
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📊 PERIOD
|
||||
/// =========================
|
||||
Future<void> _onPeriodChanged(
|
||||
EvaporasiPeriodChanged event,
|
||||
Emitter<EvaporasiState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
|
||||
|
||||
final history = state.history;
|
||||
|
||||
List<double> updated;
|
||||
|
||||
if (event.period == "Minggu Ini") {
|
||||
updated = TimeSeriesMapper.toWeekly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
);
|
||||
} else if (event.period == "Bulan Ini") {
|
||||
updated = TimeSeriesMapper.toMonthly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
);
|
||||
} else {
|
||||
updated = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.evaporasi,
|
||||
);
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
dailyValues: updated,
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _subscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// INTERNAL EVENT
|
||||
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
|
||||
final Evaporasi data;
|
||||
|
||||
const _EvaporasiRealtimeUpdated(this.data);
|
||||
|
||||
@override
|
||||
List<Object> get props => [data];
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
part of 'evaporasi_bloc.dart';
|
||||
|
||||
abstract class EvaporasiEvent extends Equatable {
|
||||
const EvaporasiEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
/// 🚀 START MONITORING
|
||||
class WatchEvaporasiStarted extends EvaporasiEvent {}
|
||||
|
||||
/// 📊 GANTI PERIODE (Harian / Mingguan / Bulanan)
|
||||
class EvaporasiPeriodChanged extends EvaporasiEvent {
|
||||
final String period;
|
||||
|
||||
const EvaporasiPeriodChanged(this.period);
|
||||
|
||||
@override
|
||||
List<Object> get props => [period];
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
part of 'evaporasi_bloc.dart';
|
||||
|
||||
class EvaporasiState extends Equatable {
|
||||
final double currentValue; // nilai evaporasi realtime
|
||||
final double temperature; // suhu (opsional dari firebase)
|
||||
final double waterLevel; // tinggi air
|
||||
final String selectedPeriod;
|
||||
|
||||
final List<double> dailyValues; // untuk grafik
|
||||
final List<Evaporasi> history;
|
||||
|
||||
final bool isLoading;
|
||||
|
||||
const EvaporasiState({
|
||||
this.currentValue = 0.0,
|
||||
this.temperature = 0.0,
|
||||
this.waterLevel = 0.0,
|
||||
this.selectedPeriod = "Hari Ini",
|
||||
this.dailyValues = const [],
|
||||
this.history = const [],
|
||||
this.isLoading = true,
|
||||
});
|
||||
|
||||
EvaporasiState copyWith({
|
||||
double? currentValue,
|
||||
double? temperature,
|
||||
double? waterLevel,
|
||||
String? selectedPeriod,
|
||||
List<double>? dailyValues,
|
||||
List<Evaporasi>? history,
|
||||
bool? isLoading,
|
||||
}) {
|
||||
return EvaporasiState(
|
||||
currentValue: currentValue ?? this.currentValue,
|
||||
temperature: temperature ?? this.temperature,
|
||||
waterLevel: waterLevel ?? this.waterLevel,
|
||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||
dailyValues: dailyValues ?? this.dailyValues,
|
||||
history: history ?? this.history,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [
|
||||
currentValue,
|
||||
temperature,
|
||||
waterLevel,
|
||||
selectedPeriod,
|
||||
dailyValues,
|
||||
history,
|
||||
isLoading,
|
||||
];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,167 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../blocs/evaporasi_bloc.dart';
|
||||
import '../../shared/utils/pdf/pdf_export_service.dart';
|
||||
import '../../shared/widgets/export_pdf_button.dart';
|
||||
|
||||
class EvaporasiScreen extends StatelessWidget {
|
||||
const EvaporasiScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"Evaporasi",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
body: BlocBuilder<EvaporasiBloc, EvaporasiState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final historyMaps = state.history
|
||||
.map((e) => {
|
||||
'timestamp': e.timestamp,
|
||||
'evaporasi': e.evaporasi,
|
||||
'suhu': e.suhu,
|
||||
'tinggiAir': e.tinggiAir,
|
||||
})
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
_mainCard(state),
|
||||
const SizedBox(height: 25),
|
||||
_infoRow(state),
|
||||
const SizedBox(height: 25),
|
||||
_trendSection(),
|
||||
const SizedBox(height: 25),
|
||||
ExportPdfButton(
|
||||
onExport: () => PdfExportService.evaporasi(
|
||||
evaporasi: state.currentValue,
|
||||
suhu: state.temperature,
|
||||
tinggiAir: state.waterLevel,
|
||||
timestamp: DateTime.now(),
|
||||
historyData: historyMaps.isNotEmpty ? historyMaps : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🔥 MAIN CARD (EVAPORASI)
|
||||
/// =========================
|
||||
Widget _mainCard(EvaporasiState state) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue.shade400, Colors.blue.shade800],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.water_drop, color: Colors.white, size: 45),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
state.currentValue.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 70,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
"mm",
|
||||
style: TextStyle(color: Colors.white70, fontSize: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📊 INFO KECIL (SUHU & AIR)
|
||||
/// =========================
|
||||
Widget _infoRow(EvaporasiState state) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_miniCard(
|
||||
"Suhu",
|
||||
"${state.temperature.toStringAsFixed(1)} °C",
|
||||
Icons.thermostat,
|
||||
Colors.orange,
|
||||
),
|
||||
_miniCard(
|
||||
"Tinggi Air",
|
||||
"${state.waterLevel.toStringAsFixed(1)} cm",
|
||||
Icons.water,
|
||||
Colors.blue,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _miniCard(String title, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
width: 160,
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📈 TREND (SIMPLE PLACEHOLDER)
|
||||
/// =========================
|
||||
Widget _trendSection() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
"Grafik Evaporasi",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import 'pdf_components.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// Atmospheric PDF Builder
|
||||
/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/atmospheric_pdf_builder.dart
|
||||
/// ============================================================
|
||||
|
||||
Future<void> exportAtmosphericPdf({
|
||||
required double temperature,
|
||||
required double humidity,
|
||||
required double pressure,
|
||||
required double altitude,
|
||||
required DateTime timestamp,
|
||||
List<Map<String, dynamic>>? historyData,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
header: (ctx) => buildPdfHeader('Laporan Kondisi Atmosfer', timestamp),
|
||||
footer: (ctx) => buildPdfFooter(ctx),
|
||||
build: (ctx) => [
|
||||
// — Summary card
|
||||
buildSummaryCard(
|
||||
title: 'Data Terkini',
|
||||
subtitle: pdfFullFormat.format(timestamp),
|
||||
items: [
|
||||
SummaryItem('Suhu', '${temperature.toStringAsFixed(1)} °C', '🌡️'),
|
||||
SummaryItem('Kelembapan', '${humidity.toStringAsFixed(1)} %', '💧'),
|
||||
SummaryItem('Tekanan', '${pressure.toStringAsFixed(1)} hPa', '🔵'),
|
||||
SummaryItem('Ketinggian', '${altitude.toStringAsFixed(1)} m', '⛰️'),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 24),
|
||||
|
||||
// — Tabel key-value ringkasan
|
||||
buildSectionTitle('Detail Parameter'),
|
||||
pw.SizedBox(height: 8),
|
||||
buildKeyValueTable([
|
||||
['Parameter', 'Nilai', 'Satuan'],
|
||||
['Suhu', temperature.toStringAsFixed(1), '°C'],
|
||||
['Kelembapan', humidity.toStringAsFixed(1), '%'],
|
||||
['Tekanan Udara', pressure.toStringAsFixed(1), 'hPa'],
|
||||
['Ketinggian', altitude.toStringAsFixed(1), 'm'],
|
||||
]),
|
||||
|
||||
// — Tabel history jika ada
|
||||
if (historyData != null && historyData.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 24),
|
||||
buildSectionTitle('Riwayat Data'),
|
||||
pw.SizedBox(height: 8),
|
||||
_buildHistoryTable(historyData),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await Printing.layoutPdf(onLayout: (_) => pdf.save());
|
||||
}
|
||||
|
||||
pw.Widget _buildHistoryTable(List<Map<String, dynamic>> data) {
|
||||
return buildDataTable(
|
||||
headers: ['No', 'Waktu', 'Suhu (°C)', 'Kelembapan (%)', 'Tekanan (hPa)', 'Ketinggian (m)'],
|
||||
rows: data.asMap().entries.map((e) {
|
||||
final d = e.value;
|
||||
final ts = d['timestamp'] as DateTime?;
|
||||
return [
|
||||
'${e.key + 1}',
|
||||
ts != null ? pdfFullFormat.format(ts) : '-',
|
||||
(d['temperature'] as double? ?? 0).toStringAsFixed(1),
|
||||
(d['humidity'] as double? ?? 0).toStringAsFixed(1),
|
||||
(d['pressure'] as double? ?? 0).toStringAsFixed(1),
|
||||
(d['altitude'] as double? ?? 0).toStringAsFixed(1),
|
||||
];
|
||||
}).toList(),
|
||||
colWidths: {
|
||||
0: const pw.FixedColumnWidth(25),
|
||||
1: const pw.FlexColumnWidth(2),
|
||||
2: const pw.FlexColumnWidth(1),
|
||||
3: const pw.FlexColumnWidth(1.2),
|
||||
4: const pw.FlexColumnWidth(1.2),
|
||||
5: const pw.FlexColumnWidth(1.2),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import 'pdf_components.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// Evaporasi PDF Builder
|
||||
/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/evaporasi_pdf_builder.dart
|
||||
/// ============================================================
|
||||
|
||||
Future<void> exportEvaporasiPdf({
|
||||
required double evaporasi,
|
||||
required double suhu,
|
||||
required double tinggiAir,
|
||||
required DateTime timestamp,
|
||||
List<Map<String, dynamic>>? historyData,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
header: (ctx) => buildPdfHeader('Laporan Evaporasi', timestamp),
|
||||
footer: (ctx) => buildPdfFooter(ctx),
|
||||
build: (ctx) => [
|
||||
// — Summary card
|
||||
buildSummaryCard(
|
||||
title: 'Data Terkini',
|
||||
subtitle: pdfFullFormat.format(timestamp),
|
||||
items: [
|
||||
SummaryItem('Evaporasi', '${evaporasi.toStringAsFixed(2)} mm', '💧'),
|
||||
SummaryItem('Suhu', '${suhu.toStringAsFixed(1)} °C', '🌡️'),
|
||||
SummaryItem('Tinggi Air', '${tinggiAir.toStringAsFixed(1)} cm', '📏'),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 24),
|
||||
|
||||
// — Tabel key-value ringkasan
|
||||
buildSectionTitle('Detail Parameter'),
|
||||
pw.SizedBox(height: 8),
|
||||
buildKeyValueTable([
|
||||
['Parameter', 'Nilai', 'Satuan'],
|
||||
['Evaporasi', evaporasi.toStringAsFixed(2), 'mm'],
|
||||
['Suhu', suhu.toStringAsFixed(1), '°C'],
|
||||
['Tinggi Air', tinggiAir.toStringAsFixed(1), 'cm'],
|
||||
]),
|
||||
|
||||
// — Tabel history jika ada
|
||||
if (historyData != null && historyData.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 24),
|
||||
buildSectionTitle('Riwayat Data'),
|
||||
pw.SizedBox(height: 8),
|
||||
_buildHistoryTable(historyData),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await Printing.layoutPdf(onLayout: (_) => pdf.save());
|
||||
}
|
||||
|
||||
pw.Widget _buildHistoryTable(List<Map<String, dynamic>> data) {
|
||||
return buildDataTable(
|
||||
headers: ['No', 'Waktu', 'Evaporasi (mm)', 'Suhu (°C)', 'Tinggi Air (cm)'],
|
||||
rows: data.asMap().entries.map((e) {
|
||||
final d = e.value;
|
||||
final ts = d['timestamp'] as DateTime?;
|
||||
return [
|
||||
'${e.key + 1}',
|
||||
ts != null ? pdfFullFormat.format(ts) : '-',
|
||||
(d['evaporasi'] as double? ?? 0).toStringAsFixed(2),
|
||||
(d['suhu'] as double? ?? 0).toStringAsFixed(1),
|
||||
(d['tinggiAir'] as double? ?? 0).toStringAsFixed(1),
|
||||
];
|
||||
}).toList(),
|
||||
colWidths: {
|
||||
0: const pw.FixedColumnWidth(25),
|
||||
1: const pw.FlexColumnWidth(2),
|
||||
2: const pw.FlexColumnWidth(1.3),
|
||||
3: const pw.FlexColumnWidth(1),
|
||||
4: const pw.FlexColumnWidth(1.3),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// PDF Components — Reusable builders
|
||||
/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/pdf_components.dart
|
||||
/// ============================================================
|
||||
|
||||
// ─── Warna tema ───────────────────────────────────────────────
|
||||
const kPdfPrimary = PdfColor.fromInt(0xFF1565C0);
|
||||
const kPdfAccent = PdfColor.fromInt(0xFF42A5F5);
|
||||
const kPdfRowEven = PdfColor.fromInt(0xFFF5F9FF);
|
||||
const kPdfBorder = PdfColor.fromInt(0xFFBBDEFB);
|
||||
|
||||
// ─── Format tanggal ───────────────────────────────────────────
|
||||
final pdfDateFormat = DateFormat('dd MMMM yyyy', 'id_ID');
|
||||
final pdfTimeFormat = DateFormat('HH:mm:ss');
|
||||
final pdfFullFormat = DateFormat('dd MMM yyyy, HH:mm', 'id_ID');
|
||||
|
||||
// ================================================================
|
||||
// HEADER — muncul di setiap halaman
|
||||
// ================================================================
|
||||
pw.Widget buildPdfHeader(String title, DateTime date) {
|
||||
return pw.Container(
|
||||
margin: const pw.EdgeInsets.only(bottom: 16),
|
||||
decoration: const pw.BoxDecoration(
|
||||
border: pw.Border(
|
||||
bottom: pw.BorderSide(color: kPdfBorder, width: 1.5),
|
||||
),
|
||||
),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
title,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: kPdfPrimary,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(
|
||||
'Sistem Monitoring Klimatologi',
|
||||
style: pw.TextStyle(fontSize: 10, color: PdfColors.grey600),
|
||||
),
|
||||
],
|
||||
),
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
||||
children: [
|
||||
pw.Text(
|
||||
pdfDateFormat.format(date),
|
||||
style: pw.TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: kPdfPrimary,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
pdfTimeFormat.format(date),
|
||||
style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// FOOTER — muncul di setiap halaman
|
||||
// ================================================================
|
||||
pw.Widget buildPdfFooter(pw.Context context) {
|
||||
return pw.Container(
|
||||
margin: const pw.EdgeInsets.only(top: 12),
|
||||
decoration: const pw.BoxDecoration(
|
||||
border: pw.Border(top: pw.BorderSide(color: kPdfBorder, width: 1)),
|
||||
),
|
||||
child: pw.Padding(
|
||||
padding: const pw.EdgeInsets.only(top: 6),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Digenerate otomatis oleh Aplikasi Klimatologi',
|
||||
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey500),
|
||||
),
|
||||
pw.Text(
|
||||
'Halaman ${context.pageNumber} / ${context.pagesCount}',
|
||||
style: pw.TextStyle(fontSize: 8, color: PdfColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SECTION TITLE — judul bagian dengan background biru
|
||||
// ================================================================
|
||||
pw.Widget buildSectionTitle(String title) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 6, horizontal: 10),
|
||||
decoration: const pw.BoxDecoration(
|
||||
color: kPdfPrimary,
|
||||
borderRadius: pw.BorderRadius.all(pw.Radius.circular(4)),
|
||||
),
|
||||
child: pw.Text(
|
||||
title,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SUMMARY CARD — grid kartu nilai terkini
|
||||
// ================================================================
|
||||
pw.Widget buildSummaryCard({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required List<SummaryItem> items,
|
||||
}) {
|
||||
return pw.Container(
|
||||
padding: const pw.EdgeInsets.all(16),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: const PdfColor.fromInt(0xFFE3F2FD),
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8)),
|
||||
border: pw.Border.all(color: kPdfBorder),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
title,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: kPdfPrimary,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
subtitle,
|
||||
style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
|
||||
),
|
||||
pw.SizedBox(height: 12),
|
||||
pw.Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: items.map((item) => _summaryItemCard(item)).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _summaryItemCard(SummaryItem item) {
|
||||
return pw.Container(
|
||||
width: 200,
|
||||
padding: const pw.EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.white,
|
||||
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(6)),
|
||||
border: pw.Border.all(color: kPdfBorder),
|
||||
),
|
||||
child: pw.Row(
|
||||
children: [
|
||||
pw.Text(item.emoji, style: const pw.TextStyle(fontSize: 18)),
|
||||
pw.SizedBox(width: 8),
|
||||
pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
item.label,
|
||||
style: pw.TextStyle(fontSize: 9, color: PdfColors.grey600),
|
||||
),
|
||||
pw.Text(
|
||||
item.value,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: kPdfPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BASE TABLE — semua tabel pakai ini
|
||||
// ================================================================
|
||||
pw.Widget buildDataTable({
|
||||
required List<String> headers,
|
||||
required List<List<String>> rows,
|
||||
Map<int, pw.TableColumnWidth>? colWidths,
|
||||
}) {
|
||||
return pw.Table(
|
||||
border: pw.TableBorder.all(color: kPdfBorder, width: 0.5),
|
||||
columnWidths: colWidths,
|
||||
children: [
|
||||
// Header
|
||||
pw.TableRow(
|
||||
decoration: const pw.BoxDecoration(color: kPdfPrimary),
|
||||
children: headers
|
||||
.map((h) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(
|
||||
vertical: 7, horizontal: 8),
|
||||
child: pw.Text(
|
||||
h,
|
||||
style: pw.TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: PdfColors.white,
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
// Data rows
|
||||
...rows.asMap().entries.map((entry) {
|
||||
final isEven = entry.key % 2 == 0;
|
||||
return pw.TableRow(
|
||||
decoration: pw.BoxDecoration(
|
||||
color: isEven ? kPdfRowEven : PdfColors.white,
|
||||
),
|
||||
children: entry.value
|
||||
.map((cell) => pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(
|
||||
vertical: 5, horizontal: 8),
|
||||
child: pw.Text(cell,
|
||||
style: const pw.TextStyle(fontSize: 9)),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// KEY-VALUE TABLE — tabel 3 kolom: Parameter | Nilai | Satuan
|
||||
// ================================================================
|
||||
pw.Widget buildKeyValueTable(List<List<String>> rows) {
|
||||
return buildDataTable(
|
||||
headers: rows.first,
|
||||
rows: rows.skip(1).toList(),
|
||||
colWidths: {
|
||||
0: const pw.FlexColumnWidth(2),
|
||||
1: const pw.FlexColumnWidth(1.5),
|
||||
2: const pw.FlexColumnWidth(1),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// HELPER CLASS
|
||||
// ================================================================
|
||||
class SummaryItem {
|
||||
final String label;
|
||||
final String value;
|
||||
final String emoji;
|
||||
const SummaryItem(this.label, this.value, this.emoji);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// STATS HELPER
|
||||
// ================================================================
|
||||
Map<String, double> calcStats(List<double> data) {
|
||||
if (data.isEmpty) return {'avg': 0, 'max': 0, 'min': 0};
|
||||
final sorted = List<double>.from(data)..sort();
|
||||
final avg = data.reduce((a, b) => a + b) / data.length;
|
||||
return {'avg': avg, 'max': sorted.last, 'min': sorted.first};
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'atmospheric_pdf_builder.dart';
|
||||
import 'evaporasi_pdf_builder.dart';
|
||||
import 'wind_speed_pdf_builder.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// PDF Export Service — Entry Point
|
||||
/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/pdf_export_service.dart
|
||||
///
|
||||
/// Ini cuma facade — semua logic ada di builder masing-masing.
|
||||
/// Import file ini saja di screen kamu.
|
||||
/// ============================================================
|
||||
|
||||
class PdfExportService {
|
||||
PdfExportService._(); // tidak bisa di-instantiate
|
||||
|
||||
static Future<void> atmospheric({
|
||||
required double temperature,
|
||||
required double humidity,
|
||||
required double pressure,
|
||||
required double altitude,
|
||||
required DateTime timestamp,
|
||||
List<Map<String, dynamic>>? historyData,
|
||||
}) =>
|
||||
exportAtmosphericPdf(
|
||||
temperature: temperature,
|
||||
humidity: humidity,
|
||||
pressure: pressure,
|
||||
altitude: altitude,
|
||||
timestamp: timestamp,
|
||||
historyData: historyData,
|
||||
);
|
||||
|
||||
static Future<void> evaporasi({
|
||||
required double evaporasi,
|
||||
required double suhu,
|
||||
required double tinggiAir,
|
||||
required DateTime timestamp,
|
||||
List<Map<String, dynamic>>? historyData,
|
||||
}) =>
|
||||
exportEvaporasiPdf(
|
||||
evaporasi: evaporasi,
|
||||
suhu: suhu,
|
||||
tinggiAir: tinggiAir,
|
||||
timestamp: timestamp,
|
||||
historyData: historyData,
|
||||
);
|
||||
|
||||
static Future<void> windSpeed({
|
||||
required double currentSpeed,
|
||||
required String period,
|
||||
required List<double> speeds,
|
||||
required DateTime timestamp,
|
||||
List<Map<String, dynamic>>? historyData,
|
||||
}) =>
|
||||
exportWindSpeedPdf(
|
||||
currentSpeed: currentSpeed,
|
||||
period: period,
|
||||
speeds: speeds,
|
||||
timestamp: timestamp,
|
||||
historyData: historyData,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import 'pdf_components.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// Wind Speed PDF Builder
|
||||
/// Letakkan di: lib/screens/monitoring/shared/utils/pdf/wind_speed_pdf_builder.dart
|
||||
/// ============================================================
|
||||
|
||||
Future<void> exportWindSpeedPdf({
|
||||
required double currentSpeed,
|
||||
required String period,
|
||||
required List<double> speeds,
|
||||
required DateTime timestamp,
|
||||
List<Map<String, dynamic>>? historyData,
|
||||
}) async {
|
||||
final pdf = pw.Document();
|
||||
final stats = calcStats(speeds);
|
||||
|
||||
pdf.addPage(
|
||||
pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
header: (ctx) => buildPdfHeader('Laporan Kecepatan Angin', timestamp),
|
||||
footer: (ctx) => buildPdfFooter(ctx),
|
||||
build: (ctx) => [
|
||||
// — Summary card
|
||||
buildSummaryCard(
|
||||
title: 'Data Terkini',
|
||||
subtitle: 'Periode: $period',
|
||||
items: [
|
||||
SummaryItem('Kecepatan', '${currentSpeed.toStringAsFixed(1)} m/s', '💨'),
|
||||
SummaryItem('Rata-rata', '${stats['avg']!.toStringAsFixed(1)} m/s', '📊'),
|
||||
SummaryItem('Maksimum', '${stats['max']!.toStringAsFixed(1)} m/s', '⬆️'),
|
||||
SummaryItem('Minimum', '${stats['min']!.toStringAsFixed(1)} m/s', '⬇️'),
|
||||
],
|
||||
),
|
||||
pw.SizedBox(height: 24),
|
||||
|
||||
// — Tabel data per periode
|
||||
if (speeds.isNotEmpty) ...[
|
||||
buildSectionTitle('Data Periode ($period)'),
|
||||
pw.SizedBox(height: 8),
|
||||
_buildPeriodTable(speeds, period),
|
||||
],
|
||||
|
||||
// — Tabel history jika ada
|
||||
if (historyData != null && historyData.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 24),
|
||||
buildSectionTitle('Riwayat Detail'),
|
||||
pw.SizedBox(height: 8),
|
||||
_buildHistoryTable(historyData),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await Printing.layoutPdf(onLayout: (_) => pdf.save());
|
||||
}
|
||||
|
||||
pw.Widget _buildPeriodTable(List<double> speeds, String period) {
|
||||
final labels = _periodLabels(speeds.length, period);
|
||||
return buildDataTable(
|
||||
headers: ['No', 'Label', 'Kecepatan (m/s)'],
|
||||
rows: speeds.asMap().entries.map((e) => [
|
||||
'${e.key + 1}',
|
||||
labels[e.key],
|
||||
e.value.toStringAsFixed(2),
|
||||
]).toList(),
|
||||
colWidths: {
|
||||
0: const pw.FixedColumnWidth(30),
|
||||
1: const pw.FlexColumnWidth(2),
|
||||
2: const pw.FlexColumnWidth(1.5),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pw.Widget _buildHistoryTable(List<Map<String, dynamic>> data) {
|
||||
return buildDataTable(
|
||||
headers: ['No', 'Waktu', 'Kecepatan (m/s)', 'Pulse'],
|
||||
rows: data.asMap().entries.map((e) {
|
||||
final d = e.value;
|
||||
final ts = d['timestamp'] as DateTime?;
|
||||
return [
|
||||
'${e.key + 1}',
|
||||
ts != null ? pdfFullFormat.format(ts) : '-',
|
||||
(d['speed'] as double? ?? 0).toStringAsFixed(2),
|
||||
'${d['pulse'] ?? 0}',
|
||||
];
|
||||
}).toList(),
|
||||
colWidths: {
|
||||
0: const pw.FixedColumnWidth(25),
|
||||
1: const pw.FlexColumnWidth(2.5),
|
||||
2: const pw.FlexColumnWidth(1.5),
|
||||
3: const pw.FlexColumnWidth(1),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _periodLabels(int count, String period) {
|
||||
if (period == 'Hari Ini') {
|
||||
return List.generate(count, (i) => 'Jam ${i.toString().padLeft(2, '0')}:00');
|
||||
} else if (period == 'Minggu Ini') {
|
||||
const days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
|
||||
return List.generate(count, (i) => days[i % 7]);
|
||||
} else {
|
||||
return List.generate(count, (i) => 'Minggu ${i + 1}');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// Export PDF Button — Reusable Widget
|
||||
/// Letakkan di: lib/screens/monitoring/shared/widgets/export_pdf_button.dart
|
||||
/// ============================================================
|
||||
///
|
||||
/// Cara pakai (contoh di WindSpeedScreen):
|
||||
///
|
||||
/// ExportPdfButton(
|
||||
/// onExport: () async {
|
||||
/// await PdfExportService.exportWindSpeedPdf(
|
||||
/// currentSpeed: state.currentSpeed,
|
||||
/// period: state.selectedPeriod,
|
||||
/// speeds: data,
|
||||
/// timestamp: DateTime.now(),
|
||||
/// );
|
||||
/// },
|
||||
/// )
|
||||
|
||||
class ExportPdfButton extends StatefulWidget {
|
||||
/// Callback async yang memanggil PdfExportService
|
||||
final Future<void> Function() onExport;
|
||||
|
||||
/// Label tombol (default: "Export PDF")
|
||||
final String label;
|
||||
|
||||
/// Style: FAB (floating) atau ElevatedButton (inline)
|
||||
final ExportButtonStyle style;
|
||||
|
||||
const ExportPdfButton({
|
||||
super.key,
|
||||
required this.onExport,
|
||||
this.label = 'Export PDF',
|
||||
this.style = ExportButtonStyle.elevated,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExportPdfButton> createState() => _ExportPdfButtonState();
|
||||
}
|
||||
|
||||
class _ExportPdfButtonState extends State<ExportPdfButton> {
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _handleExport() async {
|
||||
if (_isLoading) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await widget.onExport();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal export PDF: $e'),
|
||||
backgroundColor: Colors.red.shade600,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.style == ExportButtonStyle.fab
|
||||
? _buildFab()
|
||||
: _buildElevated();
|
||||
}
|
||||
|
||||
// — FAB style (floating action button)
|
||||
Widget _buildFab() {
|
||||
return FloatingActionButton.extended(
|
||||
onPressed: _isLoading ? null : _handleExport,
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.picture_as_pdf, color: Colors.white),
|
||||
label: Text(
|
||||
_isLoading ? 'Menyiapkan...' : widget.label,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// — Elevated button style (inline di dalam konten)
|
||||
Widget _buildElevated() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : _handleExport,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.picture_as_pdf, color: Colors.white),
|
||||
label: Text(
|
||||
_isLoading ? 'Menyiapkan PDF...' : widget.label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ExportButtonStyle { fab, elevated }
|
||||
|
|
@ -3,51 +3,186 @@ import 'package:bloc/bloc.dart';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||
import 'package:bloc_concurrency/bloc_concurrency.dart';
|
||||
import '../../../../core/utils/time_series_mapper.dart';
|
||||
|
||||
part 'wind_speed_event.dart';
|
||||
part 'wind_speed_state.dart';
|
||||
|
||||
class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
|
||||
final MonitoringRepository _repository;
|
||||
StreamSubscription<MyWindSpeed>? _subscription;
|
||||
|
||||
WindSpeedBloc({required MonitoringRepository repository})
|
||||
: _repository = repository,
|
||||
super(const WindSpeedState()) {
|
||||
// Handler saat aplikasi minta mulai monitoring
|
||||
on<WatchWindSpeedStarted>(
|
||||
(event, emit) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
/// 🔥 START MONITORING
|
||||
on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
|
||||
|
||||
await emit.forEach<MyWindSpeed>(
|
||||
_repository.getSensorStream(
|
||||
'anemometer/realtime', (json) => MyWindSpeed.fromJson(json)),
|
||||
onData: (data) {
|
||||
// Mengambil list lama dan menambah data baru untuk grafik
|
||||
final updatedSpeeds = List<double>.from(state.dailySpeeds)
|
||||
..add(data.speed);
|
||||
// Batasi jumlah data di grafik (misal cuma simpan 20 data terakhir agar tidak berat)
|
||||
if (updatedSpeeds.length > 20) {
|
||||
updatedSpeeds.removeAt(0);
|
||||
}
|
||||
/// 🔥 REALTIME UPDATE
|
||||
on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated);
|
||||
|
||||
return state.copyWith(
|
||||
currentSpeed: data.speed,
|
||||
dailySpeeds: updatedSpeeds, // Update list grafik
|
||||
isLoading: false,
|
||||
);
|
||||
},
|
||||
onError: (error, stackTrace) => state.copyWith(isLoading: false),
|
||||
); // emit forEach
|
||||
},
|
||||
transformer: restartable(),
|
||||
/// 🔥 CHANGE PERIOD
|
||||
on<WindSpeedPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🚀 START
|
||||
/// =========================
|
||||
Future<void> _onStarted(
|
||||
WatchWindSpeedStarted event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(isLoading: true));
|
||||
|
||||
/// 1. Ambil history SEKALI
|
||||
final history = await _repository.getSensorHistory(
|
||||
'anemometer/history',
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
);
|
||||
|
||||
on<WindSpeedPeriodChanged>((event, emit) {
|
||||
emit(state.copyWith(selectedPeriod: event.period));
|
||||
/// 2. Mapping default (harian)
|
||||
final raw = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
final dailyGraph = TimeSeriesMapper.smooth(raw);
|
||||
|
||||
final daily = TimeSeriesMapper.smooth(
|
||||
TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
),
|
||||
);
|
||||
|
||||
final weekly = TimeSeriesMapper.smooth(
|
||||
TimeSeriesMapper.toWeekly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
),
|
||||
);
|
||||
|
||||
final monthly = TimeSeriesMapper.smooth(
|
||||
TimeSeriesMapper.toMonthly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
),
|
||||
);
|
||||
|
||||
emit(state.copyWith(
|
||||
history: history,
|
||||
dailySpeeds: dailyGraph,
|
||||
weeklySpeeds: weekly,
|
||||
monthlySpeeds: monthly,
|
||||
isLoading: false,
|
||||
));
|
||||
|
||||
/// 3. Start realtime stream (manual subscription)
|
||||
await _subscription?.cancel();
|
||||
|
||||
_subscription = _repository
|
||||
.getSensorStream(
|
||||
'anemometer/realtime',
|
||||
(json) => MyWindSpeed.fromJson(json),
|
||||
)
|
||||
.listen((data) {
|
||||
add(_WindSpeedRealtimeUpdated(data));
|
||||
});
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// ⚡ REALTIME UPDATE
|
||||
/// =========================
|
||||
void _onRealtimeUpdated(
|
||||
_WindSpeedRealtimeUpdated event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) {
|
||||
final updated = List<double>.from(state.dailySpeeds);
|
||||
|
||||
final index = DateTime.now().hour;
|
||||
double newValue = event.data.speed;
|
||||
|
||||
if (index < updated.length) {
|
||||
final lastValue = updated[index];
|
||||
|
||||
/// 🔥 ANTI SPIKE + SMOOTHING
|
||||
if (lastValue != 0) {
|
||||
if ((newValue - lastValue).abs() > 15) {
|
||||
newValue = lastValue; // buang spike
|
||||
} else {
|
||||
newValue = (lastValue + newValue) / 2; // smoothing
|
||||
}
|
||||
}
|
||||
|
||||
updated[index] = newValue.clamp(0, 100);
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
currentSpeed: newValue,
|
||||
dailySpeeds: updated,
|
||||
));
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 📊 CHANGE PERIOD
|
||||
/// =========================
|
||||
Future<void> _onPeriodChanged(
|
||||
WindSpeedPeriodChanged event,
|
||||
Emitter<WindSpeedState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(selectedPeriod: event.period));
|
||||
|
||||
final history = state.history;
|
||||
|
||||
List<double> raw;
|
||||
|
||||
if (event.period == "Minggu Ini") {
|
||||
raw = TimeSeriesMapper.toWeekly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
} else if (event.period == "Bulan Ini") {
|
||||
raw = TimeSeriesMapper.toMonthly(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
} else {
|
||||
raw = TimeSeriesMapper.toDaily(
|
||||
data: history,
|
||||
getTime: (e) => e.timestamp,
|
||||
getValue: (e) => e.speed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 baru di-smooth
|
||||
final updatedGraph = TimeSeriesMapper.smooth(raw);
|
||||
|
||||
emit(state.copyWith(
|
||||
dailySpeeds: updatedGraph,
|
||||
isLoading: false,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
Future<void> close() async {
|
||||
await _subscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// =========================
|
||||
/// 🔒 INTERNAL EVENT (PRIVATE)
|
||||
/// =========================
|
||||
class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
|
||||
final MyWindSpeed data;
|
||||
|
||||
const _WindSpeedRealtimeUpdated(this.data);
|
||||
|
||||
@override
|
||||
List<Object> get props => [data];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,31 +3,43 @@ part of 'wind_speed_bloc.dart';
|
|||
class WindSpeedState extends Equatable {
|
||||
final double currentSpeed;
|
||||
final String selectedPeriod;
|
||||
final List<double> dailySpeeds; // Untuk grafik
|
||||
final List<double> dailySpeeds;
|
||||
final List<double> weeklySpeeds;
|
||||
final List<double> monthlySpeeds;
|
||||
final bool isLoading;
|
||||
final List<MyWindSpeed> history;
|
||||
|
||||
const WindSpeedState({
|
||||
this.currentSpeed = 0.0,
|
||||
this.selectedPeriod = "Hari Ini",
|
||||
this.dailySpeeds = const [],
|
||||
this.isLoading = true,
|
||||
this.history = const [],
|
||||
this.monthlySpeeds = const [],
|
||||
this.weeklySpeeds = const [],
|
||||
});
|
||||
|
||||
WindSpeedState copyWith({
|
||||
double? currentSpeed,
|
||||
String? selectedPeriod,
|
||||
List<double>? dailySpeeds,
|
||||
List<double>? weeklySpeeds,
|
||||
List<double>? monthlySpeeds,
|
||||
bool? isLoading,
|
||||
List<MyWindSpeed>? history,
|
||||
}) {
|
||||
return WindSpeedState(
|
||||
currentSpeed: currentSpeed ?? this.currentSpeed,
|
||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||
dailySpeeds: dailySpeeds ?? this.dailySpeeds,
|
||||
weeklySpeeds: weeklySpeeds ?? this.weeklySpeeds,
|
||||
monthlySpeeds: monthlySpeeds ?? this.monthlySpeeds,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
history: history ?? this.history,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props =>
|
||||
[currentSpeed, selectedPeriod, dailySpeeds, isLoading];
|
||||
[currentSpeed, selectedPeriod, dailySpeeds, isLoading, history];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/// === Tombol Filter Jam/Hari/Minggu === ///
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../blocs/wind_speed_bloc.dart';
|
||||
|
||||
class PeriodSelector extends StatelessWidget {
|
||||
const PeriodSelector({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Kita ambil state saat ini untuk tahu periode mana yang aktif
|
||||
final selectedPeriod =
|
||||
context.select((WindSpeedBloc bloc) => bloc.state.selectedPeriod);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildTab(context, "Hari Ini", selectedPeriod),
|
||||
_buildTab(context, "Minggu Ini", selectedPeriod),
|
||||
_buildTab(context, "Bulan Ini", selectedPeriod),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTab(BuildContext context, String label, String current) {
|
||||
bool isActive = label == current;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Kirim event ke Bloc saat tombol diklik
|
||||
context.read<WindSpeedBloc>().add(WindSpeedPeriodChanged(label));
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? Colors.blue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.white : Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
/// === File ini khusus untuk urusan Grafik === ///
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
// import 'package:intl/intl.dart';
|
||||
|
||||
class WindSpeedChartWidget extends StatelessWidget {
|
||||
final List<double> dailySpeeds;
|
||||
final String period; // menambahkan parameter periode
|
||||
|
||||
const WindSpeedChartWidget(
|
||||
{super.key, required this.dailySpeeds, required this.period});
|
||||
|
||||
double _getMaxY() {
|
||||
if (dailySpeeds.isEmpty) return 10;
|
||||
|
||||
final max = dailySpeeds.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return (max + 5).clamp(10, 100); // kasih padding
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<double> safeData = dailySpeeds.map((e) {
|
||||
if (e.isNaN || e.isInfinite) return 0.0;
|
||||
return e < 0 ? 0.0 : e;
|
||||
}).toList();
|
||||
return Container(
|
||||
height: 270,
|
||||
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: LineChart(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOutCubic,
|
||||
LineChartData(
|
||||
minY: 0,
|
||||
|
||||
maxY: _getMaxY(),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
|
||||
// === PENGATURAN LABEL SUMBU X ===
|
||||
titlesData: FlTitlesData(
|
||||
show: true,
|
||||
rightTitles:
|
||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles:
|
||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles:
|
||||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval:
|
||||
_getInterval(), // Mengatur jarak label agar tidak tumpang tindih
|
||||
getTitlesWidget: (value, meta) {
|
||||
return SideTitleWidget(
|
||||
meta: meta,
|
||||
space: 8,
|
||||
child: Text(
|
||||
_getBottomTitle(value),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 10),
|
||||
),
|
||||
);
|
||||
}))),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: safeData.asMap().entries.map((e) {
|
||||
return FlSpot(e.key.toDouble(), e.value);
|
||||
}).toList(),
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.2,
|
||||
preventCurveOverShooting: true,
|
||||
color: Colors.blue.shade700,
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, bar, index) {
|
||||
if (index == dailySpeeds.length - 1) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 5,
|
||||
color: Colors.red,
|
||||
strokeWidth: 2,
|
||||
strokeColor: Colors.white,
|
||||
);
|
||||
}
|
||||
return FlDotCirclePainter(radius: 0);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.blue.withOpacity(0.35),
|
||||
Colors.blue.withOpacity(0.1),
|
||||
Colors.transparent
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
shadow: Shadow(
|
||||
color: Colors.blue.withOpacity(0.3),
|
||||
blurRadius: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
handleBuiltInTouches: true,
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (touchedSpots) {
|
||||
return touchedSpots.map((spot) {
|
||||
return LineTooltipItem(
|
||||
"${spot.y.toStringAsFixed(1)} m/s",
|
||||
const TextStyle(color: Colors.white),
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Logika untuk menentukan teks label di bawah
|
||||
String _getBottomTitle(double value) {
|
||||
int index = value.toInt();
|
||||
if (index < 0 || index >= dailySpeeds.length) return '';
|
||||
|
||||
if (period == "Hari Ini") {
|
||||
// Tampilkan jam genap saja (0, 2, 4...) agar tidak penuh
|
||||
return index % 4 == 0 ? "${index.toString().padLeft(2, '0')}:00" : "";
|
||||
} else if (period == "Minggu Ini") {
|
||||
const days = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
|
||||
return days[index % 7];
|
||||
} else {
|
||||
// Bulanan: Tampilkan tanggal kelipatan 5
|
||||
return (index + 1) % 5 == 0 ? "${index + 1}" : "";
|
||||
}
|
||||
}
|
||||
|
||||
double _getInterval() {
|
||||
if (period == "Hari Ini") return 1; // Cek tiap jam
|
||||
if (period == "Minggu Ini") return 1; // Cek tiap hari
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +1,98 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
// import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../blocs/wind_speed_bloc.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';
|
||||
|
||||
class WindSpeedScreen extends StatelessWidget {
|
||||
const WindSpeedScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Pastikan MonitoringRepository sudah diprovide di atas screen ini
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("Wind Speed Monitoring")),
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"Kecepatan Angin",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
|
||||
/// === Body area, lokasi dan tata letak Widgets === ///
|
||||
body: BlocBuilder<WindSpeedBloc, WindSpeedState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
// Pilih data sesuai periode
|
||||
final List<double> data = switch (state.selectedPeriod) {
|
||||
"Minggu Ini" => state.weeklySpeeds,
|
||||
"Bulan Ini" => state.monthlySpeeds,
|
||||
_ => state.dailySpeeds,
|
||||
};
|
||||
|
||||
// List<double> data;
|
||||
|
||||
// if (state.selectedPeriod == "Minggu Ini") {
|
||||
// data = state.weeklySpeeds;
|
||||
// } else if (state.selectedPeriod == "Bulan Ini") {
|
||||
// data = state.monthlySpeeds;
|
||||
// } else {
|
||||
// data = state.dailySpeeds;
|
||||
// }
|
||||
|
||||
// Konversi history MyWindSpeed → Map (untuk tabel PDF)
|
||||
final historyMaps = state.history
|
||||
.map((e) => {
|
||||
'timestamp': e.timestamp,
|
||||
'speed': e.speed,
|
||||
'pulse': e.pulse,
|
||||
})
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Card Informasi Kecepatan Saat Ini
|
||||
_buildCurrentSpeedCard(state.currentSpeed),
|
||||
/// ==== 1. Tampilan Angka Utama kecepatan angin (Hero Widget look) ==== ///
|
||||
_buildMainSpeedDisplay(state.currentSpeed),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Area Grafik
|
||||
const Text("Grafik Kecepatan Angin",
|
||||
const Text("Tren Kecepatan",
|
||||
style:
|
||||
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 15),
|
||||
const PeriodSelector(),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: LineChart(
|
||||
_mainData(state.dailySpeeds),
|
||||
WindSpeedChartWidget(
|
||||
dailySpeeds: data,
|
||||
period: state.selectedPeriod,
|
||||
),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// 3. Info Tambahan (Status/Periode)
|
||||
_buildDetailRow(state.selectedPeriod),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -45,39 +103,75 @@ class WindSpeedScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentSpeedCard(double speed) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.wind_power, color: Colors.blue, size: 40),
|
||||
title: const Text("Kecepatan Saat Ini"),
|
||||
trailing: Text("${speed.toStringAsFixed(2)} m/s",
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Widget _buildMainSpeedDisplay(double speed) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue.shade400, Colors.blue.shade800],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withOpacity(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,
|
||||
),
|
||||
Text(
|
||||
"${speed.toStringAsFixed(1)}",
|
||||
style: const TextStyle(
|
||||
fontSize: 80, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
),
|
||||
const Text("m/s",
|
||||
style: TextStyle(fontSize: 20, color: Colors.white70))
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
LineChartData _mainData(List<double> speeds) {
|
||||
return LineChartData(
|
||||
gridData: const FlGridData(show: true),
|
||||
titlesData: const FlTitlesData(show: true), // Bisa dikustom nanti
|
||||
borderData: FlBorderData(show: true),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
// Mengubah List<double> menjadi titik koordinat grafik (x, y)
|
||||
spots: speeds.asMap().entries.map((e) {
|
||||
return FlSpot(e.key.toDouble(), e.value);
|
||||
}).toList(),
|
||||
isCurved: true, // Biar garisnya melengkung halus
|
||||
color: Colors.blue,
|
||||
barWidth: 3,
|
||||
dotData: const FlDotData(show: false), // Sembunyikan titik per data
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Colors.blue.withOpacity(0.2), // Efek bayangan di bawah garis
|
||||
),
|
||||
),
|
||||
Widget _buildDetailRow(String period) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_miniInfoCard("Status", "Normal", Icons.check_circle, Colors.green),
|
||||
_miniInfoCard("Periode", period, Icons.timer, Colors.orange),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _miniInfoCard(String label, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
width: 160,
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 30),
|
||||
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)),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <printing/printing_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||
printing_plugin_register_with_registrar(printing_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
printing
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import firebase_auth
|
|||
import firebase_core
|
||||
import firebase_database
|
||||
import google_sign_in_ios
|
||||
import printing
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||
|
|
@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,4 +38,28 @@ class FirebaseMonitoringRepo implements MonitoringRepository {
|
|||
final data = snapshot.value as Map<dynamic, dynamic>? ?? {};
|
||||
return mapper(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<T>> getSensorHistory<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
) async {
|
||||
try {
|
||||
// Ambil data dari path history
|
||||
final snapshot = await _db.ref(path).get();
|
||||
|
||||
if (snapshot.exists && snapshot.value is Map) {
|
||||
final Map<dynamic, dynamic> data = snapshot.value as Map;
|
||||
|
||||
// Karena RTDB push() menghasilkan Map dengan ID unik,
|
||||
// kita ambil values-nya saja dan ubah jadi List objek
|
||||
return data.values.map((item) {
|
||||
return mapper(item as Map<dynamic, dynamic>);
|
||||
}).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
class AtmosphericConditions {
|
||||
final double temperature;
|
||||
final double humidity;
|
||||
final double pressure;
|
||||
final double altitude;
|
||||
final DateTime timestamp;
|
||||
|
||||
AtmosphericConditions({
|
||||
required this.temperature,
|
||||
required this.humidity,
|
||||
required this.pressure,
|
||||
required this.altitude,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
factory AtmosphericConditions.fromJson(Map<dynamic, dynamic> json) {
|
||||
return AtmosphericConditions(
|
||||
temperature: (json['temperature'] ?? 0).toDouble(),
|
||||
humidity: (json['humidity'] ?? 0).toDouble(),
|
||||
pressure: (json['pressure'] ?? 0).toDouble(),
|
||||
altitude: (json['altitude'] ?? 0).toDouble(),
|
||||
timestamp: DateTime.now(), // karena kamu pakai latest
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
class Evaporasi {
|
||||
final double evaporasi;
|
||||
final double suhu;
|
||||
final double tinggiAir;
|
||||
final DateTime timestamp;
|
||||
|
||||
Evaporasi({
|
||||
required this.evaporasi,
|
||||
required this.suhu,
|
||||
required this.tinggiAir,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
static final empty = Evaporasi(
|
||||
evaporasi: 0.0,
|
||||
suhu: 0.0,
|
||||
tinggiAir: 0.0,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
||||
final now = DateTime.now();
|
||||
DateTime timestamp = now;
|
||||
|
||||
final waktuStr = json['waktu'] as String?;
|
||||
if (waktuStr != null) {
|
||||
final parts = waktuStr.split(':');
|
||||
if (parts.length >= 2) {
|
||||
final jam = int.tryParse(parts[0]) ?? 0;
|
||||
final menit = int.tryParse(parts[1]) ?? 0;
|
||||
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
|
||||
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
|
||||
}
|
||||
}
|
||||
|
||||
return Evaporasi(
|
||||
evaporasi: (json['evaporasi'] ?? 0).toDouble(),
|
||||
suhu: (json['suhu_air'] ?? 0).toDouble(),
|
||||
tinggiAir: (json['tinggi_air'] ?? 0).toDouble(),
|
||||
|
||||
/// 🔥 bikin timestamp dari jam & menit
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export 'wind_speed.dart';
|
||||
export 'evaporasi.dart';
|
||||
export 'pressure_sensor.dart';
|
||||
export 'atmospheric_conditions.dart';
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ class MyWindSpeed {
|
|||
required this.timestamp,
|
||||
});
|
||||
|
||||
static final empty = MyWindSpeed(
|
||||
speed: 0.0,
|
||||
pulse: 0,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
factory MyWindSpeed.fromJson(Map<dynamic, dynamic> json) {
|
||||
return MyWindSpeed(
|
||||
speed: (json['speed'] ?? 0).toDouble(),
|
||||
pulse: (json['pulse'] ?? 0) as int,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(json['timestamp'] ?? 0) * 1000, // kalau dari Unix detik
|
||||
(json['timesTamp'] ?? 0) * 1000, // kalau dari Unix detik
|
||||
).toLocal(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import 'models/models.dart';
|
||||
// import 'models/models.dart';
|
||||
|
||||
abstract class MonitoringRepository {
|
||||
// fungsi untuk ambil data berkali/streaming
|
||||
Stream<T> getSensorStream<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
);
|
||||
|
||||
// fungsi untuk ambil sensor sekali
|
||||
Future<T> getSensorSnapshot<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
);
|
||||
|
||||
// Fungsi untuk ambil riwayat (List)
|
||||
Future<List<T>> getSensorHistory<T>(
|
||||
String path,
|
||||
T Function(Map<dynamic, dynamic> json) mapper,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:user_repository/user_repository.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
class FirebaseUserRepo implements UserRepository {
|
||||
final FirebaseAuth _firebaseAuth;
|
||||
|
|
@ -72,6 +74,8 @@ class FirebaseUserRepo implements UserRepository {
|
|||
/// == Melakukan Implement Log Out == ///
|
||||
@override
|
||||
Future<void> logOut() async {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
await googleSignIn.signOut();
|
||||
await _firebaseAuth.signOut();
|
||||
}
|
||||
|
||||
|
|
@ -93,26 +97,25 @@ class FirebaseUserRepo implements UserRepository {
|
|||
@override
|
||||
Future<void> signInWithGoogle() async {
|
||||
try {
|
||||
final GoogleAuthProvider googleProvider = GoogleAuthProvider();
|
||||
final UserCredential userCredential =
|
||||
await _firebaseAuth.signInWithPopup(googleProvider);
|
||||
if (kIsWeb) {
|
||||
final googleProvider = GoogleAuthProvider();
|
||||
await _firebaseAuth.signInWithPopup(googleProvider);
|
||||
} else {
|
||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||
final googleUser = await googleSignIn.signIn();
|
||||
|
||||
final User? firebaseUser = userCredential.user;
|
||||
if (firebaseUser != null) {
|
||||
// Check if user data already exists in Firestore
|
||||
final existingUser = await userCollection.doc(firebaseUser.uid).get();
|
||||
|
||||
if (!existingUser.exists) {
|
||||
// Create new user data from Google account info
|
||||
final newUser = MyUser(
|
||||
userId: firebaseUser.uid,
|
||||
email: firebaseUser.email ?? '',
|
||||
name: firebaseUser.displayName ?? 'User',
|
||||
hasActiveCart: false,
|
||||
);
|
||||
|
||||
await setUserData(newUser);
|
||||
if (googleUser == null) {
|
||||
throw Exception("cancelled");
|
||||
}
|
||||
|
||||
final GoogleSignInAuthentication googleAuth =
|
||||
await googleUser.authentication;
|
||||
|
||||
final credential = GoogleAuthProvider.credential(
|
||||
accessToken: googleAuth.accessToken,
|
||||
idToken: googleAuth.idToken,
|
||||
);
|
||||
await _firebaseAuth.signInWithCredential(credential);
|
||||
}
|
||||
} catch (e) {
|
||||
log('Google sign-in error: $e');
|
||||
|
|
|
|||
|
|
@ -152,6 +152,54 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
google_identity_services_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_identity_services_web
|
||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_sign_in:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
google_sign_in_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4+4"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -335,4 +383,4 @@ packages:
|
|||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.8.0-0 <4.0.0"
|
||||
flutter: ">=3.22.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ environment:
|
|||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
google_sign_in: ^6.3.0
|
||||
# equatable: ^2.0.5
|
||||
firebase_auth: ^6.1.3
|
||||
cloud_firestore: ^6.1.1
|
||||
|
|
@ -23,4 +23,4 @@ dev_dependencies:
|
|||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
uses-material-design: true
|
||||
|
|
|
|||
116
pubspec.lock
116
pubspec.lock
|
|
@ -9,6 +9,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.66"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -25,6 +33,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
barcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: barcode
|
||||
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.9"
|
||||
bidi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bidi
|
||||
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
bloc:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -292,42 +316,42 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
|
||||
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
version: "6.3.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: "5ec98ab35387c68c0050495bb211bd88375873723a80fae7c2e9266ea0bdd8bb"
|
||||
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.7"
|
||||
version: "6.2.1"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: "234fc2830b55d1bbeb7e05662967691f5994143ff43dc70d3f139d1bbb3b8fb2"
|
||||
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.5"
|
||||
version: "5.9.0"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
|
||||
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "2.5.0"
|
||||
google_sign_in_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da"
|
||||
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "0.12.4+4"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -352,6 +376,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -487,6 +519,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -535,6 +575,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdf
|
||||
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.12.0"
|
||||
pdf_widget_wrapper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pdf_widget_wrapper
|
||||
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -551,6 +615,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
printing:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: printing
|
||||
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.3"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -567,6 +647,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
rxdart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -683,6 +771,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -18,13 +18,15 @@ dependencies:
|
|||
sdk: flutter
|
||||
flutter_bloc: ^8.1.0
|
||||
google_fonts: ^8.0.2
|
||||
google_sign_in: ^7.2.0
|
||||
google_sign_in: ^6.3.0
|
||||
intl: ^0.20.2
|
||||
monitoring_repository:
|
||||
path: packages/monitoring_repository
|
||||
rxdart: ^0.27.7
|
||||
user_repository:
|
||||
path: packages/user_repository
|
||||
pdf: ^3.11.1
|
||||
printing: ^5.13.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^3.0.0
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
CloudFirestorePluginCApiRegisterWithRegistrar(
|
||||
|
|
@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||
cloud_firestore
|
||||
firebase_auth
|
||||
firebase_core
|
||||
printing
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Reference in New Issue