Update kode terbaru

This commit is contained in:
M.Nizar Fahrurrozi 2026-05-13 19:10:01 +07:00
parent 3c1e9de4bf
commit 318f572f4a
23 changed files with 2169 additions and 524 deletions

View File

@ -17,7 +17,7 @@ def flutterVersionName = localProperties.getProperty('flutter.versionName') ?: '
android {
namespace "com.example.coffee_iot_flutter"
compileSdk 35
compileSdk 36
ndkVersion flutter.ndkVersion
compileOptions {

View File

@ -14,6 +14,15 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="com.example.coffee_iot_flutter"
android:host="reset-callback" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues

View File

@ -1,22 +1,47 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart';
import 'services/mqtt_service.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
import 'screens/home_screen.dart';
import 'screens/analytics_screen.dart';
import 'screens/image_result_screen.dart';
import 'screens/change_password_screen.dart';
import 'screens/forgot_password_screen.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Supabase with the provided URL and Publishable Key
await Supabase.initialize(
url: 'https://ddmhzzegejbsshihzext.supabase.co',
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRkbWh6emVnZWpic3NoaWh6ZXh0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzcyMjIyNzAsImV4cCI6MjA5Mjc5ODI3MH0._r-viIQLMXuNNdg4tcn7sJTBO9QqSpSlF7hk43hu2Ks',
);
// Listener auth state handle password recovery
Supabase.instance.client.auth.onAuthStateChange.listen((data) {
if (data.event == AuthChangeEvent.passwordRecovery) {
navigatorKey.currentState?.pushReplacementNamed(
'/change_password',
arguments: {'dari_reset': true},
);
}
});
// Handle deep link saat app sudah jalan
final appLinks = AppLinks();
appLinks.uriLinkStream.listen((uri) {
final uriStr = uri.toString();
if (uriStr.contains('reset-callback') ||
uriStr.contains('type=recovery')) {
// Supabase akan handle token otomatis via onAuthStateChange
debugPrint('Deep link diterima: $uriStr');
}
});
runApp(
MultiProvider(
providers: [
@ -33,6 +58,7 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
title: 'Coffee IoT',
debugShowCheckedModeBanner: false,
theme: ThemeData(
@ -40,14 +66,16 @@ class MyApp extends StatelessWidget {
primarySwatch: Colors.green,
scaffoldBackgroundColor: Colors.black,
),
initialRoute: '/login', // Back to Login to test Supabase
initialRoute: '/login',
routes: {
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
'/home': (context) => const HomeScreen(),
'/analytics': (context) => const AnalyticsScreen(),
'/image_result': (context) => const ImageResultScreen(),
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
'/home': (context) => const HomeScreen(),
'/analytics': (context) => const AnalyticsScreen(),
'/image_result': (context) => const ImageResultScreen(),
'/change_password': (context) => const ChangePasswordScreen(),
'/forgot_password': (context) => const ForgotPasswordScreen(),
},
);
}
}
}

View File

@ -21,7 +21,7 @@ class _AnalyticsScreenState extends State<AnalyticsScreen> {
Future<void> _fetchLogs() async {
try {
final data = await SupabaseService().getSensorLogs();
final data = await SupabaseService().getFotoDataset();
setState(() {
_logs = data;
_isLoading = false;

View File

@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import '../services/supabase_service.dart';
class ChangePasswordScreen extends StatefulWidget {
const ChangePasswordScreen({super.key});
@override
State<ChangePasswordScreen> createState() => _ChangePasswordScreenState();
}
class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final _passLamaController = TextEditingController();
final _passBaruController = TextEditingController();
final _konfirmasiController = TextEditingController();
bool _isLoading = false;
bool _obscurePassLama = true;
bool _obscurePassBaru = true;
bool _obscureKonfirmasi = true;
// Deteksi apakah datang dari deep link reset password
// Kalau dari deep link, tidak perlu password lama
bool _dariResetLink = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Map && args['dari_reset'] == true) {
_dariResetLink = true;
}
}
@override
void dispose() {
_passLamaController.dispose();
_passBaruController.dispose();
_konfirmasiController.dispose();
super.dispose();
}
Future<void> _handleGantiPassword() async {
final passLama = _passLamaController.text.trim();
final passBaru = _passBaruController.text.trim();
final konfirmasi = _konfirmasiController.text.trim();
// Validasi
if (!_dariResetLink && passLama.isEmpty) {
_showSnackBar('Password lama harus diisi!', isError: true);
return;
}
if (passBaru.isEmpty || konfirmasi.isEmpty) {
_showSnackBar('Password baru dan konfirmasi harus diisi!', isError: true);
return;
}
if (passBaru.length < 6) {
_showSnackBar('Password baru minimal 6 karakter!', isError: true);
return;
}
if (passBaru != konfirmasi) {
_showSnackBar('Konfirmasi password tidak cocok!', isError: true);
return;
}
if (!_dariResetLink && passLama == passBaru) {
_showSnackBar('Password baru tidak boleh sama dengan password lama!',
isError: true);
return;
}
setState(() => _isLoading = true);
try {
if (!_dariResetLink) {
// Dari settings verifikasi password lama dulu
final user = SupabaseService().currentUser;
if (user == null) throw Exception('User tidak ditemukan');
await SupabaseService().signIn(user.email!, passLama);
}
// Update password baru
await SupabaseService().updatePassword(passBaru);
if (mounted) {
_showSnackBar('Password berhasil diubah!', isError: false);
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) {
if (_dariResetLink) {
// Dari reset link ke login
Navigator.pushReplacementNamed(context, '/login');
} else {
// Dari settings kembali
Navigator.pop(context);
}
}
});
}
} catch (e) {
if (mounted) {
final errorStr = e.toString().toLowerCase();
String pesan = 'Gagal mengubah password, coba lagi.';
if (errorStr.contains('invalid_credentials') ||
errorStr.contains('invalid login credentials')) {
pesan = 'Password lama salah!';
} else if (errorStr.contains('network') ||
errorStr.contains('socket')) {
pesan = 'Tidak ada koneksi internet.';
}
_showSnackBar(pesan, isError: true);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showSnackBar(String pesan, {required bool isError}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(isError ? Icons.error_outline : Icons.check_circle_outline,
color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Text(pesan,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold)),
),
],
),
backgroundColor:
isError ? Colors.red.shade700 : Colors.green.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 3),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => _dariResetLink
? Navigator.pushReplacementNamed(context, '/login')
: Navigator.pop(context),
),
title: Text(
_dariResetLink ? 'Reset Password' : 'Ganti Password',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_dariResetLink
? 'Masukkan password baru untuk akun kamu.'
: 'Ubah password akun kamu.\nPastikan password baru minimal 6 karakter.',
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 30),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Column(
children: [
// Field password lama hanya tampil kalau bukan dari reset link
if (!_dariResetLink) ...[
_buildPasswordField(
controller: _passLamaController,
label: 'Password Lama',
obscure: _obscurePassLama,
onToggle: () => setState(
() => _obscurePassLama = !_obscurePassLama),
),
const Divider(color: Colors.grey),
],
_buildPasswordField(
controller: _passBaruController,
label: 'Password Baru',
obscure: _obscurePassBaru,
onToggle: () => setState(
() => _obscurePassBaru = !_obscurePassBaru),
),
const Divider(color: Colors.grey),
_buildPasswordField(
controller: _konfirmasiController,
label: 'Konfirmasi Password Baru',
obscure: _obscureKonfirmasi,
onToggle: () => setState(
() => _obscureKonfirmasi = !_obscureKonfirmasi),
),
],
),
),
const SizedBox(height: 30),
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleGantiPassword,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'Simpan Password Baru',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
],
),
),
),
);
}
Widget _buildPasswordField({
required TextEditingController controller,
required String label,
required bool obscure,
required VoidCallback onToggle,
}) {
return TextField(
controller: controller,
obscureText: obscure,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock, color: Colors.green),
suffixIcon: IconButton(
icon: Icon(obscure ? Icons.visibility : Icons.visibility_off,
color: Colors.grey),
onPressed: onToggle,
),
hintText: label,
hintStyle: const TextStyle(color: Colors.grey),
border: InputBorder.none,
),
);
}
}

View File

@ -12,7 +12,7 @@ class ControlSettingsScreen extends StatefulWidget {
}
class _ControlSettingsScreenState extends State<ControlSettingsScreen> {
int _operatingMode = 1; // 0 = otomatis, 1 = manual
int _operatingMode = 0; // 0 = otomatis, 1 = manual
final TextEditingController _tempMinController = TextEditingController();
final TextEditingController _tempMaxController = TextEditingController();
final TextEditingController _humMinController = TextEditingController();

View File

@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import '../services/supabase_service.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _emailController = TextEditingController();
final _otpController = TextEditingController();
bool _isLoading = false;
bool _otpSent = false; // step 2: input OTP
String _email = '';
@override
void dispose() {
_emailController.dispose();
_otpController.dispose();
super.dispose();
}
// Step 1 kirim OTP ke email
Future<void> _handleKirimOTP() async {
final email = _emailController.text.trim();
if (email.isEmpty) {
_showSnackBar('Email tidak boleh kosong!', isError: true);
return;
}
if (!email.contains('@') || !email.contains('.')) {
_showSnackBar('Format email tidak valid!', isError: true);
return;
}
setState(() => _isLoading = true);
try {
await SupabaseService().resetPassword(email);
if (mounted) {
setState(() {
_otpSent = true;
_email = email;
});
}
} catch (e) {
if (mounted) {
final err = e.toString().toLowerCase();
String pesan = 'Gagal mengirim kode, coba lagi.';
if (err.contains('network') || err.contains('socket')) {
pesan = 'Tidak ada koneksi internet.';
} else if (err.contains('rate limit') || err.contains('too many')) {
pesan = 'Terlalu banyak percobaan, tunggu beberapa menit.';
} else if (err.contains('user not found') || err.contains('invalid')) {
pesan = 'Email tidak terdaftar di sistem.';
}
_showSnackBar(pesan, isError: true);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// Step 2 verifikasi OTP dan masuk ke halaman ganti password
Future<void> _handleVerifikasiOTP() async {
final otp = _otpController.text.trim();
if (otp.isEmpty || otp.length != 6) {
_showSnackBar('Masukkan kode 6 angka dari email!', isError: true);
return;
}
setState(() => _isLoading = true);
try {
await SupabaseService().verifyOTP(_email, otp);
if (mounted) {
// OTP valid langsung ke halaman ganti password
Navigator.pushReplacementNamed(
context,
'/change_password',
arguments: {'dari_reset': true},
);
}
} catch (e) {
if (mounted) {
final err = e.toString().toLowerCase();
String pesan = 'Kode tidak valid atau sudah kadaluarsa.';
if (err.contains('expired')) {
pesan = 'Kode sudah kadaluarsa. Kirim ulang kode baru.';
} else if (err.contains('invalid')) {
pesan = 'Kode salah. Periksa kembali kode di email.';
}
_showSnackBar(pesan, isError: true);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showSnackBar(String pesan, {required bool isError}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(children: [
Icon(
isError ? Icons.error_outline : Icons.check_circle_outline,
color: Colors.white,
),
const SizedBox(width: 10),
Expanded(
child: Text(pesan,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold)),
),
]),
backgroundColor:
isError ? Colors.red.shade700 : Colors.green.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 4),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.green),
onPressed: () => Navigator.pop(context),
),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30.0),
child: _otpSent ? _buildStepOTP() : _buildStepEmail(),
),
),
);
}
// Step 1: Input Email
Widget _buildStepEmail() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Lupa Password?',
style: TextStyle(
color: Colors.green, fontSize: 32, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Masukkan email akun kamu. Kami akan kirim kode verifikasi 6 angka ke inbox kamu.',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 50),
// Field email
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.email, color: Colors.green),
hintText: 'Email',
hintStyle: TextStyle(color: Colors.grey),
border: InputBorder.none,
),
),
),
const SizedBox(height: 30),
// Tombol kirim
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleKirimOTP,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'Kirim Kode Verifikasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
const SizedBox(height: 20),
Center(
child: TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Kembali ke Login',
style: TextStyle(color: Colors.green)),
),
),
],
);
}
// Step 2: Input OTP
Widget _buildStepOTP() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(
radius: 40,
backgroundColor: Color(0xFF1A1A1A),
child: Icon(Icons.mark_email_read, size: 40, color: Colors.green),
),
const SizedBox(height: 24),
const Text(
'Cek Email Kamu',
style: TextStyle(
color: Colors.green, fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Kode verifikasi 6 angka sudah dikirim ke:\n$_email\n\nMasukkan kode tersebut di bawah ini.',
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 40),
// Field OTP
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: Colors.green.withOpacity(0.5)),
),
child: TextField(
controller: _otpController,
keyboardType: TextInputType.number,
maxLength: 6,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white, fontSize: 28, letterSpacing: 12),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.lock_open, color: Colors.green),
hintText: '000000',
hintStyle: TextStyle(color: Colors.grey, letterSpacing: 8),
border: InputBorder.none,
counterText: '', // sembunyikan counter maxLength
),
),
),
const SizedBox(height: 30),
// Tombol verifikasi
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleVerifikasiOTP,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor: Colors.green.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'Verifikasi Kode',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
const SizedBox(height: 16),
// Kirim ulang kode
Center(
child: TextButton(
onPressed: _isLoading
? null
: () => setState(() {
_otpSent = false;
_otpController.clear();
}),
child: const Text(
'Kirim ulang kode',
style: TextStyle(color: Colors.green),
),
),
),
],
);
}
}

View File

@ -36,12 +36,12 @@ class _HomeScreenState extends State<HomeScreen> {
});
}
final List<Widget> _screens = [
const MonitoringScreen(),
const ControlSettingsScreen(),
const RecordingScreen(),
const SettingsScreen(),
];
List<Widget> get _screens => [
MonitoringScreen(onProfileTap: () => setState(() => _currentIndex = 3)),
const ControlSettingsScreen(),
const RecordingScreen(),
const SettingsScreen(),
];
@override
Widget build(BuildContext context) {
@ -58,7 +58,7 @@ class _HomeScreenState extends State<HomeScreen> {
showUnselectedLabels: false,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.grid_view), label: 'Monitoring'),
BottomNavigationBarItem(icon: Icon(Icons.build), label: 'Control'),
BottomNavigationBarItem(icon: Icon(Icons.tune), label: 'Control'),
BottomNavigationBarItem(icon: Icon(Icons.access_time), label: 'Recording'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],

View File

@ -1,44 +1,254 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:http/http.dart' as http;
class ImageResultScreen extends StatelessWidget {
const ImageResultScreen({super.key});
// ============================================================
// GENERATE PDF
// ============================================================
Future<void> _exportPDF(
BuildContext context, Map<String, dynamic> args) async {
// Tampilkan loading
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(
child: CircularProgressIndicator(color: Colors.green),
),
);
try {
final pdf = pw.Document();
// Download foto dari URL untuk dimasukkan ke PDF
pw.MemoryImage? fotoImage;
final urlFoto = args['url_foto']?.toString() ?? '';
if (urlFoto.isNotEmpty) {
try {
final response = await http.get(Uri.parse(urlFoto));
if (response.statusCode == 200) {
fotoImage = pw.MemoryImage(response.bodyBytes);
}
} catch (_) {
// Foto gagal didownload, lanjut tanpa foto
}
}
// Buat halaman PDF
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
build: (pw.Context context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// Header
pw.Container(
width: double.infinity,
padding: const pw.EdgeInsets.all(16),
decoration: pw.BoxDecoration(
color: PdfColors.green800,
borderRadius: pw.BorderRadius.circular(8),
),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'Dataset Pengeringan Biji Kopi',
style: pw.TextStyle(
color: PdfColors.white,
fontSize: 18,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 4),
pw.Text(
'Sistem Monitoring Otomatis',
style: const pw.TextStyle(
color: PdfColors.white,
fontSize: 12,
),
),
],
),
),
pw.SizedBox(height: 20),
// Info Waktu
pw.Text(
'Tanggal & Waktu: ${args['date']} - ${args['time']}',
style: pw.TextStyle(
fontSize: 13,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 16),
pw.Divider(),
pw.SizedBox(height: 16),
// Foto
if (fotoImage != null) ...[
pw.Center(
child: pw.Container(
height: 180,
width: double.infinity,
decoration: pw.BoxDecoration(
borderRadius: pw.BorderRadius.circular(8),
border: pw.Border.all(color: PdfColors.grey300),
),
child: pw.ClipRRect(
horizontalRadius: 8,
verticalRadius: 8,
child: pw.Image(
fotoImage,
fit: pw.BoxFit.contain,
height: 180,
width: double.infinity,
),
),
),
),
pw.SizedBox(height: 16),
pw.Divider(),
pw.SizedBox(height: 16),
],
// Data Sensor
pw.Text(
'Data Sensor',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
),
),
pw.SizedBox(height: 10),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300),
columnWidths: {
0: const pw.FlexColumnWidth(1),
1: const pw.FlexColumnWidth(2),
},
children: [
_pdfRow('Suhu', args['temp']),
_pdfRow('Kelembapan', args['humidity']),
_pdfRow('Intensitas Cahaya', args['light']),
_pdfRow('Fan Intake (Kipas 1)', args['intake']),
_pdfRow('Fan Exhaust (Kipas 2)', args['exhaust']),
],
),
pw.SizedBox(height: 24),
pw.Divider(),
pw.SizedBox(height: 8),
// Footer
pw.Text(
'Diekspor dari Aplikasi Monitoring Kopi',
style: const pw.TextStyle(
color: PdfColors.grey,
fontSize: 10,
),
),
],
);
},
),
);
// Tutup loading
if (context.mounted) Navigator.pop(context);
// Tampilkan preview PDF + opsi simpan/share
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdf.save(),
name:
'dataset_kopi_${args['date']?.replaceAll('/', '-')}_${args['time']?.replaceAll(':', '-')}.pdf',
);
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // tutup loading
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal membuat PDF: $e'),
backgroundColor: Colors.red.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
),
);
}
}
}
pw.TableRow _pdfRow(String label, String? value) {
return pw.TableRow(
children: [
pw.Padding(
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
label,
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11),
),
),
pw.Padding(
padding: const pw.EdgeInsets.all(8),
child: pw.Text(value ?? '-', style: const pw.TextStyle(fontSize: 11)),
),
],
);
}
// ============================================================
// BUILD UI
// ============================================================
@override
Widget build(BuildContext context) {
// Receiving arguments from the previous screen
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>? ?? {
'id': '1',
'date': '2025/11/18',
'time': '14:35:00',
'temp': '45.5 °C',
'humidity': '52 %',
'light': '200 Lux',
'location': 'Gudang A',
'intake': 'ON',
'exhaust': 'ON'
};
final args = ModalRoute.of(context)?.settings.arguments
as Map<String, dynamic>? ??
{
'id': '1',
'date': '2025/11/18',
'time': '14:35:00',
'temp': '45.5 °C',
'humidity': '52 %',
'light': '200 Lux',
'intake': 'ON',
'exhaust': 'ON',
'url_foto': '',
};
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text('Detail Data Citra', style: TextStyle(color: Colors.white)),
title: const Text(
'Detail Data Citra',
style: TextStyle(color: Colors.white),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.grey),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.download, color: Colors.green),
)
],
// Icon download dihapus
),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// Image Box
// Foto
Container(
height: 250,
width: double.infinity,
@ -47,20 +257,23 @@ class ImageResultScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
clipBehavior: Clip.hardEdge,
child: args['url_foto'] != null && args['url_foto'].toString().isNotEmpty
child: args['url_foto'] != null &&
args['url_foto'].toString().isNotEmpty
? Image.network(
args['url_foto'],
fit: BoxFit.cover,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const Center(
child: CircularProgressIndicator(color: Colors.green),
child:
CircularProgressIndicator(color: Colors.green),
);
},
errorBuilder: (context, error, stack) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.broken_image, size: 80, color: Colors.grey),
const Icon(Icons.broken_image,
size: 80, color: Colors.grey),
Text('Citra #${args['id']}',
style: const TextStyle(color: Colors.grey)),
],
@ -76,8 +289,8 @@ class ImageResultScreen extends StatelessWidget {
),
),
const SizedBox(height: 20),
// Data Card
// Data Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@ -86,50 +299,60 @@ class ImageResultScreen extends StatelessWidget {
),
child: Column(
children: [
// Tanggal & waktu
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.calendar_today, color: Colors.green, size: 16),
const Icon(Icons.calendar_today,
color: Colors.green, size: 16),
const SizedBox(width: 8),
Text(
'${args['date']} - ${args['time']}',
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
fontSize: 16),
),
],
),
const SizedBox(height: 10),
Text('Lokasi: ${args['location']}', style: const TextStyle(color: Colors.grey, fontSize: 13)),
// Lokasi dihapus
const Divider(color: Colors.grey, height: 30),
_buildDataRow('Suhu', args['temp'], 'Kelembaban', args['humidity']),
const SizedBox(height: 15),
_buildDataRow('Cahaya', args['light'], 'Mode', 'Terjadwal'),
const Divider(color: Colors.grey, height: 30),
_buildDataRow(
'Fan Intake', args['intake'],
'Fan Exhaust', args['exhaust'],
valueColor: Colors.green
'Suhu', args['temp'], 'Kelembaban', args['humidity']),
const SizedBox(height: 15),
_buildDataRow(
'Cahaya', args['light'], 'Mode', 'Terjadwal'),
const Divider(color: Colors.grey, height: 30),
_buildDataRow(
'Kipas 1 (Intake)', args['intake'],
'Kipas 2 (Exhaust)', args['exhaust'],
valueColor: Colors.green,
),
],
),
),
const Spacer(),
// Share Button
// Tombol Export PDF
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton.icon(
onPressed: () {},
onPressed: () => _exportPDF(context, args),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
icon: const Icon(Icons.share, color: Colors.white),
label: const Text('Export Data Lengkap (.CSV)', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
icon: const Icon(Icons.picture_as_pdf, color: Colors.white),
label: const Text(
'Export Data Lengkap (.PDF)',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
),
],
@ -138,7 +361,13 @@ class ImageResultScreen extends StatelessWidget {
);
}
Widget _buildDataRow(String label1, String value1, String label2, String value2, {Color valueColor = Colors.white}) {
Widget _buildDataRow(
String label1,
String value1,
String label2,
String value2, {
Color valueColor = Colors.white,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -146,9 +375,14 @@ class ImageResultScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label1, style: const TextStyle(color: Colors.grey, fontSize: 12)),
Text(label1,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
Text(': $value1', style: TextStyle(color: valueColor, fontWeight: FontWeight.bold, fontSize: 15)),
Text(': $value1',
style: TextStyle(
color: valueColor,
fontWeight: FontWeight.bold,
fontSize: 15)),
],
),
),
@ -156,13 +390,18 @@ class ImageResultScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label2, style: const TextStyle(color: Colors.grey, fontSize: 12)),
Text(label2,
style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
Text(': $value2', style: TextStyle(color: valueColor, fontWeight: FontWeight.bold, fontSize: 15)),
Text(': $value2',
style: TextStyle(
color: valueColor,
fontWeight: FontWeight.bold,
fontSize: 15)),
],
),
),
],
);
}
}
}

View File

@ -9,12 +9,18 @@ class LoginScreen extends StatefulWidget {
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true; // Tambahan: State untuk mata password
bool _isLoading = false;
bool _obscurePassword = true;
Future<void> _handleLogin() async {
if (_emailController.text.trim().isEmpty ||
_passwordController.text.trim().isEmpty) {
_showError('Email dan password tidak boleh kosong!');
return;
}
setState(() => _isLoading = true);
try {
await SupabaseService().signIn(
@ -26,15 +32,62 @@ class _LoginScreenState extends State<LoginScreen> {
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
final errorStr = e.toString().toLowerCase();
String pesanError = 'Login gagal, coba lagi.';
if (errorStr.contains('invalid_credentials') ||
errorStr.contains('invalid login credentials') ||
errorStr.contains('email or password')) {
pesanError = 'Email atau password salah!';
} else if (errorStr.contains('not confirmed')) {
pesanError = 'Email belum dikonfirmasi, cek inbox kamu.';
} else if (errorStr.contains('network') ||
errorStr.contains('socket') ||
errorStr.contains('connection')) {
pesanError = 'Tidak ada koneksi internet.';
} else if (errorStr.contains('too many') ||
errorStr.contains('rate limit')) {
pesanError = 'Terlalu banyak percobaan, tunggu sebentar.';
} else if (errorStr.contains('user not found')) {
pesanError = 'Email atau password salah!';
}
_showError(pesanError);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showError(String pesan) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Text(
pesan,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
backgroundColor: Colors.red.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 3),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -52,7 +105,10 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 30),
const Text(
'Selamat Datang',
style: TextStyle(color: Colors.green, fontSize: 32, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.green,
fontSize: 32,
fontWeight: FontWeight.bold),
),
const Text(
'Sistem Kendali Pengering Kopi',
@ -70,6 +126,7 @@ class _LoginScreenState extends State<LoginScreen> {
children: [
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.email, color: Colors.green),
@ -81,21 +138,20 @@ class _LoginScreenState extends State<LoginScreen> {
const Divider(color: Colors.grey),
TextField(
controller: _passwordController,
obscureText: _obscurePassword, // Menggunakan variabel state
obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock, color: Colors.green),
// Menggunakan IconButton untuk aksi klik
prefixIcon:
const Icon(Icons.lock, color: Colors.green),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Colors.grey,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword),
),
hintText: 'Password',
hintStyle: const TextStyle(color: Colors.grey),
@ -115,17 +171,31 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
? const CircularProgressIndicator(
color: Colors.white)
: const Text(
'Masuk ke Akun',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
],
),
),
const SizedBox(height: 30),
// Lupa Password dan Daftar rapat tanpa jarak besar, warna sama hijau
const SizedBox(height: 10),
TextButton(
onPressed: () =>
Navigator.pushNamed(context, '/forgot_password'),
child: const Text(
'Lupa Password?',
style: TextStyle(color: Colors.green),
),
),
TextButton(
onPressed: () => Navigator.pushNamed(context, '/register'),
child: const Text(

View File

@ -5,7 +5,8 @@ import '../services/mqtt_service.dart';
import '../services/supabase_service.dart';
class MonitoringScreen extends StatefulWidget {
const MonitoringScreen({super.key});
final VoidCallback? onProfileTap;
const MonitoringScreen({super.key, this.onProfileTap});
@override
State<MonitoringScreen> createState() => _MonitoringScreenState();
@ -13,6 +14,9 @@ class MonitoringScreen extends StatefulWidget {
class _MonitoringScreenState extends State<MonitoringScreen> {
bool _isInitialLoading = true;
double _prevSuhu = 0;
double _prevKelembapan = 0;
double _prevIntensitas = 0;
@override
void initState() {
@ -33,6 +37,8 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
Provider.of<MqttService>(context, listen: false).setLimits(
double.parse(data['suhu_max'].toString()),
double.parse(data['rh_max'].toString()),
double.parse(data['suhu_min'].toString()),
double.parse(data['rh_min'].toString()),
);
}
} catch (e) {
@ -42,288 +48,321 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
@override
Widget build(BuildContext context) {
final mqtt = Provider.of<MqttService>(context);
final curTemp = double.tryParse(mqtt.suhu) ?? 0;
final curHum = double.tryParse(mqtt.kelembapan) ?? 0;
final mqtt = Provider.of<MqttService>(context);
final curSuhu = double.tryParse(mqtt.suhu) ?? 0;
final curHum = double.tryParse(mqtt.kelembapan) ?? 0;
final curLux = double.tryParse(mqtt.intensitas) ?? 0;
WidgetsBinding.instance.addPostFrameCallback((_) {
_prevSuhu = curSuhu;
_prevKelembapan = curHum;
_prevIntensitas = curLux;
});
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: _isInitialLoading
? const Center(
child: CircularProgressIndicator(color: Colors.green))
? const Center(child: CircularProgressIndicator(color: Colors.green))
: RefreshIndicator(
onRefresh: _initData,
color: Colors.green,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Monitoring',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold),
child: LayoutBuilder(
builder: (context, constraints) {
// Tinggi tersedia = tinggi layar dikurangi header
const headerHeight = 175.0; // header + timestamp + mode badge + padding
const gaps = 15.0 * 2; // 2 gap antar 3 baris
const padding = 20.0 * 2; // padding atas bawah
final available = constraints.maxHeight - headerHeight - gaps - padding;
final cardH = (available / 3).clamp(100.0, 180.0);
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Monitoring',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold)),
const Text('Sistem Pengeringan Kopi',
style: TextStyle(color: Colors.grey, fontSize: 16)),
const SizedBox(height: 5),
Row(
children: [
Container(
width: 8, height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: mqtt.client?.connectionStatus?.state ==
MqttConnectionState.connected
? Colors.green
: Colors.red,
),
),
const SizedBox(width: 8),
Text(
mqtt.client?.connectionStatus?.state ==
MqttConnectionState.connected
? 'Connected'
: 'Disconnected',
style: TextStyle(
color: mqtt.client?.connectionStatus?.state ==
MqttConnectionState.connected
? Colors.green
: Colors.red,
fontSize: 12,
),
),
],
),
],
),
GestureDetector(
onTap: widget.onProfileTap,
child: const CircleAvatar(
backgroundColor: Color(0xFF1A1A1A),
radius: 25,
child: Icon(Icons.person, color: Colors.green),
),
),
],
),
const SizedBox(height: 6),
// Timestamp
Text('Update: ${mqtt.timestamp}',
style: const TextStyle(color: Colors.grey, fontSize: 11)),
const SizedBox(height: 10),
// Mode Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: mqtt.mode == 'manual'
? Colors.orange.withOpacity(0.2)
: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: mqtt.mode == 'manual' ? Colors.orange : Colors.green,
),
),
const Text(
'Sistem Pengeringan Kopi',
child: Text(
'Mode: ${mqtt.mode.toUpperCase()}',
style: TextStyle(
color: Colors.grey, fontSize: 16),
color: mqtt.mode == 'manual' ? Colors.orange : Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 5),
Row(
),
const SizedBox(height: 15),
// Baris 1: Suhu & Kelembaban
SizedBox(
height: cardH,
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: mqtt.client?.connectionStatus
?.state ==
MqttConnectionState.connected
? Colors.green
: Colors.red,
Expanded(
child: _buildSensorCard(
title: 'Suhu',
prev: _prevSuhu,
cur: curSuhu,
suffix: '°C',
dec: 1,
icon: Icons.thermostat,
color: curSuhu > mqtt.maxSuhu ? Colors.red : Colors.redAccent,
isAlert: curSuhu > mqtt.maxSuhu,
isLow: curSuhu < mqtt.minSuhu,
onTap: () => Navigator.pushNamed(context, '/analytics',
arguments: {'title': 'Grafik Suhu (°C)', 'color': Colors.redAccent, 'value': '${mqtt.suhu}°C'}),
),
),
const SizedBox(width: 8),
Text(
mqtt.client?.connectionStatus?.state ==
MqttConnectionState.connected
? 'Connected'
: 'Disconnected',
style: TextStyle(
color: mqtt.client?.connectionStatus
?.state ==
MqttConnectionState.connected
? Colors.green
: Colors.red,
fontSize: 12,
const SizedBox(width: 15),
Expanded(
child: _buildSensorCard(
title: 'Kelembaban',
prev: _prevKelembapan,
cur: curHum,
suffix: '%',
dec: 1,
icon: Icons.water_drop,
color: curHum > mqtt.maxRh ? Colors.red : Colors.cyan,
isAlert: curHum > mqtt.maxRh,
isLow: curHum < mqtt.minRh,
onTap: () => Navigator.pushNamed(context, '/analytics',
arguments: {'title': 'Grafik Kelembaban (%)', 'color': Colors.cyan, 'value': '${mqtt.kelembapan}%'}),
),
),
],
),
],
),
const CircleAvatar(
backgroundColor: Color(0xFF1A1A1A),
radius: 25,
child:
Icon(Icons.person, color: Colors.green),
),
],
),
const SizedBox(height: 10),
// Timestamp
Text(
'Update: ${mqtt.timestamp}',
style: const TextStyle(
color: Colors.grey, fontSize: 11),
),
const SizedBox(height: 20),
// Mode Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: mqtt.mode == 'manual'
? Colors.orange.withOpacity(0.2)
: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: mqtt.mode == 'manual'
? Colors.orange
: Colors.green,
),
),
child: Text(
'Mode: ${mqtt.mode.toUpperCase()}',
style: TextStyle(
color: mqtt.mode == 'manual'
? Colors.orange
: Colors.green,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 20),
// Grid Sensor + Kontrol
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
crossAxisSpacing: 15,
mainAxisSpacing: 15,
children: [
_buildSensorCard(
'Suhu',
'${mqtt.suhu}°C',
Icons.thermostat,
curTemp > mqtt.maxSuhu
? Colors.red
: Colors.redAccent,
isAlert: curTemp > mqtt.maxSuhu,
onTap: () => Navigator.pushNamed(
context,
'/analytics',
arguments: {
'title': 'Grafik Suhu (°C)',
'color': Colors.redAccent,
'value': '${mqtt.suhu}°C',
},
),
),
_buildSensorCard(
'Kelembaban',
'${mqtt.kelembapan}%',
Icons.water_drop,
curHum > mqtt.maxRh
? Colors.red
: Colors.cyan,
isAlert: curHum > mqtt.maxRh,
onTap: () => Navigator.pushNamed(
context,
'/analytics',
arguments: {
'title': 'Grafik Kelembaban (%)',
'color': Colors.cyan,
'value': '${mqtt.kelembapan}%',
},
),
),
_buildSensorCard(
'Cahaya',
'${mqtt.intensitas} Lux',
Icons.wb_sunny,
Colors.orange,
onTap: () => Navigator.pushNamed(
context,
'/analytics',
arguments: {
'title': 'Grafik Cahaya (Lux)',
'color': Colors.orange,
'value': '${mqtt.intensitas} Lux',
},
),
),
_buildControlCard(
'Kipas 1 (Exhaust)',
mqtt.isKipas1On,
(val) async {
mqtt.perintahKipas("1", val);
await SupabaseService().logFanAction(
val ? 'ON' : 'OFF',
mqtt.isKipas2On ? 'ON' : 'OFF',
curTemp,
curHum,
double.tryParse(mqtt.intensitas) ?? 0,
);
},
mqtt,
),
_buildControlCard(
'Kipas 2 (Intake)',
mqtt.isKipas2On,
(val) async {
mqtt.perintahKipas("2", val);
await SupabaseService().logFanAction(
mqtt.isKipas1On ? 'ON' : 'OFF',
val ? 'ON' : 'OFF',
curTemp,
curHum,
double.tryParse(mqtt.intensitas) ?? 0,
);
},
mqtt,
),
],
),
const SizedBox(height: 15),
// Alert Banner
if (curTemp > mqtt.maxSuhu || curHum > mqtt.maxRh)
Container(
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(15),
border: Border.all(color: Colors.red),
),
child: const Row(
children: [
Icon(Icons.warning_amber_rounded,
color: Colors.red),
SizedBox(width: 10),
Expanded(
child: Text(
'Peringatan: Parameter pengeringan melewati batas aman!',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 12),
// Baris 2: Cahaya full width
SizedBox(
height: cardH,
width: double.infinity,
child: _buildSensorCard(
title: 'Cahaya',
prev: _prevIntensitas,
cur: curLux,
suffix: ' Lux',
dec: 1,
icon: Icons.wb_sunny,
color: Colors.orange,
onTap: () => Navigator.pushNamed(context, '/analytics',
arguments: {'title': 'Grafik Cahaya (Lux)', 'color': Colors.orange, 'value': '${mqtt.intensitas} Lux'}),
),
),
const SizedBox(height: 15),
// Baris 3: Kipas 1 & Kipas 2
SizedBox(
height: cardH,
child: Row(
children: [
Expanded(
child: _buildControlCard(
'Kipas 1\n(Intake)',
mqtt.isKipas1On,
(val) => mqtt.perintahKipas("1", val),
mqtt,
),
),
const SizedBox(width: 15),
Expanded(
child: _buildControlCard(
'Kipas 2\n(Exhaust)',
mqtt.isKipas2On,
(val) => mqtt.perintahKipas("2", val),
mqtt,
),
),
],
),
),
// Alert Banner
if (curSuhu > mqtt.maxSuhu || curHum > mqtt.maxRh)
Container(
margin: const EdgeInsets.only(top: 15),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(15),
border: Border.all(color: Colors.red),
),
child: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.red),
SizedBox(width: 10),
Expanded(
child: Text(
'Peringatan: Parameter pengeringan melewati batas aman!',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
fontSize: 12),
),
),
],
),
),
],
),
if (curSuhu < mqtt.minSuhu || curHum < mqtt.minRh)
Container(
margin: const EdgeInsets.only(top: 15),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.2),
borderRadius: BorderRadius.circular(15),
border: Border.all(color: Colors.blue),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Colors.blue),
SizedBox(width: 10),
Expanded(
child: Text(
'Info: Parameter pengeringan di bawah batas minimum!',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
fontSize: 12),
),
),
],
),
),
],
),
const SizedBox(height: 20),
],
),
),
),
),
);
},
),
),
),
);
}
Widget _buildSensorCard(
String title,
String value,
IconData icon,
Color color, {
Widget _buildSensorCard({
required String title,
required double prev,
required double cur,
required String suffix,
required int dec,
required IconData icon,
required Color color,
bool isAlert = false,
bool isLow = false,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(15),
width: double.infinity,
height: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isAlert
? Colors.red.withOpacity(0.1)
: const Color(0xFF1A1A1A),
color: isAlert ? Colors.red.withOpacity(0.1) : isLow ? Colors.blue.withOpacity(0.1) : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20),
border:
isAlert ? Border.all(color: Colors.red, width: 2) : null,
border: isAlert ? Border.all(color: Colors.red, width: 2) : isLow ? Border.all(color: Colors.blue, width: 2) : null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 40),
const SizedBox(height: 10),
Text(title,
style:
const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 5),
Text(value,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold)),
Icon(icon, color: color, size: 32),
const SizedBox(height: 6),
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 4),
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: prev, end: cur),
duration: const Duration(milliseconds: 800),
curve: Curves.easeOutCubic,
builder: (context, value, _) => FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'${value.toStringAsFixed(dec)}$suffix',
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold),
),
),
),
],
),
),
@ -337,50 +376,57 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
MqttService mqtt,
) {
return Container(
padding: const EdgeInsets.all(15),
width: double.infinity,
height: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.settings_input_component,
color: Colors.green, size: 20),
const SizedBox(width: 8),
Icon(Icons.settings_input_component,
color: isOn ? Colors.green : Colors.grey, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(title,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontSize: 11,
fontWeight: FontWeight.bold)),
),
],
),
const Spacer(),
Text(
'Mode: ${mqtt.mode.toUpperCase()}',
style: const TextStyle(
color: Colors.green,
fontSize: 9,
fontWeight: FontWeight.bold),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isOn ? 'ON' : 'OFF',
Text('Mode: ${mqtt.mode.toUpperCase()}',
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
SizedBox(
height: 30,
child: Switch(
value: isOn,
onChanged:
mqtt.mode == 'manual' ? onChanged : null,
activeColor: Colors.green,
),
color: Colors.green, fontSize: 9, fontWeight: FontWeight.bold)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
isOn ? 'ON' : 'OFF',
style: TextStyle(
color: isOn ? Colors.green : Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold),
),
SizedBox(
height: 28,
child: Switch(
value: isOn,
onChanged: mqtt.mode == 'manual' ? onChanged : null,
activeColor: Colors.green,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
],
),

View File

@ -246,9 +246,9 @@ class _RecordingScreenState extends State<RecordingScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Galeri Citra Dataset',
style: TextStyle(
Text(
'Galeri Citra Dataset (${_gallery.length} foto)',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold),
@ -281,23 +281,23 @@ class _RecordingScreenState extends State<RecordingScreen> {
itemCount: _gallery.length,
itemBuilder: (context, index) {
final item = _gallery[index];
final nomorFoto = index + 1;
final timestamp = item['timestamp'] ?? '';
final tanggal = timestamp.contains(' ') ? timestamp.split(' ')[0] : timestamp;
final jam = timestamp.contains(' ') ? timestamp.split(' ')[1] : '';
final jamSingkat = jam.length >= 5 ? jam.substring(0, 5) : jam;
return GestureDetector(
onTap: () => Navigator.pushNamed(
context,
'/image_result',
arguments: {
'id': item['id'].toString(),
'date': item['timestamp']
?.split(' ')[0] ??
'',
'time': item['timestamp']
?.split(' ')[1] ??
'',
'date': tanggal,
'time': jam,
'temp': '${item['suhu']} °C',
'humidity':
'${item['kelembapan']} %',
'light':
'${item['intensitas']} Lux',
'humidity': '${item['kelembapan']} %',
'light': '${item['intensitas']} Lux',
'location': 'Gudang Pengering',
'intake': item['kipas1'] ?? 'OFF',
'exhaust': item['kipas2'] ?? 'OFF',
@ -307,23 +307,77 @@ class _RecordingScreenState extends State<RecordingScreen> {
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius:
BorderRadius.circular(15),
image: item['url_foto'] != null
? DecorationImage(
image: NetworkImage(
item['url_foto']),
fit: BoxFit.cover,
)
: null,
borderRadius: BorderRadius.circular(15),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Foto 75% tinggi card
Expanded(
flex: 3,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: item['url_foto'] != null
? Image.network(
item['url_foto'],
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(
child: Icon(Icons.broken_image, color: Colors.grey, size: 40),
),
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return const Center(
child: CircularProgressIndicator(color: Colors.green, strokeWidth: 2),
);
},
)
: const Center(
child: Icon(Icons.image, color: Colors.grey, size: 40),
),
),
),
// Caption bawah foto
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: const BoxDecoration(
color: Color(0xFF1A1A1A),
borderRadius: BorderRadius.vertical(bottom: Radius.circular(15)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Badge nomor foto
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.green.withOpacity(0.5)),
),
child: Text(
'#$nomorFoto',
style: const TextStyle(
color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold),
),
),
// Tanggal dan jam
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(tanggal,
style: const TextStyle(color: Colors.grey, fontSize: 8)),
Text(jamSingkat,
style: const TextStyle(
color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
],
),
],
),
),
],
),
child: item['url_foto'] == null
? const Center(
child: Icon(Icons.image,
color: Colors.grey,
size: 50),
)
: null,
),
);
},

View File

@ -9,37 +9,110 @@ class RegisterScreen extends StatefulWidget {
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true; // Tambahan: State untuk mata password
bool _isLoading = false;
bool _obscurePassword = true;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleRegister() async {
final nama = _nameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text.trim();
// Validasi kosong
if (nama.isEmpty || email.isEmpty || password.isEmpty) {
_showSnackBar('Semua field harus diisi!', isError: true);
return;
}
// Validasi format email
if (!email.contains('@') || !email.contains('.')) {
_showSnackBar('Format email tidak valid!', isError: true);
return;
}
// Validasi panjang password
if (password.length < 6) {
_showSnackBar('Password minimal 6 karakter!', isError: true);
return;
}
setState(() => _isLoading = true);
try {
await SupabaseService().signUp(
_emailController.text.trim(),
_passwordController.text.trim(),
_nameController.text.trim(),
);
await SupabaseService().signUp(email, password, nama);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful! Please login.')),
);
Navigator.pop(context);
_showSnackBar('Akun berhasil dibuat! Silakan login.', isError: false);
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) Navigator.pop(context);
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
);
final errorStr = e.toString().toLowerCase();
String pesan = 'Pendaftaran gagal, coba lagi.';
if (errorStr.contains('user_already_exists') ||
errorStr.contains('already registered') ||
errorStr.contains('already been registered')) {
pesan = 'Email sudah terdaftar, silakan login.';
} else if (errorStr.contains('invalid') &&
errorStr.contains('email')) {
pesan = 'Format email tidak valid!';
} else if (errorStr.contains('password') &&
errorStr.contains('weak')) {
pesan = 'Password terlalu lemah, gunakan kombinasi huruf dan angka.';
} else if (errorStr.contains('network') ||
errorStr.contains('socket')) {
pesan = 'Tidak ada koneksi internet.';
}
_showSnackBar(pesan, isError: true);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _showSnackBar(String pesan, {required bool isError}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
isError ? Icons.error_outline : Icons.check_circle_outline,
color: Colors.white,
),
const SizedBox(width: 10),
Expanded(
child: Text(
pesan,
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
backgroundColor:
isError ? Colors.red.shade700 : Colors.green.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 3),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -60,7 +133,10 @@ class _RegisterScreenState extends State<RegisterScreen> {
children: [
const Text(
'Buat Akun Baru',
style: TextStyle(color: Colors.green, fontSize: 32, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.green,
fontSize: 32,
fontWeight: FontWeight.bold),
),
const Text(
'Lengkapi data diri Anda untuk memulai',
@ -72,56 +148,67 @@ class _RegisterScreenState extends State<RegisterScreen> {
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(25),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
border:
Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Column(
children: [
// Nama Lengkap
TextField(
controller: _nameController,
style: const TextStyle(color: Colors.white),
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.person, color: Colors.green),
prefixIcon:
Icon(Icons.person, color: Colors.green),
hintText: 'Nama Lengkap',
hintStyle: TextStyle(color: Colors.grey),
border: InputBorder.none,
),
),
const Divider(color: Colors.grey),
// Email
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
prefixIcon: Icon(Icons.email, color: Colors.green),
prefixIcon:
Icon(Icons.email, color: Colors.green),
hintText: 'Email',
hintStyle: TextStyle(color: Colors.grey),
border: InputBorder.none,
),
),
const Divider(color: Colors.grey),
// Password
TextField(
controller: _passwordController,
obscureText: _obscurePassword, // Menggunakan variabel state
obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock, color: Colors.green),
// Menggunakan IconButton untuk aksi klik
prefixIcon:
const Icon(Icons.lock, color: Colors.green),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off,
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Colors.grey,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword),
),
hintText: 'Password',
hintText: 'Password (min. 6 karakter)',
hintStyle: const TextStyle(color: Colors.grey),
border: InputBorder.none,
),
),
const SizedBox(height: 30),
// Tombol Daftar
SizedBox(
width: double.infinity,
height: 55,
@ -129,15 +216,21 @@ class _RegisterScreenState extends State<RegisterScreen> {
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
disabledBackgroundColor:
Colors.green.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
? const CircularProgressIndicator(
color: Colors.white)
: const Text(
'Daftar Sekarang',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),

View File

@ -1,12 +1,193 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:excel/excel.dart';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import '../services/supabase_service.dart';
import '../services/mqtt_service.dart';
import 'package:mqtt_client/mqtt_client.dart';
class SettingsScreen extends StatelessWidget {
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
String _namaLengkap = '';
bool _isLoadingProfile = true;
bool _isExporting = false;
@override
void initState() {
super.initState();
_loadProfile();
}
Future<void> _loadProfile() async {
try {
final profile = await SupabaseService().getProfile();
if (mounted) {
setState(() {
_namaLengkap = profile?['nama_lengkap'] ?? '';
_isLoadingProfile = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoadingProfile = false);
}
}
// Export foto_dataset ke Excel
Future<void> _exportExcel() async {
setState(() => _isExporting = true);
try {
// Ambil semua data dari foto_dataset
final data = await SupabaseService().getFotoDataset();
if (data.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Belum ada data dataset untuk diexport!'),
backgroundColor: Colors.orange,
),
);
}
return;
}
// Buat file Excel
final excel = Excel.createExcel();
final sheet = excel['Dataset Kopi'];
// Header kolom
final headers = [
'No',
'Timestamp',
'Suhu (°C)',
'Kelembapan (%)',
'Intensitas Cahaya (Lux)',
'Kipas 1 (Exhaust)',
'Kipas 2 (Intake)',
'URL Foto',
];
// Style header
for (int i = 0; i < headers.length; i++) {
final cell = sheet.cell(
CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0));
cell.value = TextCellValue(headers[i]);
cell.cellStyle = CellStyle(
bold: true,
backgroundColorHex: ExcelColor.fromHexString('#16A34A'),
fontColorHex: ExcelColor.fromHexString('#FFFFFF'),
);
}
// Isi data
for (int i = 0; i < data.length; i++) {
final row = data[i];
final rowIndex = i + 1;
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 0, rowIndex: rowIndex))
.value = IntCellValue(i + 1);
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 1, rowIndex: rowIndex))
.value = TextCellValue(row['timestamp']?.toString() ?? '-');
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 2, rowIndex: rowIndex))
.value = DoubleCellValue(
double.tryParse(row['suhu'].toString()) ?? 0);
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 3, rowIndex: rowIndex))
.value = DoubleCellValue(
double.tryParse(row['kelembapan'].toString()) ?? 0);
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 4, rowIndex: rowIndex))
.value = DoubleCellValue(
double.tryParse(row['intensitas'].toString()) ?? 0);
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 5, rowIndex: rowIndex))
.value = TextCellValue(row['kipas1']?.toString() ?? '-');
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 6, rowIndex: rowIndex))
.value = TextCellValue(row['kipas2']?.toString() ?? '-');
sheet
.cell(CellIndex.indexByColumnRow(
columnIndex: 7, rowIndex: rowIndex))
.value = TextCellValue(row['url_foto']?.toString() ?? '-');
}
// Set lebar kolom
sheet.setColumnWidth(0, 5);
sheet.setColumnWidth(1, 22);
sheet.setColumnWidth(2, 12);
sheet.setColumnWidth(3, 16);
sheet.setColumnWidth(4, 24);
sheet.setColumnWidth(5, 18);
sheet.setColumnWidth(6, 18);
sheet.setColumnWidth(7, 50);
// Simpan file
final dir = await getApplicationDocumentsDirectory();
final tanggal = DateTime.now()
.toString()
.substring(0, 10)
.replaceAll('-', '');
final fileName = 'dataset_kopi_$tanggal.xlsx';
final filePath = '${dir.path}/$fileName';
final fileBytes = excel.save();
if (fileBytes == null) throw Exception('Gagal generate file Excel');
final file = File(filePath);
await file.writeAsBytes(fileBytes);
// Share file
await OpenFile.open(filePath);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'✓ Export berhasil — ${data.length} data tersimpan'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal export: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) setState(() => _isExporting = false);
}
}
@override
Widget build(BuildContext context) {
final mqtt = Provider.of<MqttService>(context);
@ -21,7 +202,7 @@ class SettingsScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile Section
// Profile Section
Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
@ -33,80 +214,116 @@ class SettingsScreen extends StatelessWidget {
const CircleAvatar(
radius: 30,
backgroundColor: Colors.green,
child: Icon(Icons.person, color: Colors.white, size: 40),
child: Icon(Icons.person,
color: Colors.white, size: 40),
),
const SizedBox(width: 15),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user?.email?.split('@')[0].toUpperCase() ?? 'Admin Kopi',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
_isLoadingProfile
? const SizedBox(
width: 100,
height: 16,
child: LinearProgressIndicator(
color: Colors.green,
backgroundColor: Colors.grey,
),
)
: Text(
_namaLengkap.isNotEmpty
? _namaLengkap
: 'Admin Kopi',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
user?.email ?? 'admin@coffee.io',
style: const TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(
color: Colors.grey, fontSize: 12),
),
const Text(
'Status: Active User',
style: TextStyle(color: Colors.green, fontSize: 12),
style: TextStyle(
color: Colors.green, fontSize: 12),
),
],
),
],
),
),
const SizedBox(height: 30),
// Sistem & Koneksi
_buildSectionTitle('Sistem & Koneksi'),
_buildSettingsCard([
_buildConnectionStatus(
'Database Supabase',
true, // Always true if they can reach this screen
Icons.storage
),
'Database Supabase', true, Icons.storage),
const Divider(color: Colors.grey, height: 1),
_buildConnectionStatus(
'MQTT Broker (HiveMQ)',
mqtt.client?.connectionStatus?.state == MqttConnectionState.connected,
Icons.cloud_sync
'MQTT Broker (EMQX)',
mqtt.client?.connectionStatus?.state ==
MqttConnectionState.connected,
Icons.cloud_sync,
),
]),
const SizedBox(height: 20),
// Manajemen Data
_buildSectionTitle('Manajemen Data'),
_buildSettingsCard([
_buildSettingItem(Icons.file_download, 'Export Semua Log (.CSV)', () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Menyiapkan file CSV...')));
}),
const Divider(color: Colors.grey, height: 1),
_buildSettingItem(Icons.delete_sweep, 'Bersihkan Cache Aplikasi', () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Cache dibersihkan')));
}),
_isExporting
? const ListTile(
leading: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.green, strokeWidth: 2),
),
title: Text(
'Menyiapkan file Excel...',
style: TextStyle(
color: Colors.grey, fontSize: 14),
),
)
: _buildSettingItem(
Icons.table_chart,
'Export Data Dataset (.xlsx)',
_exportExcel,
),
]),
const SizedBox(height: 20),
// Keamanan
_buildSectionTitle('Keamanan'),
_buildSettingsCard([
_buildSettingItem(Icons.lock_reset, 'Ganti Password Akun', () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Fitur ini akan segera hadir')));
}),
]),
const SizedBox(height: 20),
_buildSectionTitle('Tentang'),
_buildSettingsCard([
_buildSettingItem(Icons.info_outline, 'Versi Aplikasi', null, trailing: 'v1.2.0-Production'),
]),
_buildSettingItem(
Icons.lock_reset, 'Ganti Password Akun', () {
Navigator.pushNamed(
context,
'/change_password',
arguments: {'dari_reset': false},
);
}),
const SizedBox(height: 30),
// Tombol Keluar
SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton.icon(
onPressed: () async {
await SupabaseService().signOut();
Navigator.pushReplacementNamed(context, '/login');
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/login');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent.withOpacity(0.8),
@ -115,7 +332,13 @@ class SettingsScreen extends StatelessWidget {
),
),
icon: const Icon(Icons.logout, color: Colors.white),
label: const Text('Keluar dari Akun', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
label: const Text(
'Keluar dari Akun',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white),
),
),
),
const SizedBox(height: 100),
@ -132,7 +355,10 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 10, left: 5),
child: Text(
title,
style: const TextStyle(color: Colors.grey, fontSize: 13, fontWeight: FontWeight.bold),
style: const TextStyle(
color: Colors.grey,
fontSize: 13,
fontWeight: FontWeight.bold),
),
);
}
@ -147,21 +373,27 @@ class SettingsScreen extends StatelessWidget {
);
}
Widget _buildSettingItem(IconData icon, String title, VoidCallback? onTap, {String? trailing}) {
Widget _buildSettingItem(IconData icon, String title, VoidCallback? onTap,
{String? trailing}) {
return ListTile(
leading: Icon(icon, color: Colors.green, size: 22),
title: Text(title, style: const TextStyle(color: Colors.white, fontSize: 14)),
trailing: trailing != null
? Text(trailing, style: const TextStyle(color: Colors.grey, fontSize: 12))
title: Text(title,
style: const TextStyle(color: Colors.white, fontSize: 14)),
trailing: trailing != null
? Text(trailing,
style: const TextStyle(color: Colors.grey, fontSize: 12))
: const Icon(Icons.chevron_right, color: Colors.grey, size: 18),
onTap: onTap,
);
}
Widget _buildConnectionStatus(String title, bool isConnected, IconData icon) {
Widget _buildConnectionStatus(
String title, bool isConnected, IconData icon) {
return ListTile(
leading: Icon(icon, color: isConnected ? Colors.blue : Colors.red, size: 22),
title: Text(title, style: const TextStyle(color: Colors.white, fontSize: 14)),
leading: Icon(icon,
color: isConnected ? Colors.blue : Colors.red, size: 22),
title: Text(title,
style: const TextStyle(color: Colors.white, fontSize: 14)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -176,10 +408,13 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(width: 8),
Text(
isConnected ? 'Online' : 'Offline',
style: TextStyle(color: isConnected ? Colors.green : Colors.red, fontSize: 12, fontWeight: FontWeight.bold),
style: TextStyle(
color: isConnected ? Colors.green : Colors.red,
fontSize: 12,
fontWeight: FontWeight.bold),
),
],
),
);
}
}
}

View File

@ -29,14 +29,21 @@ class MqttService extends ChangeNotifier {
String get timestamp => _timestamp;
// Getter batas
double _minSuhu = 0.0;
double _minRh = 0.0;
double get maxSuhu => _maxSuhu;
double get maxRh => _maxRh;
double get minSuhu => _minSuhu;
double get minRh => _minRh;
Function(String, bool)? onConnectionResult;
void setLimits(double suhuMax, double rhMax) {
void setLimits(double suhuMax, double rhMax, double suhuMin, double rhMin) {
_maxSuhu = suhuMax;
_maxRh = rhMax;
_minSuhu = suhuMin;
_minRh = rhMin;
notifyListeners();
}
@ -76,12 +83,12 @@ class MqttService extends ChangeNotifier {
if (client?.connectionStatus?.state == MqttConnectionState.connected) {
debugPrint('MQTT: Terhubung!');
onConnectionResult?.call(' Terhubung ke Server Kopi', true);
onConnectionResult?.call(' Terhubung ke Server Kopi', true);
_subscribeToTopics();
notifyListeners();
return true;
} else {
onConnectionResult?.call(' Koneksi Gagal', false);
onConnectionResult?.call(' Koneksi Gagal', false);
notifyListeners();
return false;
}

View File

@ -7,15 +7,18 @@ class SupabaseService {
final supabase = Supabase.instance.client;
// --- Auth ---
Future<AuthResponse> signUp(String email, String password, String fullName) async {
// ============================================================
// AUTH
// ============================================================
Future<AuthResponse> signUp(
String email, String password, String fullName) async {
final response = await supabase.auth.signUp(
email: email,
password: password,
);
if (response.user != null) {
// Insert into profiles table
await supabase.from('profiles').insert({
'id': response.user!.id,
'nama_lengkap': fullName,
@ -38,7 +41,50 @@ class SupabaseService {
User? get currentUser => supabase.auth.currentUser;
// --- Sensor Data (sensor_log) ---
// ============================================================
// PROFILE
// ============================================================
// Ambil profil dari tabel profiles berdasarkan user yang login
Future<Map<String, dynamic>?> getProfile() async {
final user = currentUser;
if (user == null) return null;
final response = await supabase
.from('profiles')
.select()
.eq('id', user.id)
.single();
return response;
}
// Update password akun yang sedang login
Future<void> updatePassword(String passwordBaru) async {
await supabase.auth.updateUser(
UserAttributes(password: passwordBaru),
);
}
// Kirim OTP ke email
Future<void> resetPassword(String email) async {
await supabase.auth.signInWithOtp(
email: email,
shouldCreateUser: false, // jangan buat user baru
);
}
// Verifikasi OTP yang diinput user
Future<void> verifyOTP(String email, String token) async {
await supabase.auth.verifyOTP(
email: email,
token: token,
type: OtpType.email,
);
}
// ============================================================
// SENSOR DATA (sensor_log)
// ============================================================
Future<List<Map<String, dynamic>>> getSensorLogs() async {
return await supabase
.from('sensor_log')
@ -47,56 +93,6 @@ class SupabaseService {
.limit(20);
}
// --- Limits (batas_sensor) ---
Future<Map<String, dynamic>?> getBatasSensor() async {
final data = await supabase.from('batas_sensor').select().limit(1).single();
return data;
}
Future<void> updateBatasSensor(double suhuMin, double suhuMax, double rhMin, double rhMax) async {
await supabase.from('batas_sensor').update({
'suhu_min': suhuMin,
'suhu_max': suhuMax,
'rh_min': rhMin,
'rh_max': rhMax,
'updated_at': DateTime.now().toIso8601String(),
}).eq('id', 1);
}
// --- Interval (interval_setting) ---
Future<int> getIntervalSetting() async {
final data = await supabase.from('interval_setting').select('durasi_menit').limit(1).single();
return data['durasi_menit'] as int;
}
Future<void> updateIntervalSetting(int menit) async {
await supabase.from('interval_setting').update({
'durasi_menit': menit,
'update_at': DateTime.now().toIso8601String(),
}).eq('id', 1);
}
// --- Gallery (foto_dataset) ---
Future<List<Map<String, dynamic>>> getFotoDataset() async {
final List<dynamic> data = await supabase
.from('foto_dataset')
.select()
.order('created_at', ascending: false);
return List<Map<String, dynamic>>.from(data);
}
// --- Log Fan Status ---
Future<void> logFanAction(String fan1, String fan2, double suhu, double hum, double lux) async {
await supabase.from('sensor_log').insert({
'kipas1': fan1,
'kipas2': fan2,
'suhu': suhu,
'kelembapan': hum,
'intensitas': lux,
'timestamp': DateTime.now().toString().substring(0, 19),
});
}
Future<Map<String, dynamic>?> getLatestLog() async {
final List<dynamic> data = await supabase
.from('sensor_log')
@ -105,4 +101,72 @@ class SupabaseService {
.limit(1);
return data.isNotEmpty ? data.first as Map<String, dynamic> : null;
}
}
Future<void> logFanAction(
String fan1, String fan2, double suhu, double hum, double lux) async {
await supabase.from('sensor_log').insert({
'kipas1': fan1,
'kipas2': fan2,
'suhu': suhu,
'kelembapan': hum,
'intensitas': lux,
'timestamp': DateTime.now().toString().substring(0, 19),
});
}
// ============================================================
// BATAS SENSOR (batas_sensor)
// ============================================================
Future<Map<String, dynamic>?> getBatasSensor() async {
final data = await supabase
.from('batas_sensor')
.select()
.limit(1)
.single();
return data;
}
Future<void> updateBatasSensor(
double suhuMin, double suhuMax, double rhMin, double rhMax) async {
await supabase.from('batas_sensor').update({
'suhu_min': suhuMin,
'suhu_max': suhuMax,
'rh_min': rhMin,
'rh_max': rhMax,
'updated_at': DateTime.now().toIso8601String(),
}).eq('id', 1);
}
// ============================================================
// INTERVAL SETTING (interval_setting)
// ============================================================
Future<int> getIntervalSetting() async {
final data = await supabase
.from('interval_setting')
.select('durasi_menit')
.limit(1)
.single();
return data['durasi_menit'] as int;
}
Future<void> updateIntervalSetting(int menit) async {
await supabase.from('interval_setting').update({
'durasi_menit': menit,
'update_at': DateTime.now().toIso8601String(),
}).eq('id', 1);
}
// ============================================================
// GALLERY (foto_dataset)
// ============================================================
Future<List<Map<String, dynamic>>> getFotoDataset() async {
final List<dynamic> data = await supabase
.from('foto_dataset')
.select()
.order('created_at', ascending: true);
return List<Map<String, dynamic>>.from(data);
}
}

View File

@ -7,12 +7,20 @@
#include "generated_plugin_registrant.h"
#include <gtk/gtk_plugin.h>
#include <open_file_linux/open_file_linux_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -4,6 +4,8 @@
list(APPEND FLUTTER_PLUGIN_LIST
gtk
open_file_linux
printing
url_launcher_linux
)

View File

@ -6,11 +6,15 @@ import FlutterMacOS
import Foundation
import app_links
import open_file_mac
import printing
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@ -49,6 +57,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
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"
boolean_selector:
dependency: transitive
description:
@ -97,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -137,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
excel:
dependency: "direct main"
description:
name: excel
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
fake_async:
dependency: transitive
description:
@ -169,6 +209,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
@ -241,7 +289,7 @@ packages:
source: hosted
version: "1.0.3"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -256,6 +304,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl:
dependency: "direct main"
description:
@ -356,10 +412,10 @@ packages:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "1.0.6"
mqtt_client:
dependency: "direct main"
description:
@ -392,6 +448,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
open_file:
dependency: "direct main"
description:
name: open_file
sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b
url: "https://pub.dev"
source: hosted
version: "3.5.11"
open_file_android:
dependency: transitive
description:
name: open_file_android
sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
open_file_ios:
dependency: transitive
description:
name: open_file_ios
sha256: a5acd07ba1f304f807a97acbcc489457e1ad0aadff43c467987dd9eef814098f
url: "https://pub.dev"
source: hosted
version: "1.0.4"
open_file_linux:
dependency: transitive
description:
name: open_file_linux
sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550
url: "https://pub.dev"
source: hosted
version: "0.0.5"
open_file_mac:
dependency: transitive
description:
name: open_file_mac
sha256: cd293f6750de6438ab2390513c99128ade8c974825d4d8128886d1cda8c64d01
url: "https://pub.dev"
source: hosted
version: "1.0.4"
open_file_platform_interface:
dependency: transitive
description:
name: open_file_platform_interface
sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
open_file_web:
dependency: transitive
description:
name: open_file_web
sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f
url: "https://pub.dev"
source: hosted
version: "0.0.4"
open_file_windows:
dependency: transitive
description:
name: open_file_windows
sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875
url: "https://pub.dev"
source: hosted
version: "0.0.3"
package_config:
dependency: transitive
description:
@ -408,8 +528,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -456,6 +584,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:
@ -488,6 +640,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.7.0"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
provider:
dependency: "direct main"
description:
@ -504,6 +664,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"
realtime_client:
dependency: transitive
description:
@ -536,6 +704,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
share_plus_platform_interface:
dependency: "direct overridden"
description:
name: share_plus_platform_interface
sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
url: "https://pub.dev"
source: hosted
version: "3.4.0"
shared_preferences:
dependency: "direct main"
description:
@ -741,6 +917,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
@ -797,6 +981,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

@ -37,9 +37,16 @@ dependencies:
fl_chart: ^1.2.0
intl: ^0.20.2
shared_preferences: ^2.5.5
pdf: ^3.10.8
printing: ^5.12.0
http: ^1.2.0
excel: ^4.0.3
open_file: ^3.3.2
path_provider: ^2.1.2
dependency_overrides:
win32: ^6.1.0
share_plus_platform_interface: ^3.4.0
dev_dependencies:
flutter_test:

View File

@ -7,11 +7,14 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <printing/printing_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
PrintingPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintingPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
app_links
printing
url_launcher_windows
)