import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_database/firebase_database.dart'; import 'package:intl/intl.dart'; import 'package:tabungankuy/app/modules/navbar/controllers/navbar_controller.dart'; import '../../../models/profil_model.dart'; import '../../../models/transaction_model.dart'; import '../../../models/target_emoji_map.dart'; import '../../../routes/app_pages.dart'; import '../../../services/notification_service.dart'; import '../../../widgets/open_celengan_modal.dart'; import '../../../widgets/numpad_pin_widget.dart'; import '../../../widgets/edit_target_modal.dart'; import '../../../widgets/konfirmasi_uang_modal.dart'; class HistoryItem { final String amount; final String date; final String percent; final String tipe; const HistoryItem({ required this.amount, required this.date, required this.percent, required this.tipe, }); } class HomeController extends GetxController { final _auth = FirebaseAuth.instance; final _firestore = FirebaseFirestore.instance; final _rtdb = FirebaseDatabase.instance.ref(); final namaUser = ''.obs; final targetEmoji = 'đŸŽ¯'.obs; final targetLabel = ''.obs; final targetPercent = 0.0.obs; final targetNominal = 0.obs; final totalSaldo = 0.obs; final totalUang = 'Rp 0'.obs; final isSaving = true.obs; final isLoading = true.obs; final recentHistory = [].obs; final _pinController = TextEditingController(); final _editNominalController = TextEditingController(); final _editTargetController = TextEditingController(); final _pinWidgetKey = GlobalKey(); int _pinAttempts = 0; static const int _maxPinAttempts = 3; StreamSubscription? _detectedValueSub; StreamSubscription? _masukkanUangSub; bool _konfirmasiModalTampil = false; bool _pintuTerbuka = false; bool _sudahNotifTargetTercapai = false; bool _pinModalTerbuka = false; Timer? _konfirmasiResetTimer; int? _pendingNominal; String get _uid => _auth.currentUser?.uid ?? ''; final _currencyFormat = NumberFormat.currency( locale: 'id_ID', symbol: 'Rp ', decimalDigits: 0, ); @override void onInit() { super.onInit(); NotificationService.requestPermission(); _loadData(); _listenMasukkanUang(); _listenDetectedValue(); } void _listenMasukkanUang() { _masukkanUangSub = _rtdb.child('tabungan/masukkan_uang').onValue.listen(( event, ) { final sebelumnya = _pintuTerbuka; _pintuTerbuka = event.snapshot.value == true; if (sebelumnya && !_pintuTerbuka) _cobaTampilkanKonfirmasi(); }); } void _listenDetectedValue() { _detectedValueSub = _rtdb.child('tabungan/detected_value').onValue.listen(( event, ) { final value = event.snapshot.value; if (value != null && value is int && value > 0) { _pendingNominal = value; _cobaTampilkanKonfirmasi(); } }); } void _cobaTampilkanKonfirmasi() { if (_pendingNominal == null || _pendingNominal! <= 0) return; if (_pintuTerbuka || _konfirmasiModalTampil) return; final context = Get.context; if (context == null || !context.mounted) return; _tampilkanKonfirmasiUang(context, _pendingNominal!); } void _tampilkanKonfirmasiUang(BuildContext context, int nominal) { _konfirmasiModalTampil = true; _pendingNominal = null; KonfirmasiUangModal.show( context, nominal: nominal, onKonfirmasi: () async { Navigator.of(context, rootNavigator: true).pop(); _konfirmasiModalTampil = false; _konfirmasiResetTimer?.cancel(); await _rtdb.child('tabungan').update({ 'konfirmasi_masuk': true, 'detected_value': 0, }); _konfirmasiResetTimer = Timer(const Duration(seconds: 5), () async { if (!isClosed) { await _rtdb.child('tabungan').update({'konfirmasi_masuk': false}); } }); await _simpanTransaksiMasuk(nominal); }, onBatal: () async { Navigator.of(context, rootNavigator: true).pop(); _konfirmasiModalTampil = false; _konfirmasiResetTimer?.cancel(); await _rtdb.child('tabungan').update({ 'konfirmasi_masuk': false, 'detected_value': 0, 'masukkan_uang': true, }); _pintuTerbuka = true; Get.find().isIotActive.value = true; Get.snackbar( 'â†Šī¸ Dibatalkan', 'Pintu dibuka kembali, silakan coba lagi', snackPosition: SnackPosition.TOP, backgroundColor: Colors.orange.shade50, colorText: Colors.orange.shade800, duration: const Duration(seconds: 3), ); }, ); } Future _simpanTransaksiMasuk(int nominal) async { try { await _firestore.collection('transactions').add({ 'userId': _uid, 'nominal': nominal, 'tipe': 'masuk', 'createdAt': FieldValue.serverTimestamp(), }); await _loadData(); Get.snackbar( '✅ Berhasil!', 'Berhasil menabung ${_currencyFormat.format(nominal)}', snackPosition: SnackPosition.TOP, backgroundColor: Colors.green.shade100, colorText: Colors.green.shade800, duration: const Duration(seconds: 3), ); } catch (e) { Get.snackbar( 'Error', 'Gagal menyimpan transaksi: $e', snackPosition: SnackPosition.TOP, ); } } Future _loadData() async { isLoading.value = true; await _loadProfil(); await _loadTransaksi(); isLoading.value = false; } Future _loadProfil() async { try { if (_uid.isEmpty) return; final doc = await _firestore.collection('profil').doc(_uid).get(); if (!doc.exists) return; final profil = ProfilModel.fromMap(doc.data()!); namaUser.value = profil.nama; targetLabel.value = profil.target; targetEmoji.value = TargetEmojiMap.getEmoji(profil.target); targetNominal.value = profil.targetNominal; } catch (e) { Get.snackbar( 'Error', 'Gagal memuat profil: $e', snackPosition: SnackPosition.TOP, ); } } Future _loadTransaksi() async { try { if (_uid.isEmpty) return; QuerySnapshot snapshot; try { snapshot = await _firestore .collection('transactions') .where('userId', isEqualTo: _uid) .orderBy('createdAt', descending: true) .get(); } catch (_) { snapshot = await _firestore .collection('transactions') .where('userId', isEqualTo: _uid) .get(); } final docs = snapshot.docs.toList() ..sort((a, b) { final aTime = (a.data() as Map)['createdAt'] as Timestamp?; final bTime = (b.data() as Map)['createdAt'] as Timestamp?; if (aTime == null && bTime == null) return 0; if (aTime == null) return 1; if (bTime == null) return -1; return bTime.compareTo(aTime); }); final transactions = docs .map( (doc) => TransactionModel.fromMap( doc.id, doc.data() as Map, ), ) .toList(); int saldo = 0; for (final t in transactions) { saldo += t.tipe == 'masuk' ? t.nominal : -t.nominal; } totalSaldo.value = saldo; totalUang.value = _currencyFormat.format(saldo); final persenSebelumnya = targetPercent.value; _hitungPersen(); _cekDanKirimNotifikasiTarget(persenSebelumnya); recentHistory.value = transactions.take(2).map((t) { final dateStr = t.createdAt != null ? DateFormat('hh:mm a dd MMM yyyy', 'id_ID').format(t.createdAt!) : '-'; final persen = targetNominal.value > 0 ? '${((t.nominal / targetNominal.value) * 100).toStringAsFixed(1)}%' : '0%'; return HistoryItem( amount: _currencyFormat.format(t.nominal), date: dateStr, percent: persen, tipe: t.tipe, ); }).toList(); } catch (e) { Get.snackbar( 'Error', 'Gagal memuat transaksi: $e', snackPosition: SnackPosition.TOP, ); } } void _hitungPersen() { targetPercent.value = targetNominal.value > 0 ? ((totalSaldo.value / targetNominal.value) * 100).clamp(0.0, 100.0) : 0.0; } void _cekDanKirimNotifikasiTarget(double persenSebelumnya) { final sudahTercapai = targetPercent.value >= 100.0; if (sudahTercapai && (persenSebelumnya < 100.0 || !_sudahNotifTargetTercapai)) { if (!_sudahNotifTargetTercapai) { _sudahNotifTargetTercapai = true; NotificationService.showTargetTercapai( namaTarget: targetLabel.value, nominal: _currencyFormat.format(targetNominal.value), ); } } if (!sudahTercapai) _sudahNotifTargetTercapai = false; } void toggleSaving() => isSaving.value = !isSaving.value; void bukaCelengan(BuildContext context) { OpenCelenganModal.show( context, targetTercapai: targetPercent.value >= 100.0, onKonfirmasiBuka: () => _tampilkanInputPin(context), ); } void _tampilkanInputPin(BuildContext context) { _pinController.clear(); _pinAttempts = 0; _pinModalTerbuka = true; showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, isDismissible: false, enableDrag: false, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(24)), ), builder: (sheetContext) => NumpadPinInputWidget( key: _pinWidgetKey, displayController: _pinController, onKey: _onPinKey, onSimpan: () => _validasiPin(context), onVerifikasiPassword: () => _tampilkanVerifikasiPassword(context), ), ).whenComplete(() => _pinModalTerbuka = false); } Future _validasiPin(BuildContext context) async { final pin = _pinController.text; try { if (_uid.isEmpty) return; final doc = await _firestore.collection('profil').doc(_uid).get(); if (!doc.exists) { _pinWidgetKey.currentState?.showError('Data profil tidak ditemukan'); return; } final savedPin = (doc.data()?['pin'] ?? '').toString(); if (pin != savedPin) { _pinAttempts++; final sisaPercobaan = _maxPinAttempts - _pinAttempts; if (_pinAttempts >= _maxPinAttempts) { _pinWidgetKey.currentState?.showError( 'PIN salah 3x, verifikasi dengan password akun', showVerifikasiPassword: true, ); } else { _pinWidgetKey.currentState?.showError( 'PIN salah ($sisaPercobaan percobaan tersisa)', ); } return; } _pinAttempts = 0; await _rtdb.child('tabungan').update({'buka_tabungan': true}); Get.back(); await Future.delayed(const Duration(milliseconds: 350)); await _simpanTransaksiAmbil(totalSaldo.value); } catch (_) { _pinWidgetKey.currentState?.showError('Terjadi kesalahan, coba lagi'); } } Future _tampilkanVerifikasiPassword(BuildContext context) async { final emailController = TextEditingController(); final passwordController = TextEditingController(); final verified = await showDialog( context: context, barrierDismissible: false, builder: (dialogContext) { String errorMsg = ''; bool isLoading = false; return StatefulBuilder( builder: (ctx, setStateDialog) => Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), backgroundColor: Colors.white, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Verifikasi Akun', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87, ), ), const SizedBox(height: 8), const Text( 'Masukkan email dan password akun untuk membuka celengan', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: Colors.black54), ), const SizedBox(height: 20), TextField( controller: emailController, keyboardType: TextInputType.emailAddress, autofocus: true, decoration: InputDecoration( hintText: 'Email akun', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), ), ), const SizedBox(height: 12), TextField( controller: passwordController, obscureText: true, decoration: InputDecoration( hintText: 'Password akun', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), ), ), if (errorMsg.isNotEmpty) ...[ const SizedBox(height: 10), Text( errorMsg, style: TextStyle( color: Colors.red.shade600, fontSize: 13, fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), ], const SizedBox(height: 20), Row( children: [ Expanded( child: OutlinedButton( style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Batal'), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF5B9BD5), padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), ), onPressed: isLoading ? null : () async { final email = emailController.text.trim(); final password = passwordController.text .trim(); if (email.isEmpty || password.isEmpty) { setStateDialog(() { errorMsg = 'Email dan password tidak boleh kosong'; }); return; } setStateDialog(() { isLoading = true; errorMsg = ''; }); try { final user = _auth.currentUser; if (user == null) { throw Exception('User tidak ditemukan'); } final credential = EmailAuthProvider.credential( email: email, password: password, ); await user.reauthenticateWithCredential( credential, ); Navigator.of(dialogContext).pop(true); } on FirebaseAuthException catch (e) { setStateDialog(() { isLoading = false; errorMsg = switch (e.code) { 'wrong-password' || 'invalid-credential' => 'Email atau password salah, coba lagi', 'user-mismatch' => 'Email tidak sesuai dengan akun ini', 'invalid-email' => 'Format email tidak valid', 'too-many-requests' => 'Terlalu banyak percobaan, coba lagi nanti', _ => 'Gagal verifikasi: ${e.message}', }; }); } catch (_) { setStateDialog(() { isLoading = false; errorMsg = 'Terjadi kesalahan, coba lagi'; }); } }, child: isLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text( 'Verifikasi', style: TextStyle(color: Colors.white), ), ), ), ], ), ], ), ), ), ); }, ); emailController.dispose(); passwordController.dispose(); if (verified != true) return; _pinAttempts = 0; if (_pinModalTerbuka) { Get.back(); await Future.delayed(const Duration(milliseconds: 400)); } await _rtdb.child('tabungan').update({'buka_tabungan': true}); await _simpanTransaksiAmbil(totalSaldo.value); } Future _simpanTransaksiAmbil(int jumlah) async { try { await _firestore.collection('transactions').add({ 'userId': _uid, 'nominal': jumlah, 'tipe': 'keluar', 'createdAt': FieldValue.serverTimestamp(), }); Future.delayed(const Duration(seconds: 10), () async { if (!isClosed) { await _rtdb.child('tabungan').update({'buka_tabungan': false}); } }); await _loadData(); Get.snackbar( '✅ Berhasil!', 'Berhasil mengambil ${_currencyFormat.format(jumlah)} dari celengan', snackPosition: SnackPosition.TOP, backgroundColor: Colors.green.shade100, colorText: Colors.green.shade800, duration: const Duration(seconds: 3), ); await Future.delayed(const Duration(milliseconds: 400)); final navContext = Get.context; if (navContext != null && navContext.mounted) { editTarget(navContext); } } catch (e) { Get.snackbar( 'Error', 'Gagal menyimpan transaksi: $e', snackPosition: SnackPosition.TOP, ); } } void editTarget(BuildContext context) { _editNominalController.text = targetNominal.value.toString(); _editTargetController.text = targetLabel.value; EditTargetModal.show( context, nominalController: _editNominalController, targetController: _editTargetController, onNominalKey: _onEditNominalKey, onSimpan: () async { final newTarget = _editTargetController.text; final newNominal = int.tryParse( _editNominalController.text.replaceAll(RegExp(r'[^0-9]'), ''), ) ?? 0; await _firestore.collection('profil').doc(_uid).update({ 'target': newTarget, 'targetNominal': newNominal, }); targetLabel.value = newTarget; targetEmoji.value = TargetEmojiMap.getEmoji(newTarget); targetNominal.value = newNominal; _hitungPersen(); await _loadTransaksi(); Get.back(); }, ); } Future refreshData() => _loadData(); Future logout() async { _detectedValueSub?.cancel(); _masukkanUangSub?.cancel(); _konfirmasiResetTimer?.cancel(); await _rtdb.child('tabungan').update({ 'masukkan_uang': false, 'konfirmasi_masuk': false, 'buka_tabungan': false, 'detected_value': 0, }); await _auth.signOut(); Get.offAllNamed(Routes.LOGIN); } void _onEditNominalKey(String key) { if (key == 'del') { if (_editNominalController.text.isNotEmpty) { _editNominalController.text = _editNominalController.text.substring( 0, _editNominalController.text.length - 1, ); } } else { _editNominalController.text += key; } } void _onPinKey(String key) { if (key == 'del') { if (_pinController.text.isNotEmpty) { _pinController.text = _pinController.text.substring( 0, _pinController.text.length - 1, ); } } else if (_pinController.text.length < 6) { _pinController.text += key; } } @override void onClose() { _detectedValueSub?.cancel(); _masukkanUangSub?.cancel(); _konfirmasiResetTimer?.cancel(); _pinController.dispose(); _editNominalController.dispose(); _editTargetController.dispose(); super.onClose(); } }