Update kode terbaru
This commit is contained in:
parent
3c1e9de4bf
commit
318f572f4a
|
|
@ -17,7 +17,7 @@ def flutterVersionName = localProperties.getProperty('flutter.versionName') ?: '
|
|||
|
||||
android {
|
||||
namespace "com.example.coffee_iot_flutter"
|
||||
compileSdk 35
|
||||
compileSdk 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@
|
|||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="com.example.coffee_iot_flutter"
|
||||
android:host="reset-callback" />
|
||||
</intent-filter>
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
|
|
|
|||
|
|
@ -1,22 +1,47 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'services/mqtt_service.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/register_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/analytics_screen.dart';
|
||||
import 'screens/image_result_screen.dart';
|
||||
import 'screens/change_password_screen.dart';
|
||||
import 'screens/forgot_password_screen.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Supabase with the provided URL and Publishable Key
|
||||
await Supabase.initialize(
|
||||
url: 'https://ddmhzzegejbsshihzext.supabase.co',
|
||||
anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRkbWh6emVnZWpic3NoaWh6ZXh0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzcyMjIyNzAsImV4cCI6MjA5Mjc5ODI3MH0._r-viIQLMXuNNdg4tcn7sJTBO9QqSpSlF7hk43hu2Ks',
|
||||
);
|
||||
|
||||
// Listener auth state — handle password recovery
|
||||
Supabase.instance.client.auth.onAuthStateChange.listen((data) {
|
||||
if (data.event == AuthChangeEvent.passwordRecovery) {
|
||||
navigatorKey.currentState?.pushReplacementNamed(
|
||||
'/change_password',
|
||||
arguments: {'dari_reset': true},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle deep link saat app sudah jalan
|
||||
final appLinks = AppLinks();
|
||||
appLinks.uriLinkStream.listen((uri) {
|
||||
final uriStr = uri.toString();
|
||||
if (uriStr.contains('reset-callback') ||
|
||||
uriStr.contains('type=recovery')) {
|
||||
// Supabase akan handle token otomatis via onAuthStateChange
|
||||
debugPrint('Deep link diterima: $uriStr');
|
||||
}
|
||||
});
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
|
|
@ -33,6 +58,7 @@ class MyApp extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
title: 'Coffee IoT',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
|
|
@ -40,14 +66,16 @@ class MyApp extends StatelessWidget {
|
|||
primarySwatch: Colors.green,
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
),
|
||||
initialRoute: '/login', // Back to Login to test Supabase
|
||||
initialRoute: '/login',
|
||||
routes: {
|
||||
'/login': (context) => const LoginScreen(),
|
||||
'/register': (context) => const RegisterScreen(),
|
||||
'/home': (context) => const HomeScreen(),
|
||||
'/analytics': (context) => const AnalyticsScreen(),
|
||||
'/image_result': (context) => const ImageResultScreen(),
|
||||
'/login': (context) => const LoginScreen(),
|
||||
'/register': (context) => const RegisterScreen(),
|
||||
'/home': (context) => const HomeScreen(),
|
||||
'/analytics': (context) => const AnalyticsScreen(),
|
||||
'/image_result': (context) => const ImageResultScreen(),
|
||||
'/change_password': (context) => const ChangePasswordScreen(),
|
||||
'/forgot_password': (context) => const ForgotPasswordScreen(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ class _AnalyticsScreenState extends State<AnalyticsScreen> {
|
|||
|
||||
Future<void> _fetchLogs() async {
|
||||
try {
|
||||
final data = await SupabaseService().getSensorLogs();
|
||||
final data = await SupabaseService().getFotoDataset();
|
||||
setState(() {
|
||||
_logs = data;
|
||||
_isLoading = false;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ class ControlSettingsScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _ControlSettingsScreenState extends State<ControlSettingsScreen> {
|
||||
int _operatingMode = 1; // 0 = otomatis, 1 = manual
|
||||
int _operatingMode = 0; // 0 = otomatis, 1 = manual
|
||||
final TextEditingController _tempMinController = TextEditingController();
|
||||
final TextEditingController _tempMaxController = TextEditingController();
|
||||
final TextEditingController _humMinController = TextEditingController();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,12 +36,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
final List<Widget> _screens = [
|
||||
const MonitoringScreen(),
|
||||
const ControlSettingsScreen(),
|
||||
const RecordingScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
List<Widget> get _screens => [
|
||||
MonitoringScreen(onProfileTap: () => setState(() => _currentIndex = 3)),
|
||||
const ControlSettingsScreen(),
|
||||
const RecordingScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -58,7 +58,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||
showUnselectedLabels: false,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.grid_view), label: 'Monitoring'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.build), label: 'Control'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.tune), label: 'Control'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.access_time), label: 'Recording'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,44 +1,254 @@
|
|||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class ImageResultScreen extends StatelessWidget {
|
||||
const ImageResultScreen({super.key});
|
||||
|
||||
// ============================================================
|
||||
// GENERATE PDF
|
||||
// ============================================================
|
||||
|
||||
Future<void> _exportPDF(
|
||||
BuildContext context, Map<String, dynamic> args) async {
|
||||
// Tampilkan loading
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(
|
||||
child: CircularProgressIndicator(color: Colors.green),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final pdf = pw.Document();
|
||||
|
||||
// Download foto dari URL untuk dimasukkan ke PDF
|
||||
pw.MemoryImage? fotoImage;
|
||||
final urlFoto = args['url_foto']?.toString() ?? '';
|
||||
if (urlFoto.isNotEmpty) {
|
||||
try {
|
||||
final response = await http.get(Uri.parse(urlFoto));
|
||||
if (response.statusCode == 200) {
|
||||
fotoImage = pw.MemoryImage(response.bodyBytes);
|
||||
}
|
||||
} catch (_) {
|
||||
// Foto gagal didownload, lanjut tanpa foto
|
||||
}
|
||||
}
|
||||
|
||||
// Buat halaman PDF
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
margin: const pw.EdgeInsets.all(32),
|
||||
build: (pw.Context context) {
|
||||
return pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header ──
|
||||
pw.Container(
|
||||
width: double.infinity,
|
||||
padding: const pw.EdgeInsets.all(16),
|
||||
decoration: pw.BoxDecoration(
|
||||
color: PdfColors.green800,
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
),
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
'Dataset Pengeringan Biji Kopi',
|
||||
style: pw.TextStyle(
|
||||
color: PdfColors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
pw.Text(
|
||||
'Sistem Monitoring Otomatis',
|
||||
style: const pw.TextStyle(
|
||||
color: PdfColors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 20),
|
||||
|
||||
// ── Info Waktu ──
|
||||
pw.Text(
|
||||
'Tanggal & Waktu: ${args['date']} - ${args['time']}',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Divider(),
|
||||
pw.SizedBox(height: 16),
|
||||
|
||||
// ── Foto ──
|
||||
if (fotoImage != null) ...[
|
||||
pw.Center(
|
||||
child: pw.Container(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
decoration: pw.BoxDecoration(
|
||||
borderRadius: pw.BorderRadius.circular(8),
|
||||
border: pw.Border.all(color: PdfColors.grey300),
|
||||
),
|
||||
child: pw.ClipRRect(
|
||||
horizontalRadius: 8,
|
||||
verticalRadius: 8,
|
||||
child: pw.Image(
|
||||
fotoImage,
|
||||
fit: pw.BoxFit.contain,
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Divider(),
|
||||
pw.SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// ── Data Sensor ──
|
||||
pw.Text(
|
||||
'Data Sensor',
|
||||
style: pw.TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
),
|
||||
),
|
||||
pw.SizedBox(height: 10),
|
||||
|
||||
pw.Table(
|
||||
border: pw.TableBorder.all(color: PdfColors.grey300),
|
||||
columnWidths: {
|
||||
0: const pw.FlexColumnWidth(1),
|
||||
1: const pw.FlexColumnWidth(2),
|
||||
},
|
||||
children: [
|
||||
_pdfRow('Suhu', args['temp']),
|
||||
_pdfRow('Kelembapan', args['humidity']),
|
||||
_pdfRow('Intensitas Cahaya', args['light']),
|
||||
_pdfRow('Fan Intake (Kipas 1)', args['intake']),
|
||||
_pdfRow('Fan Exhaust (Kipas 2)', args['exhaust']),
|
||||
],
|
||||
),
|
||||
|
||||
pw.SizedBox(height: 24),
|
||||
pw.Divider(),
|
||||
pw.SizedBox(height: 8),
|
||||
|
||||
// ── Footer ──
|
||||
pw.Text(
|
||||
'Diekspor dari Aplikasi Monitoring Kopi',
|
||||
style: const pw.TextStyle(
|
||||
color: PdfColors.grey,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Tutup loading
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
|
||||
// Tampilkan preview PDF + opsi simpan/share
|
||||
await Printing.layoutPdf(
|
||||
onLayout: (PdfPageFormat format) async => pdf.save(),
|
||||
name:
|
||||
'dataset_kopi_${args['date']?.replaceAll('/', '-')}_${args['time']?.replaceAll(':', '-')}.pdf',
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context); // tutup loading
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal membuat PDF: $e'),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pw.TableRow _pdfRow(String label, String? value) {
|
||||
return pw.TableRow(
|
||||
children: [
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(
|
||||
label,
|
||||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 11),
|
||||
),
|
||||
),
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.all(8),
|
||||
child: pw.Text(value ?? '-', style: const pw.TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BUILD UI
|
||||
// ============================================================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Receiving arguments from the previous screen
|
||||
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>? ?? {
|
||||
'id': '1',
|
||||
'date': '2025/11/18',
|
||||
'time': '14:35:00',
|
||||
'temp': '45.5 °C',
|
||||
'humidity': '52 %',
|
||||
'light': '200 Lux',
|
||||
'location': 'Gudang A',
|
||||
'intake': 'ON',
|
||||
'exhaust': 'ON'
|
||||
};
|
||||
final args = ModalRoute.of(context)?.settings.arguments
|
||||
as Map<String, dynamic>? ??
|
||||
{
|
||||
'id': '1',
|
||||
'date': '2025/11/18',
|
||||
'time': '14:35:00',
|
||||
'temp': '45.5 °C',
|
||||
'humidity': '52 %',
|
||||
'light': '200 Lux',
|
||||
'intake': 'ON',
|
||||
'exhaust': 'ON',
|
||||
'url_foto': '',
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text('Detail Data Citra', style: TextStyle(color: Colors.white)),
|
||||
title: const Text(
|
||||
'Detail Data Citra',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.grey),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.download, color: Colors.green),
|
||||
)
|
||||
],
|
||||
// Icon download dihapus
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Image Box
|
||||
// ── Foto ──
|
||||
Container(
|
||||
height: 250,
|
||||
width: double.infinity,
|
||||
|
|
@ -47,20 +257,23 @@ class ImageResultScreen extends StatelessWidget {
|
|||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: args['url_foto'] != null && args['url_foto'].toString().isNotEmpty
|
||||
child: args['url_foto'] != null &&
|
||||
args['url_foto'].toString().isNotEmpty
|
||||
? Image.network(
|
||||
args['url_foto'],
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.green),
|
||||
child:
|
||||
CircularProgressIndicator(color: Colors.green),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stack) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.broken_image, size: 80, color: Colors.grey),
|
||||
const Icon(Icons.broken_image,
|
||||
size: 80, color: Colors.grey),
|
||||
Text('Citra #${args['id']}',
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
|
|
@ -76,8 +289,8 @@ class ImageResultScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Data Card
|
||||
|
||||
// ── Data Card ──
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -86,50 +299,60 @@ class ImageResultScreen extends StatelessWidget {
|
|||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Tanggal & waktu
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.calendar_today, color: Colors.green, size: 16),
|
||||
const Icon(Icons.calendar_today,
|
||||
color: Colors.green, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${args['date']} - ${args['time']}',
|
||||
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text('Lokasi: ${args['location']}', style: const TextStyle(color: Colors.grey, fontSize: 13)),
|
||||
// Lokasi dihapus
|
||||
const Divider(color: Colors.grey, height: 30),
|
||||
|
||||
_buildDataRow('Suhu', args['temp'], 'Kelembaban', args['humidity']),
|
||||
const SizedBox(height: 15),
|
||||
_buildDataRow('Cahaya', args['light'], 'Mode', 'Terjadwal'),
|
||||
const Divider(color: Colors.grey, height: 30),
|
||||
|
||||
|
||||
_buildDataRow(
|
||||
'Fan Intake', args['intake'],
|
||||
'Fan Exhaust', args['exhaust'],
|
||||
valueColor: Colors.green
|
||||
'Suhu', args['temp'], 'Kelembaban', args['humidity']),
|
||||
const SizedBox(height: 15),
|
||||
_buildDataRow(
|
||||
'Cahaya', args['light'], 'Mode', 'Terjadwal'),
|
||||
const Divider(color: Colors.grey, height: 30),
|
||||
|
||||
_buildDataRow(
|
||||
'Kipas 1 (Intake)', args['intake'],
|
||||
'Kipas 2 (Exhaust)', args['exhaust'],
|
||||
valueColor: Colors.green,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
// Share Button
|
||||
|
||||
// ── Tombol Export PDF ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 55,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {},
|
||||
onPressed: () => _exportPDF(context, args),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.share, color: Colors.white),
|
||||
label: const Text('Export Data Lengkap (.CSV)', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
icon: const Icon(Icons.picture_as_pdf, color: Colors.white),
|
||||
label: const Text(
|
||||
'Export Data Lengkap (.PDF)',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -138,7 +361,13 @@ class ImageResultScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildDataRow(String label1, String value1, String label2, String value2, {Color valueColor = Colors.white}) {
|
||||
Widget _buildDataRow(
|
||||
String label1,
|
||||
String value1,
|
||||
String label2,
|
||||
String value2, {
|
||||
Color valueColor = Colors.white,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
@ -146,9 +375,14 @@ class ImageResultScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label1, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Text(label1,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(': $value1', style: TextStyle(color: valueColor, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
Text(': $value1',
|
||||
style: TextStyle(
|
||||
color: valueColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -156,13 +390,18 @@ class ImageResultScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label2, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Text(label2,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(': $value2', style: TextStyle(color: valueColor, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||
Text(': $value2',
|
||||
style: TextStyle(
|
||||
color: valueColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,12 +9,18 @@ class LoginScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _emailController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true; // Tambahan: State untuk mata password
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (_emailController.text.trim().isEmpty ||
|
||||
_passwordController.text.trim().isEmpty) {
|
||||
_showError('Email dan password tidak boleh kosong!');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await SupabaseService().signIn(
|
||||
|
|
@ -26,15 +32,62 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String pesanError = 'Login gagal, coba lagi.';
|
||||
|
||||
if (errorStr.contains('invalid_credentials') ||
|
||||
errorStr.contains('invalid login credentials') ||
|
||||
errorStr.contains('email or password')) {
|
||||
pesanError = 'Email atau password salah!';
|
||||
} else if (errorStr.contains('not confirmed')) {
|
||||
pesanError = 'Email belum dikonfirmasi, cek inbox kamu.';
|
||||
} else if (errorStr.contains('network') ||
|
||||
errorStr.contains('socket') ||
|
||||
errorStr.contains('connection')) {
|
||||
pesanError = 'Tidak ada koneksi internet.';
|
||||
} else if (errorStr.contains('too many') ||
|
||||
errorStr.contains('rate limit')) {
|
||||
pesanError = 'Terlalu banyak percobaan, tunggu sebentar.';
|
||||
} else if (errorStr.contains('user not found')) {
|
||||
pesanError = 'Email atau password salah!';
|
||||
}
|
||||
|
||||
_showError(pesanError);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String pesan) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.white),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pesan,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -52,7 +105,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
const SizedBox(height: 30),
|
||||
const Text(
|
||||
'Selamat Datang',
|
||||
style: TextStyle(color: Colors.green, fontSize: 32, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Text(
|
||||
'Sistem Kendali Pengering Kopi',
|
||||
|
|
@ -70,6 +126,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
children: [
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.email, color: Colors.green),
|
||||
|
|
@ -81,21 +138,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
const Divider(color: Colors.grey),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword, // Menggunakan variabel state
|
||||
obscureText: _obscurePassword,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.lock, color: Colors.green),
|
||||
// Menggunakan IconButton untuk aksi klik
|
||||
prefixIcon:
|
||||
const Icon(Icons.lock, color: Colors.green),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility : Icons.visibility_off,
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Colors.grey,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
hintText: 'Password',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
|
|
@ -115,17 +171,31 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
? const CircularProgressIndicator(
|
||||
color: Colors.white)
|
||||
: const Text(
|
||||
'Masuk ke Akun',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Lupa Password dan Daftar — rapat tanpa jarak besar, warna sama hijau
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pushNamed(context, '/forgot_password'),
|
||||
child: const Text(
|
||||
'Lupa Password?',
|
||||
style: TextStyle(color: Colors.green),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pushNamed(context, '/register'),
|
||||
child: const Text(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import '../services/mqtt_service.dart';
|
|||
import '../services/supabase_service.dart';
|
||||
|
||||
class MonitoringScreen extends StatefulWidget {
|
||||
const MonitoringScreen({super.key});
|
||||
final VoidCallback? onProfileTap;
|
||||
const MonitoringScreen({super.key, this.onProfileTap});
|
||||
|
||||
@override
|
||||
State<MonitoringScreen> createState() => _MonitoringScreenState();
|
||||
|
|
@ -13,6 +14,9 @@ class MonitoringScreen extends StatefulWidget {
|
|||
|
||||
class _MonitoringScreenState extends State<MonitoringScreen> {
|
||||
bool _isInitialLoading = true;
|
||||
double _prevSuhu = 0;
|
||||
double _prevKelembapan = 0;
|
||||
double _prevIntensitas = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -33,6 +37,8 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
|
|||
Provider.of<MqttService>(context, listen: false).setLimits(
|
||||
double.parse(data['suhu_max'].toString()),
|
||||
double.parse(data['rh_max'].toString()),
|
||||
double.parse(data['suhu_min'].toString()),
|
||||
double.parse(data['rh_min'].toString()),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -42,288 +48,321 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mqtt = Provider.of<MqttService>(context);
|
||||
final curTemp = double.tryParse(mqtt.suhu) ?? 0;
|
||||
final curHum = double.tryParse(mqtt.kelembapan) ?? 0;
|
||||
final mqtt = Provider.of<MqttService>(context);
|
||||
final curSuhu = double.tryParse(mqtt.suhu) ?? 0;
|
||||
final curHum = double.tryParse(mqtt.kelembapan) ?? 0;
|
||||
final curLux = double.tryParse(mqtt.intensitas) ?? 0;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_prevSuhu = curSuhu;
|
||||
_prevKelembapan = curHum;
|
||||
_prevIntensitas = curLux;
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: _isInitialLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Colors.green))
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.green))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _initData,
|
||||
color: Colors.green,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header ──
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Monitoring',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Tinggi tersedia = tinggi layar dikurangi header
|
||||
const headerHeight = 175.0; // header + timestamp + mode badge + padding
|
||||
const gaps = 15.0 * 2; // 2 gap antar 3 baris
|
||||
const padding = 20.0 * 2; // padding atas bawah
|
||||
final available = constraints.maxHeight - headerHeight - gaps - padding;
|
||||
final cardH = (available / 3).clamp(100.0, 180.0);
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Header ──
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Monitoring',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold)),
|
||||
const Text('Sistem Pengeringan Kopi',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 16)),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8, height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: mqtt.client?.connectionStatus?.state ==
|
||||
MqttConnectionState.connected
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
mqtt.client?.connectionStatus?.state ==
|
||||
MqttConnectionState.connected
|
||||
? 'Connected'
|
||||
: 'Disconnected',
|
||||
style: TextStyle(
|
||||
color: mqtt.client?.connectionStatus?.state ==
|
||||
MqttConnectionState.connected
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: widget.onProfileTap,
|
||||
child: const CircleAvatar(
|
||||
backgroundColor: Color(0xFF1A1A1A),
|
||||
radius: 25,
|
||||
child: Icon(Icons.person, color: Colors.green),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// ── Timestamp ──
|
||||
Text('Update: ${mqtt.timestamp}',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 11)),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ── Mode Badge ──
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: mqtt.mode == 'manual'
|
||||
? Colors.orange.withOpacity(0.2)
|
||||
: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: mqtt.mode == 'manual' ? Colors.orange : Colors.green,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'Sistem Pengeringan Kopi',
|
||||
child: Text(
|
||||
'Mode: ${mqtt.mode.toUpperCase()}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey, fontSize: 16),
|
||||
color: mqtt.mode == 'manual' ? Colors.orange : Colors.green,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Row(
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// ── Baris 1: Suhu & Kelembaban ──
|
||||
SizedBox(
|
||||
height: cardH,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: mqtt.client?.connectionStatus
|
||||
?.state ==
|
||||
MqttConnectionState.connected
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
Expanded(
|
||||
child: _buildSensorCard(
|
||||
title: 'Suhu',
|
||||
prev: _prevSuhu,
|
||||
cur: curSuhu,
|
||||
suffix: '°C',
|
||||
dec: 1,
|
||||
icon: Icons.thermostat,
|
||||
color: curSuhu > mqtt.maxSuhu ? Colors.red : Colors.redAccent,
|
||||
isAlert: curSuhu > mqtt.maxSuhu,
|
||||
isLow: curSuhu < mqtt.minSuhu,
|
||||
onTap: () => Navigator.pushNamed(context, '/analytics',
|
||||
arguments: {'title': 'Grafik Suhu (°C)', 'color': Colors.redAccent, 'value': '${mqtt.suhu}°C'}),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
mqtt.client?.connectionStatus?.state ==
|
||||
MqttConnectionState.connected
|
||||
? 'Connected'
|
||||
: 'Disconnected',
|
||||
style: TextStyle(
|
||||
color: mqtt.client?.connectionStatus
|
||||
?.state ==
|
||||
MqttConnectionState.connected
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
fontSize: 12,
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: _buildSensorCard(
|
||||
title: 'Kelembaban',
|
||||
prev: _prevKelembapan,
|
||||
cur: curHum,
|
||||
suffix: '%',
|
||||
dec: 1,
|
||||
icon: Icons.water_drop,
|
||||
color: curHum > mqtt.maxRh ? Colors.red : Colors.cyan,
|
||||
isAlert: curHum > mqtt.maxRh,
|
||||
isLow: curHum < mqtt.minRh,
|
||||
onTap: () => Navigator.pushNamed(context, '/analytics',
|
||||
arguments: {'title': 'Grafik Kelembaban (%)', 'color': Colors.cyan, 'value': '${mqtt.kelembapan}%'}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const CircleAvatar(
|
||||
backgroundColor: Color(0xFF1A1A1A),
|
||||
radius: 25,
|
||||
child:
|
||||
Icon(Icons.person, color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ── Timestamp ──
|
||||
Text(
|
||||
'Update: ${mqtt.timestamp}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Mode Badge ──
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: mqtt.mode == 'manual'
|
||||
? Colors.orange.withOpacity(0.2)
|
||||
: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: mqtt.mode == 'manual'
|
||||
? Colors.orange
|
||||
: Colors.green,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Mode: ${mqtt.mode.toUpperCase()}',
|
||||
style: TextStyle(
|
||||
color: mqtt.mode == 'manual'
|
||||
? Colors.orange
|
||||
: Colors.green,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Grid Sensor + Kontrol ──
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 15,
|
||||
mainAxisSpacing: 15,
|
||||
children: [
|
||||
_buildSensorCard(
|
||||
'Suhu',
|
||||
'${mqtt.suhu}°C',
|
||||
Icons.thermostat,
|
||||
curTemp > mqtt.maxSuhu
|
||||
? Colors.red
|
||||
: Colors.redAccent,
|
||||
isAlert: curTemp > mqtt.maxSuhu,
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/analytics',
|
||||
arguments: {
|
||||
'title': 'Grafik Suhu (°C)',
|
||||
'color': Colors.redAccent,
|
||||
'value': '${mqtt.suhu}°C',
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildSensorCard(
|
||||
'Kelembaban',
|
||||
'${mqtt.kelembapan}%',
|
||||
Icons.water_drop,
|
||||
curHum > mqtt.maxRh
|
||||
? Colors.red
|
||||
: Colors.cyan,
|
||||
isAlert: curHum > mqtt.maxRh,
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/analytics',
|
||||
arguments: {
|
||||
'title': 'Grafik Kelembaban (%)',
|
||||
'color': Colors.cyan,
|
||||
'value': '${mqtt.kelembapan}%',
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildSensorCard(
|
||||
'Cahaya',
|
||||
'${mqtt.intensitas} Lux',
|
||||
Icons.wb_sunny,
|
||||
Colors.orange,
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/analytics',
|
||||
arguments: {
|
||||
'title': 'Grafik Cahaya (Lux)',
|
||||
'color': Colors.orange,
|
||||
'value': '${mqtt.intensitas} Lux',
|
||||
},
|
||||
),
|
||||
),
|
||||
_buildControlCard(
|
||||
'Kipas 1 (Exhaust)',
|
||||
mqtt.isKipas1On,
|
||||
(val) async {
|
||||
mqtt.perintahKipas("1", val);
|
||||
await SupabaseService().logFanAction(
|
||||
val ? 'ON' : 'OFF',
|
||||
mqtt.isKipas2On ? 'ON' : 'OFF',
|
||||
curTemp,
|
||||
curHum,
|
||||
double.tryParse(mqtt.intensitas) ?? 0,
|
||||
);
|
||||
},
|
||||
mqtt,
|
||||
),
|
||||
_buildControlCard(
|
||||
'Kipas 2 (Intake)',
|
||||
mqtt.isKipas2On,
|
||||
(val) async {
|
||||
mqtt.perintahKipas("2", val);
|
||||
await SupabaseService().logFanAction(
|
||||
mqtt.isKipas1On ? 'ON' : 'OFF',
|
||||
val ? 'ON' : 'OFF',
|
||||
curTemp,
|
||||
curHum,
|
||||
double.tryParse(mqtt.intensitas) ?? 0,
|
||||
);
|
||||
},
|
||||
mqtt,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// ── Alert Banner ──
|
||||
if (curTemp > mqtt.maxSuhu || curHum > mqtt.maxRh)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 20),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: Colors.red),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
color: Colors.red),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Peringatan: Parameter pengeringan melewati batas aman!',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12),
|
||||
// ── Baris 2: Cahaya full width ──
|
||||
SizedBox(
|
||||
height: cardH,
|
||||
width: double.infinity,
|
||||
child: _buildSensorCard(
|
||||
title: 'Cahaya',
|
||||
prev: _prevIntensitas,
|
||||
cur: curLux,
|
||||
suffix: ' Lux',
|
||||
dec: 1,
|
||||
icon: Icons.wb_sunny,
|
||||
color: Colors.orange,
|
||||
onTap: () => Navigator.pushNamed(context, '/analytics',
|
||||
arguments: {'title': 'Grafik Cahaya (Lux)', 'color': Colors.orange, 'value': '${mqtt.intensitas} Lux'}),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// ── Baris 3: Kipas 1 & Kipas 2 ──
|
||||
SizedBox(
|
||||
height: cardH,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildControlCard(
|
||||
'Kipas 1\n(Intake)',
|
||||
mqtt.isKipas1On,
|
||||
(val) => mqtt.perintahKipas("1", val),
|
||||
mqtt,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: _buildControlCard(
|
||||
'Kipas 2\n(Exhaust)',
|
||||
mqtt.isKipas2On,
|
||||
(val) => mqtt.perintahKipas("2", val),
|
||||
mqtt,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Alert Banner ──
|
||||
if (curSuhu > mqtt.maxSuhu || curHum > mqtt.maxRh)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 15),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: Colors.red),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.red),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Peringatan: Parameter pengeringan melewati batas aman!',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (curSuhu < mqtt.minSuhu || curHum < mqtt.minRh)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 15),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: Colors.blue),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Info: Parameter pengeringan di bawah batas minimum!',
|
||||
style: TextStyle(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSensorCard(
|
||||
String title,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color color, {
|
||||
Widget _buildSensorCard({
|
||||
required String title,
|
||||
required double prev,
|
||||
required double cur,
|
||||
required String suffix,
|
||||
required int dec,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
bool isAlert = false,
|
||||
bool isLow = false,
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isAlert
|
||||
? Colors.red.withOpacity(0.1)
|
||||
: const Color(0xFF1A1A1A),
|
||||
color: isAlert ? Colors.red.withOpacity(0.1) : isLow ? Colors.blue.withOpacity(0.1) : const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border:
|
||||
isAlert ? Border.all(color: Colors.red, width: 2) : null,
|
||||
border: isAlert ? Border.all(color: Colors.red, width: 2) : isLow ? Border.all(color: Colors.blue, width: 2) : null,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 40),
|
||||
const SizedBox(height: 10),
|
||||
Text(title,
|
||||
style:
|
||||
const TextStyle(color: Colors.grey, fontSize: 14)),
|
||||
const SizedBox(height: 5),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold)),
|
||||
Icon(icon, color: color, size: 32),
|
||||
const SizedBox(height: 6),
|
||||
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: prev, end: cur),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, _) => FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'${value.toStringAsFixed(dec)}$suffix',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -337,50 +376,57 @@ class _MonitoringScreenState extends State<MonitoringScreen> {
|
|||
MqttService mqtt,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.settings_input_component,
|
||||
color: Colors.green, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.settings_input_component,
|
||||
color: isOn ? Colors.green : Colors.grey, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Mode: ${mqtt.mode.toUpperCase()}',
|
||||
style: const TextStyle(
|
||||
color: Colors.green,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isOn ? 'ON' : 'OFF',
|
||||
Text('Mode: ${mqtt.mode.toUpperCase()}',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 12)),
|
||||
SizedBox(
|
||||
height: 30,
|
||||
child: Switch(
|
||||
value: isOn,
|
||||
onChanged:
|
||||
mqtt.mode == 'manual' ? onChanged : null,
|
||||
activeColor: Colors.green,
|
||||
),
|
||||
color: Colors.green, fontSize: 9, fontWeight: FontWeight.bold)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
isOn ? 'ON' : 'OFF',
|
||||
style: TextStyle(
|
||||
color: isOn ? Colors.green : Colors.grey,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(
|
||||
height: 28,
|
||||
child: Switch(
|
||||
value: isOn,
|
||||
onChanged: mqtt.mode == 'manual' ? onChanged : null,
|
||||
activeColor: Colors.green,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -246,9 +246,9 @@ class _RecordingScreenState extends State<RecordingScreen> {
|
|||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Galeri Citra Dataset',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
'Galeri Citra Dataset (${_gallery.length} foto)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold),
|
||||
|
|
@ -281,23 +281,23 @@ class _RecordingScreenState extends State<RecordingScreen> {
|
|||
itemCount: _gallery.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = _gallery[index];
|
||||
final nomorFoto = index + 1;
|
||||
final timestamp = item['timestamp'] ?? '';
|
||||
final tanggal = timestamp.contains(' ') ? timestamp.split(' ')[0] : timestamp;
|
||||
final jam = timestamp.contains(' ') ? timestamp.split(' ')[1] : '';
|
||||
final jamSingkat = jam.length >= 5 ? jam.substring(0, 5) : jam;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/image_result',
|
||||
arguments: {
|
||||
'id': item['id'].toString(),
|
||||
'date': item['timestamp']
|
||||
?.split(' ')[0] ??
|
||||
'',
|
||||
'time': item['timestamp']
|
||||
?.split(' ')[1] ??
|
||||
'',
|
||||
'date': tanggal,
|
||||
'time': jam,
|
||||
'temp': '${item['suhu']} °C',
|
||||
'humidity':
|
||||
'${item['kelembapan']} %',
|
||||
'light':
|
||||
'${item['intensitas']} Lux',
|
||||
'humidity': '${item['kelembapan']} %',
|
||||
'light': '${item['intensitas']} Lux',
|
||||
'location': 'Gudang Pengering',
|
||||
'intake': item['kipas1'] ?? 'OFF',
|
||||
'exhaust': item['kipas2'] ?? 'OFF',
|
||||
|
|
@ -307,23 +307,77 @@ class _RecordingScreenState extends State<RecordingScreen> {
|
|||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius:
|
||||
BorderRadius.circular(15),
|
||||
image: item['url_foto'] != null
|
||||
? DecorationImage(
|
||||
image: NetworkImage(
|
||||
item['url_foto']),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Foto 75% tinggi card
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
|
||||
child: item['url_foto'] != null
|
||||
? Image.network(
|
||||
item['url_foto'],
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Center(
|
||||
child: Icon(Icons.broken_image, color: Colors.grey, size: 40),
|
||||
),
|
||||
loadingBuilder: (_, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Colors.green, strokeWidth: 2),
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Center(
|
||||
child: Icon(Icons.image, color: Colors.grey, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Caption bawah foto
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(15)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Badge nomor foto
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.green.withOpacity(0.5)),
|
||||
),
|
||||
child: Text(
|
||||
'#$nomorFoto',
|
||||
style: const TextStyle(
|
||||
color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Tanggal dan jam
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(tanggal,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 8)),
|
||||
Text(jamSingkat,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: item['url_foto'] == null
|
||||
? const Center(
|
||||
child: Icon(Icons.image,
|
||||
color: Colors.grey,
|
||||
size: 50),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,37 +9,110 @@ class RegisterScreen extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true; // Tambahan: State untuk mata password
|
||||
bool _isLoading = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
final nama = _nameController.text.trim();
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
// ── Validasi kosong ──
|
||||
if (nama.isEmpty || email.isEmpty || password.isEmpty) {
|
||||
_showSnackBar('Semua field harus diisi!', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Validasi format email ──
|
||||
if (!email.contains('@') || !email.contains('.')) {
|
||||
_showSnackBar('Format email tidak valid!', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Validasi panjang password ──
|
||||
if (password.length < 6) {
|
||||
_showSnackBar('Password minimal 6 karakter!', isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
await SupabaseService().signUp(
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text.trim(),
|
||||
_nameController.text.trim(),
|
||||
);
|
||||
await SupabaseService().signUp(email, password, nama);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Registration successful! Please login.')),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
_showSnackBar('Akun berhasil dibuat! Silakan login.', isError: false);
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) Navigator.pop(context);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String pesan = 'Pendaftaran gagal, coba lagi.';
|
||||
|
||||
if (errorStr.contains('user_already_exists') ||
|
||||
errorStr.contains('already registered') ||
|
||||
errorStr.contains('already been registered')) {
|
||||
pesan = 'Email sudah terdaftar, silakan login.';
|
||||
} else if (errorStr.contains('invalid') &&
|
||||
errorStr.contains('email')) {
|
||||
pesan = 'Format email tidak valid!';
|
||||
} else if (errorStr.contains('password') &&
|
||||
errorStr.contains('weak')) {
|
||||
pesan = 'Password terlalu lemah, gunakan kombinasi huruf dan angka.';
|
||||
} else if (errorStr.contains('network') ||
|
||||
errorStr.contains('socket')) {
|
||||
pesan = 'Tidak ada koneksi internet.';
|
||||
}
|
||||
|
||||
_showSnackBar(pesan, isError: true);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackBar(String pesan, {required bool isError}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isError ? Icons.error_outline : Icons.check_circle_outline,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pesan,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor:
|
||||
isError ? Colors.red.shade700 : Colors.green.shade700,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -60,7 +133,10 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||
children: [
|
||||
const Text(
|
||||
'Buat Akun Baru',
|
||||
style: TextStyle(color: Colors.green, fontSize: 32, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Text(
|
||||
'Lengkapi data diri Anda untuk memulai',
|
||||
|
|
@ -72,56 +148,67 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
border:
|
||||
Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Nama Lengkap
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.person, color: Colors.green),
|
||||
prefixIcon:
|
||||
Icon(Icons.person, color: Colors.green),
|
||||
hintText: 'Nama Lengkap',
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.grey),
|
||||
|
||||
// Email
|
||||
TextField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.email, color: Colors.green),
|
||||
prefixIcon:
|
||||
Icon(Icons.email, color: Colors.green),
|
||||
hintText: 'Email',
|
||||
hintStyle: TextStyle(color: Colors.grey),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.grey),
|
||||
|
||||
// Password
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword, // Menggunakan variabel state
|
||||
obscureText: _obscurePassword,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.lock, color: Colors.green),
|
||||
// Menggunakan IconButton untuk aksi klik
|
||||
prefixIcon:
|
||||
const Icon(Icons.lock, color: Colors.green),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? Icons.visibility : Icons.visibility_off,
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Colors.grey,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
hintText: 'Password',
|
||||
hintText: 'Password (min. 6 karakter)',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// Tombol Daftar
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 55,
|
||||
|
|
@ -129,15 +216,21 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||
onPressed: _isLoading ? null : _handleRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
disabledBackgroundColor:
|
||||
Colors.green.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
? const CircularProgressIndicator(
|
||||
color: Colors.white)
|
||||
: const Text(
|
||||
'Daftar Sekarang',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,193 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:excel/excel.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../services/supabase_service.dart';
|
||||
import '../services/mqtt_service.dart';
|
||||
import 'package:mqtt_client/mqtt_client.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
String _namaLengkap = '';
|
||||
bool _isLoadingProfile = true;
|
||||
bool _isExporting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
try {
|
||||
final profile = await SupabaseService().getProfile();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_namaLengkap = profile?['nama_lengkap'] ?? '';
|
||||
_isLoadingProfile = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _isLoadingProfile = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export foto_dataset ke Excel ──
|
||||
Future<void> _exportExcel() async {
|
||||
setState(() => _isExporting = true);
|
||||
|
||||
try {
|
||||
// Ambil semua data dari foto_dataset
|
||||
final data = await SupabaseService().getFotoDataset();
|
||||
|
||||
if (data.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Belum ada data dataset untuk diexport!'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Buat file Excel
|
||||
final excel = Excel.createExcel();
|
||||
final sheet = excel['Dataset Kopi'];
|
||||
|
||||
// Header kolom
|
||||
final headers = [
|
||||
'No',
|
||||
'Timestamp',
|
||||
'Suhu (°C)',
|
||||
'Kelembapan (%)',
|
||||
'Intensitas Cahaya (Lux)',
|
||||
'Kipas 1 (Exhaust)',
|
||||
'Kipas 2 (Intake)',
|
||||
'URL Foto',
|
||||
];
|
||||
|
||||
// Style header
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
final cell = sheet.cell(
|
||||
CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0));
|
||||
cell.value = TextCellValue(headers[i]);
|
||||
cell.cellStyle = CellStyle(
|
||||
bold: true,
|
||||
backgroundColorHex: ExcelColor.fromHexString('#16A34A'),
|
||||
fontColorHex: ExcelColor.fromHexString('#FFFFFF'),
|
||||
);
|
||||
}
|
||||
|
||||
// Isi data
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
final row = data[i];
|
||||
final rowIndex = i + 1;
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 0, rowIndex: rowIndex))
|
||||
.value = IntCellValue(i + 1);
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 1, rowIndex: rowIndex))
|
||||
.value = TextCellValue(row['timestamp']?.toString() ?? '-');
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 2, rowIndex: rowIndex))
|
||||
.value = DoubleCellValue(
|
||||
double.tryParse(row['suhu'].toString()) ?? 0);
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 3, rowIndex: rowIndex))
|
||||
.value = DoubleCellValue(
|
||||
double.tryParse(row['kelembapan'].toString()) ?? 0);
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 4, rowIndex: rowIndex))
|
||||
.value = DoubleCellValue(
|
||||
double.tryParse(row['intensitas'].toString()) ?? 0);
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 5, rowIndex: rowIndex))
|
||||
.value = TextCellValue(row['kipas1']?.toString() ?? '-');
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 6, rowIndex: rowIndex))
|
||||
.value = TextCellValue(row['kipas2']?.toString() ?? '-');
|
||||
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(
|
||||
columnIndex: 7, rowIndex: rowIndex))
|
||||
.value = TextCellValue(row['url_foto']?.toString() ?? '-');
|
||||
}
|
||||
|
||||
// Set lebar kolom
|
||||
sheet.setColumnWidth(0, 5);
|
||||
sheet.setColumnWidth(1, 22);
|
||||
sheet.setColumnWidth(2, 12);
|
||||
sheet.setColumnWidth(3, 16);
|
||||
sheet.setColumnWidth(4, 24);
|
||||
sheet.setColumnWidth(5, 18);
|
||||
sheet.setColumnWidth(6, 18);
|
||||
sheet.setColumnWidth(7, 50);
|
||||
|
||||
// Simpan file
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final tanggal = DateTime.now()
|
||||
.toString()
|
||||
.substring(0, 10)
|
||||
.replaceAll('-', '');
|
||||
final fileName = 'dataset_kopi_$tanggal.xlsx';
|
||||
final filePath = '${dir.path}/$fileName';
|
||||
final fileBytes = excel.save();
|
||||
|
||||
if (fileBytes == null) throw Exception('Gagal generate file Excel');
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(fileBytes);
|
||||
|
||||
// Share file
|
||||
await OpenFile.open(filePath);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'✓ Export berhasil — ${data.length} data tersimpan'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal export: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isExporting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mqtt = Provider.of<MqttService>(context);
|
||||
|
|
@ -21,7 +202,7 @@ class SettingsScreen extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Profile Section
|
||||
// ── Profile Section ──
|
||||
Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
|
|
@ -33,80 +214,116 @@ class SettingsScreen extends StatelessWidget {
|
|||
const CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: Colors.green,
|
||||
child: Icon(Icons.person, color: Colors.white, size: 40),
|
||||
child: Icon(Icons.person,
|
||||
color: Colors.white, size: 40),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user?.email?.split('@')[0].toUpperCase() ?? 'Admin Kopi',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
_isLoadingProfile
|
||||
? const SizedBox(
|
||||
width: 100,
|
||||
height: 16,
|
||||
child: LinearProgressIndicator(
|
||||
color: Colors.green,
|
||||
backgroundColor: Colors.grey,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_namaLengkap.isNotEmpty
|
||||
? _namaLengkap
|
||||
: 'Admin Kopi',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
user?.email ?? 'admin@coffee.io',
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
const Text(
|
||||
'Status: Active User',
|
||||
style: TextStyle(color: Colors.green, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: Colors.green, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// ── Sistem & Koneksi ──
|
||||
_buildSectionTitle('Sistem & Koneksi'),
|
||||
_buildSettingsCard([
|
||||
_buildConnectionStatus(
|
||||
'Database Supabase',
|
||||
true, // Always true if they can reach this screen
|
||||
Icons.storage
|
||||
),
|
||||
'Database Supabase', true, Icons.storage),
|
||||
const Divider(color: Colors.grey, height: 1),
|
||||
_buildConnectionStatus(
|
||||
'MQTT Broker (HiveMQ)',
|
||||
mqtt.client?.connectionStatus?.state == MqttConnectionState.connected,
|
||||
Icons.cloud_sync
|
||||
'MQTT Broker (EMQX)',
|
||||
mqtt.client?.connectionStatus?.state ==
|
||||
MqttConnectionState.connected,
|
||||
Icons.cloud_sync,
|
||||
),
|
||||
]),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Manajemen Data ──
|
||||
_buildSectionTitle('Manajemen Data'),
|
||||
_buildSettingsCard([
|
||||
_buildSettingItem(Icons.file_download, 'Export Semua Log (.CSV)', () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Menyiapkan file CSV...')));
|
||||
}),
|
||||
const Divider(color: Colors.grey, height: 1),
|
||||
_buildSettingItem(Icons.delete_sweep, 'Bersihkan Cache Aplikasi', () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Cache dibersihkan')));
|
||||
}),
|
||||
_isExporting
|
||||
? const ListTile(
|
||||
leading: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.green, strokeWidth: 2),
|
||||
),
|
||||
title: Text(
|
||||
'Menyiapkan file Excel...',
|
||||
style: TextStyle(
|
||||
color: Colors.grey, fontSize: 14),
|
||||
),
|
||||
)
|
||||
: _buildSettingItem(
|
||||
Icons.table_chart,
|
||||
'Export Data Dataset (.xlsx)',
|
||||
_exportExcel,
|
||||
),
|
||||
]),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Keamanan ──
|
||||
_buildSectionTitle('Keamanan'),
|
||||
_buildSettingsCard([
|
||||
_buildSettingItem(Icons.lock_reset, 'Ganti Password Akun', () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Fitur ini akan segera hadir')));
|
||||
}),
|
||||
]),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_buildSectionTitle('Tentang'),
|
||||
_buildSettingsCard([
|
||||
_buildSettingItem(Icons.info_outline, 'Versi Aplikasi', null, trailing: 'v1.2.0-Production'),
|
||||
]),
|
||||
_buildSettingItem(
|
||||
Icons.lock_reset, 'Ganti Password Akun', () {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/change_password',
|
||||
arguments: {'dari_reset': false},
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// ── Tombol Keluar ──
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 55,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
await SupabaseService().signOut();
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent.withOpacity(0.8),
|
||||
|
|
@ -115,7 +332,13 @@ class SettingsScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
icon: const Icon(Icons.logout, color: Colors.white),
|
||||
label: const Text('Keluar dari Akun', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
label: const Text(
|
||||
'Keluar dari Akun',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 100),
|
||||
|
|
@ -132,7 +355,10 @@ class SettingsScreen extends StatelessWidget {
|
|||
padding: const EdgeInsets.only(bottom: 10, left: 5),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -147,21 +373,27 @@ class SettingsScreen extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingItem(IconData icon, String title, VoidCallback? onTap, {String? trailing}) {
|
||||
Widget _buildSettingItem(IconData icon, String title, VoidCallback? onTap,
|
||||
{String? trailing}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: Colors.green, size: 22),
|
||||
title: Text(title, style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
trailing: trailing != null
|
||||
? Text(trailing, style: const TextStyle(color: Colors.grey, fontSize: 12))
|
||||
title: Text(title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
trailing: trailing != null
|
||||
? Text(trailing,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12))
|
||||
: const Icon(Icons.chevron_right, color: Colors.grey, size: 18),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionStatus(String title, bool isConnected, IconData icon) {
|
||||
Widget _buildConnectionStatus(
|
||||
String title, bool isConnected, IconData icon) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: isConnected ? Colors.blue : Colors.red, size: 22),
|
||||
title: Text(title, style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
leading: Icon(icon,
|
||||
color: isConnected ? Colors.blue : Colors.red, size: 22),
|
||||
title: Text(title,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -176,10 +408,13 @@ class SettingsScreen extends StatelessWidget {
|
|||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isConnected ? 'Online' : 'Offline',
|
||||
style: TextStyle(color: isConnected ? Colors.green : Colors.red, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: isConnected ? Colors.green : Colors.red,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,14 +29,21 @@ class MqttService extends ChangeNotifier {
|
|||
String get timestamp => _timestamp;
|
||||
|
||||
// Getter batas
|
||||
double _minSuhu = 0.0;
|
||||
double _minRh = 0.0;
|
||||
|
||||
double get maxSuhu => _maxSuhu;
|
||||
double get maxRh => _maxRh;
|
||||
double get minSuhu => _minSuhu;
|
||||
double get minRh => _minRh;
|
||||
|
||||
Function(String, bool)? onConnectionResult;
|
||||
|
||||
void setLimits(double suhuMax, double rhMax) {
|
||||
void setLimits(double suhuMax, double rhMax, double suhuMin, double rhMin) {
|
||||
_maxSuhu = suhuMax;
|
||||
_maxRh = rhMax;
|
||||
_minSuhu = suhuMin;
|
||||
_minRh = rhMin;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -76,12 +83,12 @@ class MqttService extends ChangeNotifier {
|
|||
|
||||
if (client?.connectionStatus?.state == MqttConnectionState.connected) {
|
||||
debugPrint('MQTT: Terhubung!');
|
||||
onConnectionResult?.call('✅ Terhubung ke Server Kopi', true);
|
||||
onConnectionResult?.call(' Terhubung ke Server Kopi', true);
|
||||
_subscribeToTopics();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} else {
|
||||
onConnectionResult?.call('❌ Koneksi Gagal', false);
|
||||
onConnectionResult?.call(' Koneksi Gagal', false);
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@ class SupabaseService {
|
|||
|
||||
final supabase = Supabase.instance.client;
|
||||
|
||||
// --- Auth ---
|
||||
Future<AuthResponse> signUp(String email, String password, String fullName) async {
|
||||
// ============================================================
|
||||
// AUTH
|
||||
// ============================================================
|
||||
|
||||
Future<AuthResponse> signUp(
|
||||
String email, String password, String fullName) async {
|
||||
final response = await supabase.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
|
||||
if (response.user != null) {
|
||||
// Insert into profiles table
|
||||
await supabase.from('profiles').insert({
|
||||
'id': response.user!.id,
|
||||
'nama_lengkap': fullName,
|
||||
|
|
@ -38,7 +41,50 @@ class SupabaseService {
|
|||
|
||||
User? get currentUser => supabase.auth.currentUser;
|
||||
|
||||
// --- Sensor Data (sensor_log) ---
|
||||
// ============================================================
|
||||
// PROFILE
|
||||
// ============================================================
|
||||
|
||||
// Ambil profil dari tabel profiles berdasarkan user yang login
|
||||
Future<Map<String, dynamic>?> getProfile() async {
|
||||
final user = currentUser;
|
||||
if (user == null) return null;
|
||||
final response = await supabase
|
||||
.from('profiles')
|
||||
.select()
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
return response;
|
||||
}
|
||||
|
||||
// Update password akun yang sedang login
|
||||
Future<void> updatePassword(String passwordBaru) async {
|
||||
await supabase.auth.updateUser(
|
||||
UserAttributes(password: passwordBaru),
|
||||
);
|
||||
}
|
||||
|
||||
// Kirim OTP ke email
|
||||
Future<void> resetPassword(String email) async {
|
||||
await supabase.auth.signInWithOtp(
|
||||
email: email,
|
||||
shouldCreateUser: false, // jangan buat user baru
|
||||
);
|
||||
}
|
||||
|
||||
// Verifikasi OTP yang diinput user
|
||||
Future<void> verifyOTP(String email, String token) async {
|
||||
await supabase.auth.verifyOTP(
|
||||
email: email,
|
||||
token: token,
|
||||
type: OtpType.email,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SENSOR DATA (sensor_log)
|
||||
// ============================================================
|
||||
|
||||
Future<List<Map<String, dynamic>>> getSensorLogs() async {
|
||||
return await supabase
|
||||
.from('sensor_log')
|
||||
|
|
@ -47,56 +93,6 @@ class SupabaseService {
|
|||
.limit(20);
|
||||
}
|
||||
|
||||
// --- Limits (batas_sensor) ---
|
||||
Future<Map<String, dynamic>?> getBatasSensor() async {
|
||||
final data = await supabase.from('batas_sensor').select().limit(1).single();
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> updateBatasSensor(double suhuMin, double suhuMax, double rhMin, double rhMax) async {
|
||||
await supabase.from('batas_sensor').update({
|
||||
'suhu_min': suhuMin,
|
||||
'suhu_max': suhuMax,
|
||||
'rh_min': rhMin,
|
||||
'rh_max': rhMax,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}).eq('id', 1);
|
||||
}
|
||||
|
||||
// --- Interval (interval_setting) ---
|
||||
Future<int> getIntervalSetting() async {
|
||||
final data = await supabase.from('interval_setting').select('durasi_menit').limit(1).single();
|
||||
return data['durasi_menit'] as int;
|
||||
}
|
||||
|
||||
Future<void> updateIntervalSetting(int menit) async {
|
||||
await supabase.from('interval_setting').update({
|
||||
'durasi_menit': menit,
|
||||
'update_at': DateTime.now().toIso8601String(),
|
||||
}).eq('id', 1);
|
||||
}
|
||||
|
||||
// --- Gallery (foto_dataset) ---
|
||||
Future<List<Map<String, dynamic>>> getFotoDataset() async {
|
||||
final List<dynamic> data = await supabase
|
||||
.from('foto_dataset')
|
||||
.select()
|
||||
.order('created_at', ascending: false);
|
||||
return List<Map<String, dynamic>>.from(data);
|
||||
}
|
||||
|
||||
// --- Log Fan Status ---
|
||||
Future<void> logFanAction(String fan1, String fan2, double suhu, double hum, double lux) async {
|
||||
await supabase.from('sensor_log').insert({
|
||||
'kipas1': fan1,
|
||||
'kipas2': fan2,
|
||||
'suhu': suhu,
|
||||
'kelembapan': hum,
|
||||
'intensitas': lux,
|
||||
'timestamp': DateTime.now().toString().substring(0, 19),
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getLatestLog() async {
|
||||
final List<dynamic> data = await supabase
|
||||
.from('sensor_log')
|
||||
|
|
@ -105,4 +101,72 @@ class SupabaseService {
|
|||
.limit(1);
|
||||
return data.isNotEmpty ? data.first as Map<String, dynamic> : null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logFanAction(
|
||||
String fan1, String fan2, double suhu, double hum, double lux) async {
|
||||
await supabase.from('sensor_log').insert({
|
||||
'kipas1': fan1,
|
||||
'kipas2': fan2,
|
||||
'suhu': suhu,
|
||||
'kelembapan': hum,
|
||||
'intensitas': lux,
|
||||
'timestamp': DateTime.now().toString().substring(0, 19),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BATAS SENSOR (batas_sensor)
|
||||
// ============================================================
|
||||
|
||||
Future<Map<String, dynamic>?> getBatasSensor() async {
|
||||
final data = await supabase
|
||||
.from('batas_sensor')
|
||||
.select()
|
||||
.limit(1)
|
||||
.single();
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<void> updateBatasSensor(
|
||||
double suhuMin, double suhuMax, double rhMin, double rhMax) async {
|
||||
await supabase.from('batas_sensor').update({
|
||||
'suhu_min': suhuMin,
|
||||
'suhu_max': suhuMax,
|
||||
'rh_min': rhMin,
|
||||
'rh_max': rhMax,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
}).eq('id', 1);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// INTERVAL SETTING (interval_setting)
|
||||
// ============================================================
|
||||
|
||||
Future<int> getIntervalSetting() async {
|
||||
final data = await supabase
|
||||
.from('interval_setting')
|
||||
.select('durasi_menit')
|
||||
.limit(1)
|
||||
.single();
|
||||
return data['durasi_menit'] as int;
|
||||
}
|
||||
|
||||
Future<void> updateIntervalSetting(int menit) async {
|
||||
await supabase.from('interval_setting').update({
|
||||
'durasi_menit': menit,
|
||||
'update_at': DateTime.now().toIso8601String(),
|
||||
}).eq('id', 1);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GALLERY (foto_dataset)
|
||||
// ============================================================
|
||||
|
||||
Future<List<Map<String, dynamic>>> getFotoDataset() async {
|
||||
final List<dynamic> data = await supabase
|
||||
.from('foto_dataset')
|
||||
.select()
|
||||
.order('created_at', ascending: true);
|
||||
return List<Map<String, dynamic>>.from(data);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,20 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <open_file_linux/open_file_linux_plugin.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) gtk_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
|
||||
gtk_plugin_register_with_registrar(gtk_registrar);
|
||||
g_autoptr(FlPluginRegistrar) open_file_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin");
|
||||
open_file_linux_plugin_register_with_registrar(open_file_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) printing_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
|
||||
printing_plugin_register_with_registrar(printing_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
gtk
|
||||
open_file_linux
|
||||
printing
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@ import FlutterMacOS
|
|||
import Foundation
|
||||
|
||||
import app_links
|
||||
import open_file_mac
|
||||
import printing
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
|
||||
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
200
pubspec.lock
200
pubspec.lock
|
|
@ -33,6 +33,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -49,6 +57,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
barcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: barcode
|
||||
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.9"
|
||||
bidi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bidi
|
||||
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -97,6 +121,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -137,6 +169,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
excel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: excel
|
||||
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -169,6 +209,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -241,7 +289,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.0.3"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
|
@ -256,6 +304,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -356,10 +412,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "1.0.6"
|
||||
mqtt_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -392,6 +448,70 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
open_file:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: open_file
|
||||
sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.11"
|
||||
open_file_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_android
|
||||
sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
open_file_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_ios
|
||||
sha256: a5acd07ba1f304f807a97acbcc489457e1ad0aadff43c467987dd9eef814098f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
open_file_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_linux
|
||||
sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.5"
|
||||
open_file_mac:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_mac
|
||||
sha256: cd293f6750de6438ab2390513c99128ade8c974825d4d8128886d1cda8c64d01
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
open_file_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_platform_interface
|
||||
sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
open_file_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_web
|
||||
sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
open_file_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_windows
|
||||
sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -408,8 +528,16 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
|
|
@ -456,6 +584,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdf
|
||||
sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.12.0"
|
||||
pdf_widget_wrapper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pdf_widget_wrapper
|
||||
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -488,6 +640,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
printing:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: printing
|
||||
sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.3"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -504,6 +664,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
realtime_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -536,6 +704,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
share_plus_platform_interface:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -741,6 +917,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -797,6 +981,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -37,9 +37,16 @@ dependencies:
|
|||
fl_chart: ^1.2.0
|
||||
intl: ^0.20.2
|
||||
shared_preferences: ^2.5.5
|
||||
pdf: ^3.10.8
|
||||
printing: ^5.12.0
|
||||
http: ^1.2.0
|
||||
excel: ^4.0.3
|
||||
open_file: ^3.3.2
|
||||
path_provider: ^2.1.2
|
||||
|
||||
dependency_overrides:
|
||||
win32: ^6.1.0
|
||||
share_plus_platform_interface: ^3.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <app_links/app_links_plugin_c_api.h>
|
||||
#include <printing/printing_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
PrintingPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PrintingPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
printing
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue