TKK_E32230211/lib/app/modules/home/controllers/home_controller.dart

715 lines
23 KiB
Dart

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 = <HistoryItem>[].obs;
final _pinController = TextEditingController();
final _editNominalController = TextEditingController();
final _editTargetController = TextEditingController();
final _pinWidgetKey = GlobalKey<NumpadPinInputWidgetState>();
int _pinAttempts = 0;
static const int _maxPinAttempts = 3;
StreamSubscription<DatabaseEvent>? _detectedValueSub;
StreamSubscription<DatabaseEvent>? _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<NavbarController>().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<void> _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<void> _loadData() async {
isLoading.value = true;
await _loadProfil();
await _loadTransaksi();
isLoading.value = false;
}
Future<void> _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<void> _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<String, dynamic>,
),
)
.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<void> _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<void> _tampilkanVerifikasiPassword(BuildContext context) async {
final emailController = TextEditingController();
final passwordController = TextEditingController();
final verified = await showDialog<bool>(
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<void> _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<void> refreshData() => _loadData();
Future<void> 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();
}
}