Merge branch 'branch_percobaan'

This commit is contained in:
kleponijo 2026-04-25 14:44:00 +07:00
commit 0cce4da44b
46 changed files with 2493 additions and 1950 deletions

65
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

View File

@ -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

BIN
images/logo_klimatologi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -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;
}
}

View File

@ -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(),

View File

@ -22,6 +22,7 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
emit(SignInProcess());
try {
await _userRepository.signInWithGoogle();
emit(SignInSuccess());
} catch (e) {
emit(SignInFailure());
emit(SignInInitial());

View File

@ -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: [

View File

@ -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(),

View File

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

View File

@ -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];
}

View File

@ -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 {}

View File

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

View File

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

View File

@ -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];
}

View File

@ -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];
}

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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};
}

View File

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

View File

@ -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}');
}
}

View File

@ -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 }

View File

@ -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];
}

View File

@ -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];
}

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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);
}

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
printing
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -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"))
}

View File

@ -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 [];
}
}
}

View File

@ -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
);
}
}

View File

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

View File

@ -1,3 +1,3 @@
export 'wind_speed.dart';
export 'evaporasi.dart';
export 'pressure_sensor.dart';
export 'atmospheric_conditions.dart';

View File

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

View File

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

View File

@ -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');

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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"));
}

View File

@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
cloud_firestore
firebase_auth
firebase_core
printing
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST