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 { android {
namespace "com.example.coffee_iot_flutter" namespace "com.example.coffee_iot_flutter"
compileSdk 35 compileSdk 36
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {

View File

@ -14,6 +14,15 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> 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 <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues

View File

@ -1,22 +1,47 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart';
import 'services/mqtt_service.dart'; import 'services/mqtt_service.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/register_screen.dart'; import 'screens/register_screen.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/analytics_screen.dart'; import 'screens/analytics_screen.dart';
import 'screens/image_result_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 { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize Supabase with the provided URL and Publishable Key
await Supabase.initialize( await Supabase.initialize(
url: 'https://ddmhzzegejbsshihzext.supabase.co', url: 'https://ddmhzzegejbsshihzext.supabase.co',
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRkbWh6emVnZWpic3NoaWh6ZXh0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzcyMjIyNzAsImV4cCI6MjA5Mjc5ODI3MH0._r-viIQLMXuNNdg4tcn7sJTBO9QqSpSlF7hk43hu2Ks', 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( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
@ -33,6 +58,7 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
navigatorKey: navigatorKey,
title: 'Coffee IoT', title: 'Coffee IoT',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
@ -40,14 +66,16 @@ class MyApp extends StatelessWidget {
primarySwatch: Colors.green, primarySwatch: Colors.green,
scaffoldBackgroundColor: Colors.black, scaffoldBackgroundColor: Colors.black,
), ),
initialRoute: '/login', // Back to Login to test Supabase initialRoute: '/login',
routes: { routes: {
'/login': (context) => const LoginScreen(), '/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(), '/register': (context) => const RegisterScreen(),
'/home': (context) => const HomeScreen(), '/home': (context) => const HomeScreen(),
'/analytics': (context) => const AnalyticsScreen(), '/analytics': (context) => const AnalyticsScreen(),
'/image_result': (context) => const ImageResultScreen(), '/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 { Future<void> _fetchLogs() async {
try { try {
final data = await SupabaseService().getSensorLogs(); final data = await SupabaseService().getFotoDataset();
setState(() { setState(() {
_logs = data; _logs = data;
_isLoading = false; _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> { 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 _tempMinController = TextEditingController();
final TextEditingController _tempMaxController = TextEditingController(); final TextEditingController _tempMaxController = TextEditingController();
final TextEditingController _humMinController = 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 = [ List<Widget> get _screens => [
const MonitoringScreen(), MonitoringScreen(onProfileTap: () => setState(() => _currentIndex = 3)),
const ControlSettingsScreen(), const ControlSettingsScreen(),
const RecordingScreen(), const RecordingScreen(),
const SettingsScreen(), const SettingsScreen(),
]; ];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -58,7 +58,7 @@ class _HomeScreenState extends State<HomeScreen> {
showUnselectedLabels: false, showUnselectedLabels: false,
items: const [ items: const [
BottomNavigationBarItem(icon: Icon(Icons.grid_view), label: 'Monitoring'), 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.access_time), label: 'Recording'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'), BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
], ],

View File

@ -1,44 +1,254 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; 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 { class ImageResultScreen extends StatelessWidget {
const ImageResultScreen({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Receiving arguments from the previous screen final args = ModalRoute.of(context)?.settings.arguments
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>? ?? { as Map<String, dynamic>? ??
'id': '1', {
'date': '2025/11/18', 'id': '1',
'time': '14:35:00', 'date': '2025/11/18',
'temp': '45.5 °C', 'time': '14:35:00',
'humidity': '52 %', 'temp': '45.5 °C',
'light': '200 Lux', 'humidity': '52 %',
'location': 'Gudang A', 'light': '200 Lux',
'intake': 'ON', 'intake': 'ON',
'exhaust': 'ON' 'exhaust': 'ON',
}; 'url_foto': '',
};
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.black, 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( leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.grey), icon: const Icon(Icons.arrow_back, color: Colors.grey),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
actions: [ // Icon download dihapus
IconButton(
onPressed: () {},
icon: const Icon(Icons.download, color: Colors.green),
)
],
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.all(20.0),
child: Column( child: Column(
children: [ children: [
// Image Box // Foto
Container( Container(
height: 250, height: 250,
width: double.infinity, width: double.infinity,
@ -47,20 +257,23 @@ class ImageResultScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
clipBehavior: Clip.hardEdge, 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( ? Image.network(
args['url_foto'], args['url_foto'],
fit: BoxFit.cover, fit: BoxFit.cover,
loadingBuilder: (context, child, progress) { loadingBuilder: (context, child, progress) {
if (progress == null) return child; if (progress == null) return child;
return const Center( return const Center(
child: CircularProgressIndicator(color: Colors.green), child:
CircularProgressIndicator(color: Colors.green),
); );
}, },
errorBuilder: (context, error, stack) => Column( errorBuilder: (context, error, stack) => Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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']}', Text('Citra #${args['id']}',
style: const TextStyle(color: Colors.grey)), style: const TextStyle(color: Colors.grey)),
], ],
@ -76,8 +289,8 @@ class ImageResultScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Data Card // Data Card
Container( Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -86,50 +299,60 @@ class ImageResultScreen extends StatelessWidget {
), ),
child: Column( child: Column(
children: [ children: [
// Tanggal & waktu
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(width: 8),
Text( Text(
'${args['date']} - ${args['time']}', '${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), // Lokasi dihapus
Text('Lokasi: ${args['location']}', style: const TextStyle(color: Colors.grey, fontSize: 13)),
const Divider(color: Colors.grey, height: 30), 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( _buildDataRow(
'Fan Intake', args['intake'], 'Suhu', args['temp'], 'Kelembaban', args['humidity']),
'Fan Exhaust', args['exhaust'], const SizedBox(height: 15),
valueColor: Colors.green _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(), const Spacer(),
// Share Button // Tombol Export PDF
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 55, height: 55,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () {}, onPressed: () => _exportPDF(context, args),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.green, backgroundColor: Colors.green,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
), ),
icon: const Icon(Icons.share, color: Colors.white), icon: const Icon(Icons.picture_as_pdf, color: Colors.white),
label: const Text('Export Data Lengkap (.CSV)', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), 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( return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -146,9 +375,14 @@ class ImageResultScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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), 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> { class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
bool _obscurePassword = true; // Tambahan: State untuk mata password bool _obscurePassword = true;
Future<void> _handleLogin() async { Future<void> _handleLogin() async {
if (_emailController.text.trim().isEmpty ||
_passwordController.text.trim().isEmpty) {
_showError('Email dan password tidak boleh kosong!');
return;
}
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await SupabaseService().signIn( await SupabaseService().signIn(
@ -26,15 +32,62 @@ class _LoginScreenState extends State<LoginScreen> {
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( final errorStr = e.toString().toLowerCase();
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), 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 { } finally {
if (mounted) setState(() => _isLoading = false); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -52,7 +105,10 @@ class _LoginScreenState extends State<LoginScreen> {
const SizedBox(height: 30), const SizedBox(height: 30),
const Text( const Text(
'Selamat Datang', 'Selamat Datang',
style: TextStyle(color: Colors.green, fontSize: 32, fontWeight: FontWeight.bold), style: TextStyle(
color: Colors.green,
fontSize: 32,
fontWeight: FontWeight.bold),
), ),
const Text( const Text(
'Sistem Kendali Pengering Kopi', 'Sistem Kendali Pengering Kopi',
@ -70,6 +126,7 @@ class _LoginScreenState extends State<LoginScreen> {
children: [ children: [
TextField( TextField(
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: const InputDecoration( decoration: const InputDecoration(
prefixIcon: Icon(Icons.email, color: Colors.green), prefixIcon: Icon(Icons.email, color: Colors.green),
@ -81,21 +138,20 @@ class _LoginScreenState extends State<LoginScreen> {
const Divider(color: Colors.grey), const Divider(color: Colors.grey),
TextField( TextField(
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, // Menggunakan variabel state obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock, color: Colors.green), prefixIcon:
// Menggunakan IconButton untuk aksi klik const Icon(Icons.lock, color: Colors.green),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off, _obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Colors.grey, color: Colors.grey,
), ),
onPressed: () { onPressed: () => setState(
setState(() { () => _obscurePassword = !_obscurePassword),
_obscurePassword = !_obscurePassword;
});
},
), ),
hintText: 'Password', hintText: 'Password',
hintStyle: const TextStyle(color: Colors.grey), hintStyle: const TextStyle(color: Colors.grey),
@ -115,17 +171,31 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
child: _isLoading child: _isLoading
? const CircularProgressIndicator(color: Colors.white) ? const CircularProgressIndicator(
color: Colors.white)
: const Text( : const Text(
'Masuk ke Akun', '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( TextButton(
onPressed: () => Navigator.pushNamed(context, '/register'), onPressed: () => Navigator.pushNamed(context, '/register'),
child: const Text( child: const Text(

View File

@ -5,7 +5,8 @@ import '../services/mqtt_service.dart';
import '../services/supabase_service.dart'; import '../services/supabase_service.dart';
class MonitoringScreen extends StatefulWidget { class MonitoringScreen extends StatefulWidget {
const MonitoringScreen({super.key}); final VoidCallback? onProfileTap;
const MonitoringScreen({super.key, this.onProfileTap});
@override @override
State<MonitoringScreen> createState() => _MonitoringScreenState(); State<MonitoringScreen> createState() => _MonitoringScreenState();
@ -13,6 +14,9 @@ class MonitoringScreen extends StatefulWidget {
class _MonitoringScreenState extends State<MonitoringScreen> { class _MonitoringScreenState extends State<MonitoringScreen> {
bool _isInitialLoading = true; bool _isInitialLoading = true;
double _prevSuhu = 0;
double _prevKelembapan = 0;
double _prevIntensitas = 0;
@override @override
void initState() { void initState() {
@ -33,6 +37,8 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
Provider.of<MqttService>(context, listen: false).setLimits( Provider.of<MqttService>(context, listen: false).setLimits(
double.parse(data['suhu_max'].toString()), double.parse(data['suhu_max'].toString()),
double.parse(data['rh_max'].toString()), double.parse(data['rh_max'].toString()),
double.parse(data['suhu_min'].toString()),
double.parse(data['rh_min'].toString()),
); );
} }
} catch (e) { } catch (e) {
@ -42,288 +48,321 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mqtt = Provider.of<MqttService>(context); final mqtt = Provider.of<MqttService>(context);
final curTemp = double.tryParse(mqtt.suhu) ?? 0; final curSuhu = double.tryParse(mqtt.suhu) ?? 0;
final curHum = double.tryParse(mqtt.kelembapan) ?? 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( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: SafeArea( body: SafeArea(
child: _isInitialLoading child: _isInitialLoading
? const Center( ? const Center(child: CircularProgressIndicator(color: Colors.green))
child: CircularProgressIndicator(color: Colors.green))
: RefreshIndicator( : RefreshIndicator(
onRefresh: _initData, onRefresh: _initData,
color: Colors.green, color: Colors.green,
child: SingleChildScrollView( child: LayoutBuilder(
physics: const AlwaysScrollableScrollPhysics(), builder: (context, constraints) {
child: Padding( // Tinggi tersedia = tinggi layar dikurangi header
padding: const EdgeInsets.all(20.0), const headerHeight = 175.0; // header + timestamp + mode badge + padding
child: Column( const gaps = 15.0 * 2; // 2 gap antar 3 baris
crossAxisAlignment: CrossAxisAlignment.start, const padding = 20.0 * 2; // padding atas bawah
children: [ final available = constraints.maxHeight - headerHeight - gaps - padding;
// Header final cardH = (available / 3).clamp(100.0, 180.0);
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, return SingleChildScrollView(
children: [ physics: const AlwaysScrollableScrollPhysics(),
Column( child: ConstrainedBox(
crossAxisAlignment: CrossAxisAlignment.start, constraints: BoxConstraints(minHeight: constraints.maxHeight),
children: [ child: Padding(
const Text( padding: const EdgeInsets.all(20),
'Monitoring', child: Column(
style: TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
color: Colors.white, children: [
fontSize: 32, // Header
fontWeight: FontWeight.bold), 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( child: Text(
'Sistem Pengeringan Kopi', 'Mode: ${mqtt.mode.toUpperCase()}',
style: TextStyle( 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: [ children: [
Container( Expanded(
width: 8, child: _buildSensorCard(
height: 8, title: 'Suhu',
decoration: BoxDecoration( prev: _prevSuhu,
shape: BoxShape.circle, cur: curSuhu,
color: mqtt.client?.connectionStatus suffix: '°C',
?.state == dec: 1,
MqttConnectionState.connected icon: Icons.thermostat,
? Colors.green color: curSuhu > mqtt.maxSuhu ? Colors.red : Colors.redAccent,
: Colors.red, 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), const SizedBox(width: 15),
Text( Expanded(
mqtt.client?.connectionStatus?.state == child: _buildSensorCard(
MqttConnectionState.connected title: 'Kelembaban',
? 'Connected' prev: _prevKelembapan,
: 'Disconnected', cur: curHum,
style: TextStyle( suffix: '%',
color: mqtt.client?.connectionStatus dec: 1,
?.state == icon: Icons.water_drop,
MqttConnectionState.connected color: curHum > mqtt.maxRh ? Colors.red : Colors.cyan,
? Colors.green isAlert: curHum > mqtt.maxRh,
: Colors.red, isLow: curHum < mqtt.minRh,
fontSize: 12, 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',
},
), ),
), const SizedBox(height: 15),
_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,
),
],
),
// Alert Banner // Baris 2: Cahaya full width
if (curTemp > mqtt.maxSuhu || curHum > mqtt.maxRh) SizedBox(
Container( height: cardH,
margin: const EdgeInsets.only(top: 20), width: double.infinity,
padding: const EdgeInsets.all(15), child: _buildSensorCard(
decoration: BoxDecoration( title: 'Cahaya',
color: Colors.red.withOpacity(0.2), prev: _prevIntensitas,
borderRadius: BorderRadius.circular(15), cur: curLux,
border: Border.all(color: Colors.red), suffix: ' Lux',
), dec: 1,
child: const Row( icon: Icons.wb_sunny,
children: [ color: Colors.orange,
Icon(Icons.warning_amber_rounded, onTap: () => Navigator.pushNamed(context, '/analytics',
color: Colors.red), arguments: {'title': 'Grafik Cahaya (Lux)', 'color': Colors.orange, 'value': '${mqtt.intensitas} Lux'}),
SizedBox(width: 10), ),
Expanded( ),
child: Text( const SizedBox(height: 15),
'Peringatan: Parameter pengeringan melewati batas aman!',
style: TextStyle( // Baris 3: Kipas 1 & Kipas 2
color: Colors.red, SizedBox(
fontWeight: FontWeight.bold, height: cardH,
fontSize: 12), 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( Widget _buildSensorCard({
String title, required String title,
String value, required double prev,
IconData icon, required double cur,
Color color, { required String suffix,
required int dec,
required IconData icon,
required Color color,
bool isAlert = false, bool isAlert = false,
bool isLow = false,
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.all(15), width: double.infinity,
height: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isAlert color: isAlert ? Colors.red.withOpacity(0.1) : isLow ? Colors.blue.withOpacity(0.1) : const Color(0xFF1A1A1A),
? Colors.red.withOpacity(0.1)
: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: border: isAlert ? Border.all(color: Colors.red, width: 2) : isLow ? Border.all(color: Colors.blue, width: 2) : null,
isAlert ? Border.all(color: Colors.red, width: 2) : null,
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, color: color, size: 40), Icon(icon, color: color, size: 32),
const SizedBox(height: 10), const SizedBox(height: 6),
Text(title, Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
style: const SizedBox(height: 4),
const TextStyle(color: Colors.grey, fontSize: 14)), TweenAnimationBuilder<double>(
const SizedBox(height: 5), tween: Tween<double>(begin: prev, end: cur),
Text(value, duration: const Duration(milliseconds: 800),
style: const TextStyle( curve: Curves.easeOutCubic,
color: Colors.white, builder: (context, value, _) => FittedBox(
fontSize: 22, fit: BoxFit.scaleDown,
fontWeight: FontWeight.bold)), 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, MqttService mqtt,
) { ) {
return Container( return Container(
padding: const EdgeInsets.all(15), width: double.infinity,
height: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.settings_input_component, Icon(Icons.settings_input_component,
color: Colors.green, size: 20), color: isOn ? Colors.green : Colors.grey, size: 16),
const SizedBox(width: 8), const SizedBox(width: 6),
Expanded( Expanded(
child: Text(title, child: Text(title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 11,
fontWeight: FontWeight.bold)), fontWeight: FontWeight.bold)),
), ),
], ],
), ),
const Spacer(), Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
'Mode: ${mqtt.mode.toUpperCase()}',
style: const TextStyle(
color: Colors.green,
fontSize: 9,
fontWeight: FontWeight.bold),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(isOn ? 'ON' : 'OFF', Text('Mode: ${mqtt.mode.toUpperCase()}',
style: const TextStyle( style: const TextStyle(
color: Colors.grey, fontSize: 12)), color: Colors.green, fontSize: 9, fontWeight: FontWeight.bold)),
SizedBox( Row(
height: 30, mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Switch( children: [
value: isOn, Text(
onChanged: isOn ? 'ON' : 'OFF',
mqtt.mode == 'manual' ? onChanged : null, style: TextStyle(
activeColor: Colors.green, 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( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( Text(
'Galeri Citra Dataset', 'Galeri Citra Dataset (${_gallery.length} foto)',
style: TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold), fontWeight: FontWeight.bold),
@ -281,23 +281,23 @@ class _RecordingScreenState extends State<RecordingScreen> {
itemCount: _gallery.length, itemCount: _gallery.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _gallery[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( return GestureDetector(
onTap: () => Navigator.pushNamed( onTap: () => Navigator.pushNamed(
context, context,
'/image_result', '/image_result',
arguments: { arguments: {
'id': item['id'].toString(), 'id': item['id'].toString(),
'date': item['timestamp'] 'date': tanggal,
?.split(' ')[0] ?? 'time': jam,
'',
'time': item['timestamp']
?.split(' ')[1] ??
'',
'temp': '${item['suhu']} °C', 'temp': '${item['suhu']} °C',
'humidity': 'humidity': '${item['kelembapan']} %',
'${item['kelembapan']} %', 'light': '${item['intensitas']} Lux',
'light':
'${item['intensitas']} Lux',
'location': 'Gudang Pengering', 'location': 'Gudang Pengering',
'intake': item['kipas1'] ?? 'OFF', 'intake': item['kipas1'] ?? 'OFF',
'exhaust': item['kipas2'] ?? 'OFF', 'exhaust': item['kipas2'] ?? 'OFF',
@ -307,23 +307,77 @@ class _RecordingScreenState extends State<RecordingScreen> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
borderRadius: borderRadius: BorderRadius.circular(15),
BorderRadius.circular(15), ),
image: item['url_foto'] != null child: Column(
? DecorationImage( crossAxisAlignment: CrossAxisAlignment.stretch,
image: NetworkImage( children: [
item['url_foto']), // Foto 75% tinggi card
fit: BoxFit.cover, Expanded(
) flex: 3,
: null, 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> { class _RegisterScreenState extends State<RegisterScreen> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _emailController = TextEditingController(); final _emailController = TextEditingController();
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
bool _isLoading = false; bool _isLoading = false;
bool _obscurePassword = true; // Tambahan: State untuk mata password bool _obscurePassword = true;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleRegister() async { 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); setState(() => _isLoading = true);
try { try {
await SupabaseService().signUp( await SupabaseService().signUp(email, password, nama);
_emailController.text.trim(),
_passwordController.text.trim(),
_nameController.text.trim(),
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( _showSnackBar('Akun berhasil dibuat! Silakan login.', isError: false);
const SnackBar(content: Text('Registration successful! Please login.')), Future.delayed(const Duration(milliseconds: 1500), () {
); if (mounted) Navigator.pop(context);
Navigator.pop(context); });
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( final errorStr = e.toString().toLowerCase();
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), 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 { } finally {
if (mounted) setState(() => _isLoading = false); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -60,7 +133,10 @@ class _RegisterScreenState extends State<RegisterScreen> {
children: [ children: [
const Text( const Text(
'Buat Akun Baru', '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( const Text(
'Lengkapi data diri Anda untuk memulai', 'Lengkapi data diri Anda untuk memulai',
@ -72,56 +148,67 @@ class _RegisterScreenState extends State<RegisterScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1A1A), color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
border: Border.all(color: Colors.grey.withOpacity(0.3)), border:
Border.all(color: Colors.grey.withOpacity(0.3)),
), ),
child: Column( child: Column(
children: [ children: [
// Nama Lengkap
TextField( TextField(
controller: _nameController, controller: _nameController,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration( decoration: const InputDecoration(
prefixIcon: Icon(Icons.person, color: Colors.green), prefixIcon:
Icon(Icons.person, color: Colors.green),
hintText: 'Nama Lengkap', hintText: 'Nama Lengkap',
hintStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey),
border: InputBorder.none, border: InputBorder.none,
), ),
), ),
const Divider(color: Colors.grey), const Divider(color: Colors.grey),
// Email
TextField( TextField(
controller: _emailController, controller: _emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: const InputDecoration( decoration: const InputDecoration(
prefixIcon: Icon(Icons.email, color: Colors.green), prefixIcon:
Icon(Icons.email, color: Colors.green),
hintText: 'Email', hintText: 'Email',
hintStyle: TextStyle(color: Colors.grey), hintStyle: TextStyle(color: Colors.grey),
border: InputBorder.none, border: InputBorder.none,
), ),
), ),
const Divider(color: Colors.grey), const Divider(color: Colors.grey),
// Password
TextField( TextField(
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, // Menggunakan variabel state obscureText: _obscurePassword,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock, color: Colors.green), prefixIcon:
// Menggunakan IconButton untuk aksi klik const Icon(Icons.lock, color: Colors.green),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon( icon: Icon(
_obscurePassword ? Icons.visibility : Icons.visibility_off, _obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Colors.grey, color: Colors.grey,
), ),
onPressed: () { onPressed: () => setState(
setState(() { () => _obscurePassword = !_obscurePassword),
_obscurePassword = !_obscurePassword;
});
},
), ),
hintText: 'Password', hintText: 'Password (min. 6 karakter)',
hintStyle: const TextStyle(color: Colors.grey), hintStyle: const TextStyle(color: Colors.grey),
border: InputBorder.none, border: InputBorder.none,
), ),
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
// Tombol Daftar
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 55, height: 55,
@ -129,15 +216,21 @@ class _RegisterScreenState extends State<RegisterScreen> {
onPressed: _isLoading ? null : _handleRegister, onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.green, backgroundColor: Colors.green,
disabledBackgroundColor:
Colors.green.withOpacity(0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
), ),
child: _isLoading child: _isLoading
? const CircularProgressIndicator(color: Colors.white) ? const CircularProgressIndicator(
color: Colors.white)
: const Text( : const Text(
'Daftar Sekarang', '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:flutter/material.dart';
import 'package:provider/provider.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/supabase_service.dart';
import '../services/mqtt_service.dart'; import '../services/mqtt_service.dart';
import 'package:mqtt_client/mqtt_client.dart'; import 'package:mqtt_client/mqtt_client.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mqtt = Provider.of<MqttService>(context); final mqtt = Provider.of<MqttService>(context);
@ -21,7 +202,7 @@ class SettingsScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Profile Section // Profile Section
Container( Container(
padding: const EdgeInsets.all(15), padding: const EdgeInsets.all(15),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -33,80 +214,116 @@ class SettingsScreen extends StatelessWidget {
const CircleAvatar( const CircleAvatar(
radius: 30, radius: 30,
backgroundColor: Colors.green, 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), const SizedBox(width: 15),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( _isLoadingProfile
user?.email?.split('@')[0].toUpperCase() ?? 'Admin Kopi', ? const SizedBox(
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), 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( Text(
user?.email ?? 'admin@coffee.io', user?.email ?? 'admin@coffee.io',
style: const TextStyle(color: Colors.grey, fontSize: 12), style: const TextStyle(
color: Colors.grey, fontSize: 12),
), ),
const Text( const Text(
'Status: Active User', 'Status: Active User',
style: TextStyle(color: Colors.green, fontSize: 12), style: TextStyle(
color: Colors.green, fontSize: 12),
), ),
], ],
), ),
], ],
), ),
), ),
const SizedBox(height: 30), const SizedBox(height: 30),
// Sistem & Koneksi
_buildSectionTitle('Sistem & Koneksi'), _buildSectionTitle('Sistem & Koneksi'),
_buildSettingsCard([ _buildSettingsCard([
_buildConnectionStatus( _buildConnectionStatus(
'Database Supabase', 'Database Supabase', true, Icons.storage),
true, // Always true if they can reach this screen
Icons.storage
),
const Divider(color: Colors.grey, height: 1), const Divider(color: Colors.grey, height: 1),
_buildConnectionStatus( _buildConnectionStatus(
'MQTT Broker (HiveMQ)', 'MQTT Broker (EMQX)',
mqtt.client?.connectionStatus?.state == MqttConnectionState.connected, mqtt.client?.connectionStatus?.state ==
Icons.cloud_sync MqttConnectionState.connected,
Icons.cloud_sync,
), ),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 20),
// Manajemen Data
_buildSectionTitle('Manajemen Data'), _buildSectionTitle('Manajemen Data'),
_buildSettingsCard([ _buildSettingsCard([
_buildSettingItem(Icons.file_download, 'Export Semua Log (.CSV)', () { _isExporting
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Menyiapkan file CSV...'))); ? const ListTile(
}), leading: SizedBox(
const Divider(color: Colors.grey, height: 1), width: 22,
_buildSettingItem(Icons.delete_sweep, 'Bersihkan Cache Aplikasi', () { height: 22,
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Cache dibersihkan'))); 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), const SizedBox(height: 20),
// Keamanan
_buildSectionTitle('Keamanan'), _buildSectionTitle('Keamanan'),
_buildSettingsCard([ _buildSettingItem(
_buildSettingItem(Icons.lock_reset, 'Ganti Password Akun', () { Icons.lock_reset, 'Ganti Password Akun', () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Fitur ini akan segera hadir'))); Navigator.pushNamed(
}), context,
]), '/change_password',
arguments: {'dari_reset': false},
const SizedBox(height: 20), );
_buildSectionTitle('Tentang'), }),
_buildSettingsCard([
_buildSettingItem(Icons.info_outline, 'Versi Aplikasi', null, trailing: 'v1.2.0-Production'),
]),
const SizedBox(height: 30), const SizedBox(height: 30),
// Tombol Keluar
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 55, height: 55,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () async { onPressed: () async {
await SupabaseService().signOut(); await SupabaseService().signOut();
Navigator.pushReplacementNamed(context, '/login'); if (context.mounted) {
Navigator.pushReplacementNamed(context, '/login');
}
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent.withOpacity(0.8), backgroundColor: Colors.redAccent.withOpacity(0.8),
@ -115,7 +332,13 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
icon: const Icon(Icons.logout, color: Colors.white), 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), const SizedBox(height: 100),
@ -132,7 +355,10 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 10, left: 5), padding: const EdgeInsets.only(bottom: 10, left: 5),
child: Text( child: Text(
title, 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( return ListTile(
leading: Icon(icon, color: Colors.green, size: 22), leading: Icon(icon, color: Colors.green, size: 22),
title: Text(title, style: const TextStyle(color: Colors.white, fontSize: 14)), title: Text(title,
trailing: trailing != null style: const TextStyle(color: Colors.white, fontSize: 14)),
? Text(trailing, style: const TextStyle(color: Colors.grey, fontSize: 12)) trailing: trailing != null
? Text(trailing,
style: const TextStyle(color: Colors.grey, fontSize: 12))
: const Icon(Icons.chevron_right, color: Colors.grey, size: 18), : const Icon(Icons.chevron_right, color: Colors.grey, size: 18),
onTap: onTap, onTap: onTap,
); );
} }
Widget _buildConnectionStatus(String title, bool isConnected, IconData icon) { Widget _buildConnectionStatus(
String title, bool isConnected, IconData icon) {
return ListTile( return ListTile(
leading: Icon(icon, color: isConnected ? Colors.blue : Colors.red, size: 22), leading: Icon(icon,
title: Text(title, style: const TextStyle(color: Colors.white, fontSize: 14)), color: isConnected ? Colors.blue : Colors.red, size: 22),
title: Text(title,
style: const TextStyle(color: Colors.white, fontSize: 14)),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -176,10 +408,13 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
isConnected ? 'Online' : 'Offline', 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; String get timestamp => _timestamp;
// Getter batas // Getter batas
double _minSuhu = 0.0;
double _minRh = 0.0;
double get maxSuhu => _maxSuhu; double get maxSuhu => _maxSuhu;
double get maxRh => _maxRh; double get maxRh => _maxRh;
double get minSuhu => _minSuhu;
double get minRh => _minRh;
Function(String, bool)? onConnectionResult; Function(String, bool)? onConnectionResult;
void setLimits(double suhuMax, double rhMax) { void setLimits(double suhuMax, double rhMax, double suhuMin, double rhMin) {
_maxSuhu = suhuMax; _maxSuhu = suhuMax;
_maxRh = rhMax; _maxRh = rhMax;
_minSuhu = suhuMin;
_minRh = rhMin;
notifyListeners(); notifyListeners();
} }
@ -76,12 +83,12 @@ class MqttService extends ChangeNotifier {
if (client?.connectionStatus?.state == MqttConnectionState.connected) { if (client?.connectionStatus?.state == MqttConnectionState.connected) {
debugPrint('MQTT: Terhubung!'); debugPrint('MQTT: Terhubung!');
onConnectionResult?.call(' Terhubung ke Server Kopi', true); onConnectionResult?.call(' Terhubung ke Server Kopi', true);
_subscribeToTopics(); _subscribeToTopics();
notifyListeners(); notifyListeners();
return true; return true;
} else { } else {
onConnectionResult?.call(' Koneksi Gagal', false); onConnectionResult?.call(' Koneksi Gagal', false);
notifyListeners(); notifyListeners();
return false; return false;
} }

View File

@ -7,15 +7,18 @@ class SupabaseService {
final supabase = Supabase.instance.client; 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( final response = await supabase.auth.signUp(
email: email, email: email,
password: password, password: password,
); );
if (response.user != null) { if (response.user != null) {
// Insert into profiles table
await supabase.from('profiles').insert({ await supabase.from('profiles').insert({
'id': response.user!.id, 'id': response.user!.id,
'nama_lengkap': fullName, 'nama_lengkap': fullName,
@ -38,7 +41,50 @@ class SupabaseService {
User? get currentUser => supabase.auth.currentUser; 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 { Future<List<Map<String, dynamic>>> getSensorLogs() async {
return await supabase return await supabase
.from('sensor_log') .from('sensor_log')
@ -47,56 +93,6 @@ class SupabaseService {
.limit(20); .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 { Future<Map<String, dynamic>?> getLatestLog() async {
final List<dynamic> data = await supabase final List<dynamic> data = await supabase
.from('sensor_log') .from('sensor_log')
@ -105,4 +101,72 @@ class SupabaseService {
.limit(1); .limit(1);
return data.isNotEmpty ? data.first as Map<String, dynamic> : null; 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 "generated_plugin_registrant.h"
#include <gtk/gtk_plugin.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> #include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar = g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar); 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

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

View File

@ -6,11 +6,15 @@ import FlutterMacOS
import Foundation import Foundation
import app_links import app_links
import open_file_mac
import printing
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@ -49,6 +57,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -97,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -137,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -169,6 +209,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart: fl_chart:
dependency: "direct main" dependency: "direct main"
description: description:
@ -241,7 +289,7 @@ packages:
source: hosted source: hosted
version: "1.0.3" version: "1.0.3"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@ -256,6 +304,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -356,10 +412,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: mime name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "1.0.6"
mqtt_client: mqtt_client:
dependency: "direct main" dependency: "direct main"
description: description:
@ -392,6 +448,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.3.0" 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: package_config:
dependency: transitive dependency: transitive
description: description:
@ -408,8 +528,16 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_parsing:
dependency: transitive dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -456,6 +584,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" 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: platform:
dependency: transitive dependency: transitive
description: description:
@ -488,6 +640,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.0" version: "2.7.0"
printing:
dependency: "direct main"
description:
name: printing
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
url: "https://pub.dev"
source: hosted
version: "5.14.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -504,6 +664,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
realtime_client: realtime_client:
dependency: transitive dependency: transitive
description: description:
@ -536,6 +704,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.28.0" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -741,6 +917,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -797,6 +981,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml: yaml:
dependency: transitive dependency: transitive
description: description:

View File

@ -37,9 +37,16 @@ dependencies:
fl_chart: ^1.2.0 fl_chart: ^1.2.0
intl: ^0.20.2 intl: ^0.20.2
shared_preferences: ^2.5.5 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: dependency_overrides:
win32: ^6.1.0 win32: ^6.1.0
share_plus_platform_interface: ^3.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

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

View File

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