first commit

This commit is contained in:
Andreas Malvino
2025-06-02 22:39:03 +07:00
commit e7090af3da
245 changed files with 49210 additions and 0 deletions

View File

@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
class AuthController extends GetxController {
final AuthProvider _authProvider = Get.find<AuthProvider>();
final emailController = TextEditingController();
final passwordController = TextEditingController();
// Form fields for registration
final RxString email = ''.obs;
final RxString password = ''.obs;
final RxString nik = ''.obs;
final RxString phoneNumber = ''.obs;
final RxString selectedRole = 'WARGA'.obs; // Default role
// Form status
final RxBool isLoading = false.obs;
final RxBool isPasswordVisible = false.obs;
final RxString errorMessage = ''.obs;
// Role options
final List<String> roleOptions = ['WARGA', 'PETUGAS_MITRA'];
void togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
// Change role selection
void setRole(String? role) {
if (role != null) {
selectedRole.value = role;
}
}
void login() async {
// Clear previous error messages
errorMessage.value = '';
// Basic validation
if (emailController.text.isEmpty || passwordController.text.isEmpty) {
errorMessage.value = 'Email dan password tidak boleh kosong';
return;
}
if (!GetUtils.isEmail(emailController.text.trim())) {
errorMessage.value = 'Format email tidak valid';
return;
}
try {
isLoading.value = true;
// Use the actual Supabase authentication
final response = await _authProvider.signIn(
email: emailController.text.trim(),
password: passwordController.text,
);
// Check if login was successful
if (response.user != null) {
await _checkRoleAndNavigate();
} else {
errorMessage.value = 'Login gagal. Periksa email dan password Anda.';
}
} catch (e) {
errorMessage.value = 'Terjadi kesalahan: ${e.toString()}';
} finally {
isLoading.value = false;
}
}
Future<void> _checkRoleAndNavigate() async {
try {
// Get the user's role ID from the auth provider
final roleId = await _authProvider.getUserRoleId();
if (roleId == null) {
errorMessage.value = 'Tidak dapat memperoleh peran pengguna';
return;
}
// Get role name based on role ID
final roleName = await _authProvider.getRoleName(roleId);
// Navigate based on role name
if (roleName == null) {
_navigateToWargaDashboard(); // Default to warga if role name not found
return;
}
switch (roleName.toUpperCase()) {
case 'PETUGAS_BUMDES':
_navigateToPetugasBumdesDashboard();
break;
case 'WARGA':
default:
_navigateToWargaDashboard();
break;
}
} catch (e) {
errorMessage.value = 'Gagal navigasi: ${e.toString()}';
}
}
void _navigateToPetugasBumdesDashboard() {
Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD);
}
void _navigateToWargaDashboard() {
Get.offAllNamed(Routes.WARGA_DASHBOARD);
}
void forgotPassword() async {
// Clear previous error messages
errorMessage.value = '';
// Basic validation
if (emailController.text.isEmpty) {
errorMessage.value = 'Email tidak boleh kosong';
return;
}
if (!GetUtils.isEmail(emailController.text.trim())) {
errorMessage.value = 'Format email tidak valid';
return;
}
try {
isLoading.value = true;
// Call Supabase to send password reset email
await _authProvider.client.auth.resetPasswordForEmail(
emailController.text.trim(),
);
// Show success message
Get.snackbar(
'Berhasil',
'Link reset password telah dikirim ke email Anda',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green[100],
colorText: Colors.green[800],
icon: const Icon(Icons.check_circle, color: Colors.green),
);
// Return to login page after a short delay
await Future.delayed(const Duration(seconds: 2));
Get.back();
} catch (e) {
errorMessage.value = 'Terjadi kesalahan: ${e.toString()}';
} finally {
isLoading.value = false;
}
}
void goToSignUp() {
// Clear error message when navigating away
errorMessage.value = '';
Get.toNamed(Routes.REGISTER);
}
void goToForgotPassword() {
// Clear error message when navigating away
errorMessage.value = '';
Get.toNamed(Routes.FORGOT_PASSWORD);
}
@override
void onClose() {
emailController.dispose();
passwordController.dispose();
super.onClose();
}
// Register user implementation
Future<void> registerUser() async {
// Validate all required fields
if (email.value.isEmpty ||
password.value.isEmpty ||
nik.value.isEmpty ||
phoneNumber.value.isEmpty) {
errorMessage.value = 'Semua field harus diisi';
return;
}
// Basic validation for email
if (!GetUtils.isEmail(email.value.trim())) {
errorMessage.value = 'Format email tidak valid';
return;
}
// Basic validation for password
if (password.value.length < 6) {
errorMessage.value = 'Password minimal 6 karakter';
return;
}
// Basic validation for NIK
if (nik.value.length != 16) {
errorMessage.value = 'NIK harus 16 digit';
return;
}
// Basic validation for phone number
if (!phoneNumber.value.startsWith('08') || phoneNumber.value.length < 10) {
errorMessage.value =
'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)';
return;
}
try {
isLoading.value = true;
errorMessage.value = '';
// Create user with Supabase
final response = await _authProvider.signUp(
email: email.value.trim(),
password: password.value,
data: {
'nik': nik.value.trim(),
'phone_number': phoneNumber.value.trim(),
'role': selectedRole.value,
},
);
if (response.user != null) {
// Registration successful
Get.offNamed(Routes.REGISTRATION_SUCCESS);
} else {
errorMessage.value = 'Gagal mendaftar. Silakan coba lagi.';
}
} catch (e) {
errorMessage.value = 'Terjadi kesalahan: ${e.toString()}';
print('Registration error: ${e.toString()}');
} finally {
isLoading.value = false;
}
}
}

View File

@ -0,0 +1,376 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/auth_controller.dart';
import '../../../theme/app_colors.dart';
class ForgotPasswordView extends GetView<AuthController> {
const ForgotPasswordView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// Background gradient
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.primarySoft, AppColors.background],
),
),
),
// Background pattern
Opacity(
opacity: 0.03,
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
),
),
),
// Accent circle
Positioned(
top: -100,
right: -80,
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.primary.withOpacity(0.2),
Colors.transparent,
],
),
),
),
),
// Main content
SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back button
Padding(
padding: const EdgeInsets.all(16.0),
child: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
color: AppColors.primary,
onPressed: () => Get.back(),
),
),
// Scrollable content
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 16),
_buildHeader(),
const SizedBox(height: 40),
_buildEmailField(),
const SizedBox(height: 32),
_buildResetButton(),
const SizedBox(height: 40),
_buildImportantInfo(),
const SizedBox(height: 24),
_buildBackToLoginLink(),
const SizedBox(height: 24),
],
),
),
),
),
],
),
),
],
),
);
}
Widget _buildHeader() {
return Column(
children: [
// Floating lock icon with animation effect
Container(
width: 110,
height: 110,
decoration: BoxDecoration(
color: AppColors.surface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.2),
blurRadius: 20,
spreadRadius: 5,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: Icon(
Icons.lock_open_rounded,
size: 50,
color: AppColors.primary,
),
),
),
const SizedBox(height: 32),
Text(
'Lupa Password?',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Masukkan email Anda di bawah ini dan kami akan mengirimkan link untuk reset password.',
style: TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
height: 1.5,
),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildEmailField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4.0, bottom: 8.0),
child: Text(
'Email',
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
fontSize: 15,
),
),
),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: TextField(
controller: controller.emailController,
keyboardType: TextInputType.emailAddress,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Masukkan email Anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(
Icons.email_outlined,
color: AppColors.iconGrey,
size: 22,
),
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
),
// Error message
Obx(
() =>
controller.errorMessage.value.isNotEmpty
? Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.errorMessage.value,
style: TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
);
}
Widget _buildResetButton() {
return Obx(
() => SizedBox(
height: 56,
child: ElevatedButton(
onPressed:
controller.isLoading.value ? null : controller.forgotPassword,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2,
shadowColor: AppColors.primary.withOpacity(0.4),
disabledBackgroundColor: AppColors.primary.withOpacity(0.6),
),
child:
controller.isLoading.value
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Kirim Link Reset',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
const SizedBox(width: 8),
const Icon(Icons.send_rounded, size: 18),
],
),
),
),
);
}
Widget _buildImportantInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.green.shade100),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.info_outline,
size: 20,
color: Colors.green.shade700,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informasi Penting',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.green.shade800,
),
),
const SizedBox(height: 4),
Text(
'Petunjuk reset password akan dikirim ke email Anda. Silakan periksa kotak masuk atau folder spam setelah permintaan reset password.',
style: TextStyle(
fontSize: 13,
color: Colors.green.shade900,
height: 1.4,
),
),
],
),
),
],
),
);
}
Widget _buildBackToLoginLink() {
return Center(
child: TextButton.icon(
onPressed: () => Get.back(),
icon: Icon(
Icons.arrow_back_rounded,
size: 16,
color: AppColors.primary,
),
label: Text(
'Kembali ke Login',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
);
}
}

View File

@ -0,0 +1,368 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/auth_controller.dart';
import '../../../theme/app_colors.dart';
class LoginView extends GetView<AuthController> {
const LoginView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// Background gradient
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [
AppColors.primaryLight.withOpacity(0.1),
AppColors.background,
AppColors.accentLight.withOpacity(0.1),
],
),
),
),
// Pattern overlay
Opacity(
opacity: 0.03,
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
),
),
),
// Accent circles
Positioned(
top: -40,
right: -20,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.primary.withOpacity(0.2),
Colors.transparent,
],
),
),
),
),
Positioned(
bottom: -50,
left: -30,
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.accent.withOpacity(0.2),
Colors.transparent,
],
),
),
),
),
// Main content
SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 50),
_buildHeader(),
const SizedBox(height: 40),
_buildLoginCard(),
const SizedBox(height: 24),
_buildRegisterLink(),
const SizedBox(height: 30),
],
),
),
),
),
],
),
);
}
Widget _buildHeader() {
return Center(
child: Hero(
tag: 'logo',
child: Image.asset(
'assets/images/logo.png',
width: 220,
height: 220,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.apartment_rounded,
size: 180,
color: AppColors.primary,
);
},
),
),
);
}
Widget _buildLoginCard() {
return Card(
elevation: 4,
shadowColor: AppColors.shadow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(28.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Welcome text
Text(
'Selamat Datang',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'Masuk untuk melanjutkan ke akun Anda',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 32),
// Email field
_buildInputLabel('Email'),
const SizedBox(height: 8),
_buildTextField(
controller: controller.emailController,
hintText: 'Masukkan email Anda',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 24),
// Password field
_buildInputLabel('Password'),
const SizedBox(height: 8),
Obx(
() => _buildTextField(
controller: controller.passwordController,
hintText: 'Masukkan password Anda',
prefixIcon: Icons.lock_outline,
obscureText: !controller.isPasswordVisible.value,
suffixIcon: IconButton(
icon: Icon(
controller.isPasswordVisible.value
? Icons.visibility
: Icons.visibility_off,
color: AppColors.iconGrey,
),
onPressed: controller.togglePasswordVisibility,
),
),
),
// Forgot password
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => controller.goToForgotPassword(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 0,
vertical: 8,
),
),
child: Text(
'Lupa sandi?',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 32),
// Login button
Obx(
() => SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed:
controller.isLoading.value ? null : controller.login,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.buttonText,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: controller.isLoading.value ? 0 : 2,
shadowColor: AppColors.primary.withOpacity(0.4),
),
child:
controller.isLoading.value
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Masuk',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
const SizedBox(width: 8),
const Icon(Icons.arrow_forward, size: 18),
],
),
),
),
),
// Error message
Obx(
() =>
controller.errorMessage.value.isNotEmpty
? Container(
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.errorMessage.value,
style: TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
);
}
Widget _buildInputLabel(String label) {
return Text(
label,
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
fontSize: 15,
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String hintText,
required IconData prefixIcon,
TextInputType keyboardType = TextInputType.text,
bool obscureText = false,
Widget? suffixIcon,
}) {
return TextField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(prefixIcon, color: AppColors.iconGrey, size: 22),
suffixIcon: suffixIcon,
filled: true,
fillColor: AppColors.inputBackground,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
);
}
Widget _buildRegisterLink() {
return Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"Belum punya akun?",
style: TextStyle(color: AppColors.textSecondary),
),
TextButton(
onPressed: controller.goToSignUp,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
child: Text(
'Daftar',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors.dart';
class RegistrationSuccessView extends StatefulWidget {
const RegistrationSuccessView({Key? key}) : super(key: key);
@override
State<RegistrationSuccessView> createState() =>
_RegistrationSuccessViewState();
}
class _RegistrationSuccessViewState extends State<RegistrationSuccessView>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
_scaleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.elasticOut),
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: const Interval(0.4, 1.0, curve: Curves.easeInOut),
),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Stack(
children: [
// Background elements
Positioned(
top: -120,
left: -120,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.successLight,
),
),
),
Positioned(
right: -80,
bottom: 100,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primaryLight.withOpacity(0.2),
),
),
),
// Confetti particles
Positioned.fill(child: _buildConfettiParticles()),
// Main content
Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSuccessAnimation(),
const SizedBox(height: 40),
_buildSuccessMessage(),
const SizedBox(height: 40),
_buildBackToLoginButton(),
],
),
),
],
),
),
);
}
Widget _buildConfettiParticles() {
return Stack(
children: List.generate(20, (index) {
final left = (index * 20) % MediaQuery.of(context).size.width;
final top = (index * 30) % MediaQuery.of(context).size.height;
final size = 8.0 + (index % 5) * 2;
final colors = [
AppColors.success,
AppColors.primary,
AppColors.accent,
AppColors.primaryLight,
];
return Positioned(
left: left.toDouble(),
top: top.toDouble(),
child: AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
final delay = index * 0.1;
final startTime = delay;
final endTime = startTime + 0.8;
double opacity = 0.0;
if (_animationController.value >= startTime) {
opacity =
(_animationController.value - startTime) /
(endTime - startTime);
if (opacity > 1.0) opacity = 1.0;
}
return Opacity(
opacity: opacity,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colors[index % colors.length],
shape:
index % 2 == 0 ? BoxShape.circle : BoxShape.rectangle,
borderRadius:
index % 2 == 0 ? null : BorderRadius.circular(2),
),
),
);
},
),
);
}),
);
}
Widget _buildSuccessAnimation() {
return Center(
child: Column(
children: [
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Hero(
tag: 'success',
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppColors.success,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.success.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: const Icon(
Icons.check,
size: 70,
color: Colors.white,
),
),
),
);
},
),
],
),
);
}
Widget _buildSuccessMessage() {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Column(
children: [
Text(
'Pendaftaran Berhasil!',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Akun Anda telah berhasil terdaftar. Silakan masuk dengan email dan password yang telah Anda daftarkan.',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
height: 1.5,
),
textAlign: TextAlign.center,
),
),
],
),
);
},
);
}
Widget _buildBackToLoginButton() {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: ElevatedButton(
onPressed: () {
// Navigate back to login page
Get.offAllNamed('/login');
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.buttonText,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
elevation: 0,
),
child: const Text(
'Masuk Sekarang',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
),
);
},
);
}
}

View File

@ -0,0 +1,549 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/auth_controller.dart';
import '../../../theme/app_colors.dart';
class RegistrationView extends GetView<AuthController> {
const RegistrationView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Stack(
children: [
// Background gradient
Positioned(
top: -100,
right: -100,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.primaryLight.withOpacity(0.2),
AppColors.background.withOpacity(0),
],
stops: const [0.0, 1.0],
),
),
),
),
Positioned(
bottom: -80,
left: -80,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.accent.withOpacity(0.15),
AppColors.background.withOpacity(0),
],
stops: const [0.0, 1.0],
),
),
),
),
// Content
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildBackButton(),
const SizedBox(height: 20),
_buildHeader(),
const SizedBox(height: 24),
_buildRegistrationForm(),
const SizedBox(height: 32),
_buildRegisterButton(),
const SizedBox(height: 24),
_buildImportantInfo(),
const SizedBox(height: 24),
_buildLoginLink(),
],
),
),
),
],
),
),
);
}
Widget _buildBackButton() {
return Align(
alignment: Alignment.topLeft,
child: InkWell(
onTap: () => Get.back(),
borderRadius: BorderRadius.circular(50),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.surface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.arrow_back,
size: 20,
color: AppColors.primary,
),
),
),
);
}
Widget _buildHeader() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Hero(
tag: 'logo',
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.primarySoft,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Icon(
Icons.apartment_rounded,
size: 40,
color: AppColors.primary,
),
),
),
const SizedBox(height: 24),
Text(
'Daftar Akun',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 10),
Text(
'Lengkapi data berikut untuk mendaftar',
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildImportantInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.warningLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.warning.withOpacity(0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.warning.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(Icons.info_outline, size: 20, color: AppColors.warning),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Informasi Penting',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.warning,
),
),
const SizedBox(height: 4),
Text(
'Pendaftaran hanya dapat dilakukan oleh warga dan mitra yang sudah terverivikasi. Silahkan hubungi petugas atau kunjungi kantor untuk informasi lebih lanjut.',
style: TextStyle(
fontSize: 13,
color: AppColors.textPrimary,
height: 1.4,
),
),
],
),
),
],
),
);
}
Widget _buildRegistrationForm() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Email Input
_buildInputLabel('Email'),
const SizedBox(height: 8),
_buildEmailField(),
const SizedBox(height: 20),
// Password Input
_buildInputLabel('Password'),
const SizedBox(height: 8),
_buildPasswordField(),
const SizedBox(height: 20),
// NIK Input
_buildInputLabel('NIK'),
const SizedBox(height: 8),
_buildNikField(),
const SizedBox(height: 20),
// Phone Number Input
_buildInputLabel('No. Hp'),
const SizedBox(height: 8),
_buildPhoneField(),
const SizedBox(height: 20),
// Role Selection Dropdown
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Daftar Sebagai',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!, width: 1),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Obx(
() => DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: controller.selectedRole.value,
hint: const Text('Pilih Peran'),
items: [
DropdownMenuItem(
value: 'WARGA',
child: const Text('Warga'),
),
DropdownMenuItem(
value: 'PETUGAS_MITRA',
child: const Text('Mitra'),
),
],
onChanged: (value) {
controller.setRole(value);
},
icon: const Icon(Icons.arrow_drop_down),
style: const TextStyle(
color: Colors.black87,
fontSize: 14,
),
),
),
),
),
),
],
),
const SizedBox(height: 20),
// Error message
Obx(
() =>
controller.errorMessage.value.isNotEmpty
? Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
controller.errorMessage.value,
style: TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
);
}
Widget _buildInputLabel(String label) {
return Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
);
}
Widget _buildEmailField() {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => controller.email.value = value,
keyboardType: TextInputType.emailAddress,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Masukkan email anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(Icons.email_outlined, color: AppColors.primary),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
);
}
Widget _buildPasswordField() {
return Obx(
() => Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => controller.password.value = value,
obscureText: !controller.isPasswordVisible.value,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Masukkan password anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(Icons.lock_outlined, color: AppColors.primary),
suffixIcon: IconButton(
icon: Icon(
controller.isPasswordVisible.value
? Icons.visibility
: Icons.visibility_off,
color: AppColors.iconGrey,
),
onPressed: controller.togglePasswordVisibility,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
),
);
}
Widget _buildNikField() {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => controller.nik.value = value,
keyboardType: TextInputType.number,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Masukkan NIK anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(
Icons.credit_card_outlined,
color: AppColors.primary,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
);
}
Widget _buildPhoneField() {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.shadow,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) => controller.phoneNumber.value = value,
keyboardType: TextInputType.phone,
style: TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: 'Masukkan nomor HP anda',
hintStyle: TextStyle(color: AppColors.textLight),
prefixIcon: Icon(Icons.phone_outlined, color: AppColors.primary),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
);
}
Widget _buildRegisterButton() {
return Obx(
() => ElevatedButton(
onPressed: controller.isLoading.value ? null : controller.registerUser,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.buttonText,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
disabledBackgroundColor: AppColors.primary.withOpacity(0.6),
),
child:
controller.isLoading.value
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Daftar',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
);
}
Widget _buildLoginLink() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Sudah punya akun? ',
style: TextStyle(color: AppColors.textSecondary, fontSize: 14),
),
GestureDetector(
onTap: () {
Get.back(); // Back to login page
},
child: Text(
'Masuk',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
);
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/list_pelanggan_aktif_controller.dart';
class ListPelangganAktifBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ListPelangganAktifController>(
() => ListPelangganAktifController(),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/list_petugas_mitra_controller.dart';
class ListPetugasMitraBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ListPetugasMitraController>(() => ListPetugasMitraController());
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/list_tagihan_periode_controller.dart';
class ListTagihanPeriodeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ListTagihanPeriodeController>(
() => ListTagihanPeriodeController(),
);
}
}

View File

@ -0,0 +1,15 @@
import 'package:get/get.dart';
import '../controllers/petugas_aset_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasAsetBinding extends Bindings {
@override
void dependencies() {
// Ensure dashboard controller is registered
if (!Get.isRegistered<PetugasBumdesDashboardController>()) {
Get.put(PetugasBumdesDashboardController(), permanent: true);
}
Get.lazyPut<PetugasAsetController>(() => PetugasAsetController());
}
}

View File

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/petugas_bumdes_cbp_controller.dart';
class PetugasBumdesCbpBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PetugasBumdesCbpController>(() => PetugasBumdesCbpController());
}
}

View File

@ -0,0 +1,13 @@
import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart';
class PetugasDetailSewaBinding extends Bindings {
@override
void dependencies() {
// Memastikan controller sudah tersedia
Get.lazyPut<PetugasSewaController>(
() => PetugasSewaController(),
fenix: true,
);
}
}

View File

@ -0,0 +1,27 @@
import 'package:get/get.dart';
import '../controllers/petugas_manajemen_bumdes_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../data/providers/auth_provider.dart';
class PetugasManajemenBumdesBinding extends Bindings {
@override
void dependencies() {
// Make sure AuthProvider is registered
if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider());
}
// Register the dashboard controller if not already registered
if (!Get.isRegistered<PetugasBumdesDashboardController>()) {
Get.put<PetugasBumdesDashboardController>(
PetugasBumdesDashboardController(),
permanent: true,
);
}
// Register the manajemen bumdes controller
Get.lazyPut<PetugasManajemenBumdesController>(
() => PetugasManajemenBumdesController(),
);
}
}

View File

@ -0,0 +1,15 @@
import 'package:get/get.dart';
import '../controllers/petugas_paket_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasPaketBinding extends Bindings {
@override
void dependencies() {
// Ensure dashboard controller is registered
if (!Get.isRegistered<PetugasBumdesDashboardController>()) {
Get.put(PetugasBumdesDashboardController(), permanent: true);
}
Get.lazyPut<PetugasPaketController>(() => PetugasPaketController());
}
}

View File

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart';
class PetugasSewaBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PetugasSewaController>(() => PetugasSewaController());
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/petugas_tambah_aset_controller.dart';
class PetugasTambahAsetBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PetugasTambahAsetController>(
() => PetugasTambahAsetController(),
);
}
}

View File

@ -0,0 +1,11 @@
import 'package:get/get.dart';
import '../controllers/petugas_tambah_paket_controller.dart';
class PetugasTambahPaketBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PetugasTambahPaketController>(
() => PetugasTambahPaketController(),
);
}
}

View File

@ -0,0 +1,94 @@
import 'package:get/get.dart';
class ListPelangganAktifController extends GetxController {
// Reactive variables
final isLoading = true.obs;
final pelangganList = <Map<String, dynamic>>[].obs;
final searchQuery = ''.obs;
final serviceName = ''.obs;
@override
void onInit() {
super.onInit();
// Get the service name passed from previous page
if (Get.arguments != null && Get.arguments['serviceName'] != null) {
serviceName.value = Get.arguments['serviceName'];
}
// Load the pelanggan data
loadPelangganData();
}
// Load sample pelanggan data (would be replaced with API call in production)
Future<void> loadPelangganData() async {
isLoading.value = true;
try {
// Simulate API call delay
await Future.delayed(const Duration(milliseconds: 800));
// For now, we only have Malih as an active subscriber
final sampleData = [
{
'id': '1',
'nama': 'Malih',
'alamat': 'Jl. Desa Sejahtera No. 15, RT 03/RW 02',
'status': 'Aktif',
'tanggal_mulai': '01/05/2023',
'tanggal_berakhir': '01/05/2024',
'pembayaran_terakhir': '01/04/2024',
'tagihan': 'Rp 20.000',
'telepon': '081234567890',
'email': 'malih@example.com',
'catatan': 'Pelanggan setia sejak 2023',
},
];
pelangganList.assignAll(sampleData);
} catch (e) {
print('Error loading pelanggan data: $e');
} finally {
isLoading.value = false;
}
}
// Filter the list based on search query
List<Map<String, dynamic>> get filteredPelangganList {
if (searchQuery.value.isEmpty) {
return pelangganList;
}
final query = searchQuery.value.toLowerCase();
return pelangganList.where((pelanggan) {
final nama = pelanggan['nama'].toString().toLowerCase();
final alamat = pelanggan['alamat'].toString().toLowerCase();
final status = pelanggan['status'].toString().toLowerCase();
return nama.contains(query) ||
alamat.contains(query) ||
status.contains(query);
}).toList();
}
// Update search query
void updateSearchQuery(String query) {
searchQuery.value = query;
}
// Get status color based on status value
getStatusColor(String status) {
switch (status.toLowerCase()) {
case 'aktif':
return 0xFF4CAF50; // Green
case 'tertunda':
return 0xFFFFA000; // Amber
case 'berakhir':
return 0xFF9E9E9E; // Grey
case 'dibatalkan':
return 0xFFE53935; // Red
default:
return 0xFF2196F3; // Blue
}
}
}

View File

@ -0,0 +1,93 @@
import 'package:get/get.dart';
class ListPetugasMitraController extends GetxController {
// Observable list of partners/mitra
final partners =
<Map<String, dynamic>>[
{
'id': '1',
'name': 'Malih',
'contact': '081234567890',
'address': 'Jl. Desa No. 123, Kecamatan Bumdes, Kabupaten Desa',
'is_active': true,
'role': 'Petugas Lapangan',
'join_date': '10 Januari 2023',
},
].obs;
// Loading state
final isLoading = false.obs;
// Search functionality
final searchQuery = ''.obs;
// Filtered list based on search
List<Map<String, dynamic>> get filteredPartners {
if (searchQuery.value.isEmpty) {
return partners;
}
return partners
.where(
(partner) =>
partner['name'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
) ||
partner['contact'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
) ||
partner['role'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
),
)
.toList();
}
// Add a new partner
void addPartner(Map<String, dynamic> partner) {
partners.add(partner);
Get.back();
Get.snackbar(
'Sukses',
'Petugas mitra berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
// Edit an existing partner
void editPartner(String id, Map<String, dynamic> updatedPartner) {
final index = partners.indexWhere((partner) => partner['id'] == id);
if (index != -1) {
partners[index] = updatedPartner;
Get.back();
Get.snackbar(
'Sukses',
'Data petugas mitra berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
);
}
}
// Delete a partner
void deletePartner(String id) {
partners.removeWhere((partner) => partner['id'] == id);
Get.snackbar(
'Sukses',
'Petugas mitra berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
);
}
// Toggle partner active status
void togglePartnerStatus(String id) {
final index = partners.indexWhere((partner) => partner['id'] == id);
if (index != -1) {
final currentStatus = partners[index]['is_active'] as bool;
partners[index]['is_active'] = !currentStatus;
Get.snackbar(
'Status Diperbarui',
'Status petugas mitra diubah menjadi ${!currentStatus ? 'Aktif' : 'Nonaktif'}',
snackPosition: SnackPosition.BOTTOM,
);
}
}
}

View File

@ -0,0 +1,106 @@
import 'package:get/get.dart';
class ListTagihanPeriodeController extends GetxController {
// Reactive variables
final isLoading = true.obs;
final periodeList = <Map<String, dynamic>>[].obs;
final searchQuery = ''.obs;
// Customer data
final pelangganData = Rx<Map<String, dynamic>>({});
final serviceName = ''.obs;
@override
void onInit() {
super.onInit();
// Get the customer data and service name passed from previous page
if (Get.arguments != null) {
if (Get.arguments['pelanggan'] != null) {
pelangganData.value = Map<String, dynamic>.from(
Get.arguments['pelanggan'],
);
}
if (Get.arguments['serviceName'] != null) {
serviceName.value = Get.arguments['serviceName'];
}
}
// Load periode data
loadPeriodeData();
}
// Load sample periode data (would be replaced with API call in production)
Future<void> loadPeriodeData() async {
isLoading.value = true;
try {
// Simulate API call delay
await Future.delayed(const Duration(milliseconds: 800));
// Sample data for periods
final sampleData = [
{
'id': '1',
'bulan': 'Maret',
'tahun': '2025',
'nominal': 'Rp 20.000',
'status_pembayaran': 'Lunas',
'tanggal_pembayaran': '05/03/2025',
'metode_pembayaran': 'Transfer Bank',
'keterangan': 'Pembayaran tepat waktu',
'is_current': true,
},
];
periodeList.assignAll(sampleData);
} catch (e) {
print('Error loading periode data: $e');
} finally {
isLoading.value = false;
}
}
// Filter the list based on search query
List<Map<String, dynamic>> get filteredPeriodeList {
if (searchQuery.value.isEmpty) {
return periodeList;
}
final query = searchQuery.value.toLowerCase();
return periodeList.where((periode) {
final bulan = periode['bulan'].toString().toLowerCase();
final tahun = periode['tahun'].toString().toLowerCase();
final status = periode['status_pembayaran'].toString().toLowerCase();
return bulan.contains(query) ||
tahun.contains(query) ||
status.contains(query);
}).toList();
}
// Update search query
void updateSearchQuery(String query) {
searchQuery.value = query;
}
// Get status color based on payment status
getStatusColor(String status) {
switch (status.toLowerCase()) {
case 'lunas':
return 0xFF4CAF50; // Green
case 'belum lunas':
return 0xFFFFA000; // Amber
case 'terlambat':
return 0xFFE53935; // Red
default:
return 0xFF2196F3; // Blue
}
}
// Get formatted month-year string
String getPeriodeString(Map<String, dynamic> periode) {
return '${periode['bulan']} ${periode['tahun']}';
}
}

View File

@ -0,0 +1,217 @@
import 'package:get/get.dart';
class PetugasAsetController extends GetxController {
// Observable lists for asset data
final asetList = <Map<String, dynamic>>[].obs;
final filteredAsetList = <Map<String, dynamic>>[].obs;
final isLoading = true.obs;
final searchQuery = ''.obs;
// Tab selection (0 for Sewa, 1 for Langganan)
final selectedTabIndex = 0.obs;
// Sort options
final sortBy = 'Nama (A-Z)'.obs;
final sortOptions =
[
'Nama (A-Z)',
'Nama (Z-A)',
'Harga (Rendah-Tinggi)',
'Harga (Tinggi-Rendah)',
].obs;
@override
void onInit() {
super.onInit();
// Load sample data when the controller is initialized
loadAsetData();
}
// Load sample asset data (would be replaced with API call in production)
Future<void> loadAsetData() async {
isLoading.value = true;
try {
// Simulate API call with a delay
await Future.delayed(const Duration(seconds: 1));
// Sample assets data
final sampleData = [
{
'id': '1',
'nama': 'Meja Rapat',
'kategori': 'Furniture',
'jenis': 'Sewa', // Added jenis field
'harga': 50000,
'satuan': 'per hari',
'stok': 10,
'deskripsi':
'Meja rapat kayu jati ukuran besar untuk acara pertemuan',
'gambar': 'https://example.com/meja.jpg',
'tersedia': true,
},
{
'id': '2',
'nama': 'Kursi Taman',
'kategori': 'Furniture',
'jenis': 'Sewa', // Added jenis field
'harga': 10000,
'satuan': 'per hari',
'stok': 50,
'deskripsi': 'Kursi taman plastik yang nyaman untuk acara outdoor',
'gambar': 'https://example.com/kursi.jpg',
'tersedia': true,
},
{
'id': '3',
'nama': 'Proyektor',
'kategori': 'Elektronik',
'jenis': 'Sewa', // Added jenis field
'harga': 100000,
'satuan': 'per hari',
'stok': 5,
'deskripsi': 'Proyektor HD dengan brightness tinggi',
'gambar': 'https://example.com/proyektor.jpg',
'tersedia': true,
},
{
'id': '4',
'nama': 'Sound System',
'kategori': 'Elektronik',
'jenis': 'Langganan', // Added jenis field
'harga': 200000,
'satuan': 'per bulan',
'stok': 3,
'deskripsi': 'Sound system lengkap dengan speaker dan mixer',
'gambar': 'https://example.com/sound.jpg',
'tersedia': false,
},
{
'id': '5',
'nama': 'Mobil Pick Up',
'kategori': 'Kendaraan',
'jenis': 'Langganan', // Added jenis field
'harga': 250000,
'satuan': 'per bulan',
'stok': 2,
'deskripsi': 'Mobil pick up untuk mengangkut barang',
'gambar': 'https://example.com/pickup.jpg',
'tersedia': true,
},
{
'id': '6',
'nama': 'Internet Fiber',
'kategori': 'Elektronik',
'jenis': 'Langganan', // Added jenis field
'harga': 350000,
'satuan': 'per bulan',
'stok': 15,
'deskripsi': 'Paket internet fiber 100Mbps untuk kantor',
'gambar': 'https://example.com/internet.jpg',
'tersedia': true,
},
];
asetList.assignAll(sampleData);
applyFilters(); // Apply default filters
} catch (e) {
print('Error loading asset data: $e');
} finally {
isLoading.value = false;
}
}
// Apply filters and sorting to asset list
void applyFilters() {
// Start with all assets
var filtered = List<Map<String, dynamic>>.from(asetList);
// Filter by tab selection (Sewa or Langganan)
String jenisFilter = selectedTabIndex.value == 0 ? 'Sewa' : 'Langganan';
filtered = filtered.where((aset) => aset['jenis'] == jenisFilter).toList();
// Apply search query
if (searchQuery.value.isNotEmpty) {
final query = searchQuery.value.toLowerCase();
filtered =
filtered.where((aset) {
final nama = aset['nama'].toString().toLowerCase();
final deskripsi = aset['deskripsi'].toString().toLowerCase();
final kategori = aset['kategori'].toString().toLowerCase();
return nama.contains(query) ||
deskripsi.contains(query) ||
kategori.contains(query);
}).toList();
}
// Apply sorting
switch (sortBy.value) {
case 'Nama (A-Z)':
filtered.sort(
(a, b) => a['nama'].toString().compareTo(b['nama'].toString()),
);
break;
case 'Nama (Z-A)':
filtered.sort(
(a, b) => b['nama'].toString().compareTo(a['nama'].toString()),
);
break;
case 'Harga (Rendah-Tinggi)':
filtered.sort((a, b) => a['harga'].compareTo(b['harga']));
break;
case 'Harga (Tinggi-Rendah)':
filtered.sort((a, b) => b['harga'].compareTo(a['harga']));
break;
}
// Update filtered list
filteredAsetList.assignAll(filtered);
}
// Change tab (Sewa or Langganan)
void changeTab(int index) {
selectedTabIndex.value = index;
applyFilters();
}
// Set search query
void setSearchQuery(String query) {
searchQuery.value = query;
applyFilters();
}
// Set sort option
void setSortBy(String option) {
sortBy.value = option;
applyFilters();
}
// Format price to Indonesian Rupiah
String formatPrice(int price) {
return 'Rp${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
// Add a new asset
void addAset(Map<String, dynamic> newAset) {
// In a real app, this would be an API call
// For demo, we'll just add to the list
asetList.add(newAset);
applyFilters();
}
// Update an existing asset
void updateAset(String id, Map<String, dynamic> updatedData) {
final index = asetList.indexWhere((aset) => aset['id'] == id);
if (index != -1) {
asetList[index] = updatedData;
applyFilters();
}
}
// Delete an asset
void deleteAset(String id) {
asetList.removeWhere((aset) => aset['id'] == id);
applyFilters();
}
}

View File

@ -0,0 +1,217 @@
import 'package:get/get.dart';
class PetugasBumdesCbpController extends GetxController {
// Observable variables
final isLoading = true.obs;
// Bank account data
final bankAccounts =
<Map<String, dynamic>>[
{
'id': '1',
'bank_name': 'Bank BRI',
'account_number': '1234-5678-9101',
'account_holder': 'BUMDes CBP Sukamaju',
'is_primary': true,
},
{
'id': '2',
'bank_name': 'Bank BNI',
'account_number': '9876-5432-1098',
'account_holder': 'BUMDes CBP Sukamaju',
'is_primary': false,
},
].obs;
// Partners data
final partners =
<Map<String, dynamic>>[
{
'id': '1',
'name': 'UD Maju Jaya',
'contact': '081234567890',
'address': 'Jl. Raya Sukamaju No. 123',
'is_active': true,
},
{
'id': '2',
'name': 'CV Tani Mandiri',
'contact': '087654321098',
'address': 'Jl. Kelapa Dua No. 45',
'is_active': true,
},
{
'id': '3',
'name': 'PT Karya Sejahtera',
'contact': '089876543210',
'address': 'Jl. Industri Blok C No. 7',
'is_active': false,
},
].obs;
@override
void onInit() {
super.onInit();
loadData();
}
Future<void> loadData() async {
try {
isLoading.value = true;
// Simulate API delay
await Future.delayed(const Duration(seconds: 1));
// Data is already loaded in the initialized lists
} catch (e) {
print('Error loading data: $e');
Get.snackbar(
'Error',
'Gagal memuat data. Silakan coba lagi.',
snackPosition: SnackPosition.BOTTOM,
);
} finally {
isLoading.value = false;
}
}
// Bank Account Methods
void setPrimaryBankAccount(String id) {
final index = bankAccounts.indexWhere((account) => account['id'] == id);
if (index != -1) {
// First, set all accounts to non-primary
for (int i = 0; i < bankAccounts.length; i++) {
final account = Map<String, dynamic>.from(bankAccounts[i]);
account['is_primary'] = false;
bankAccounts[i] = account;
}
// Then set the selected account as primary
final account = Map<String, dynamic>.from(bankAccounts[index]);
account['is_primary'] = true;
bankAccounts[index] = account;
Get.snackbar(
'Rekening Utama',
'Rekening ${account['bank_name']} telah dijadikan rekening utama',
snackPosition: SnackPosition.BOTTOM,
);
}
}
void addBankAccount(Map<String, dynamic> account) {
// Generate a new ID (in a real app, this would be from the backend)
account['id'] = (bankAccounts.length + 1).toString();
// By default, new accounts are not primary
account['is_primary'] = false;
bankAccounts.add(account);
Get.back();
Get.snackbar(
'Rekening Ditambahkan',
'Rekening bank baru telah berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
void updateBankAccount(String id, Map<String, dynamic> updatedAccount) {
final index = bankAccounts.indexWhere((account) => account['id'] == id);
if (index != -1) {
// Preserve the ID and primary status
updatedAccount['id'] = id;
updatedAccount['is_primary'] = bankAccounts[index]['is_primary'];
bankAccounts[index] = updatedAccount;
Get.back();
Get.snackbar(
'Rekening Diperbarui',
'Informasi rekening bank telah berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
);
}
}
void deleteBankAccount(String id) {
final index = bankAccounts.indexWhere((account) => account['id'] == id);
if (index != -1) {
// Check if trying to delete the primary account
if (bankAccounts[index]['is_primary'] == true) {
Get.snackbar(
'Tidak Dapat Menghapus',
'Rekening utama tidak dapat dihapus. Silakan atur rekening lain sebagai utama terlebih dahulu.',
snackPosition: SnackPosition.BOTTOM,
);
return;
}
bankAccounts.removeAt(index);
Get.back();
Get.snackbar(
'Rekening Dihapus',
'Rekening bank telah berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
);
}
}
// Partner Methods
void togglePartnerStatus(String id) {
final index = partners.indexWhere((partner) => partner['id'] == id);
if (index != -1) {
final partner = Map<String, dynamic>.from(partners[index]);
partner['is_active'] = !partner['is_active'];
partners[index] = partner;
Get.snackbar(
'Status Diperbarui',
'Status mitra telah diubah menjadi ${partner['is_active'] ? 'Aktif' : 'Tidak Aktif'}',
snackPosition: SnackPosition.BOTTOM,
);
}
}
void addPartner(Map<String, dynamic> partner) {
// Generate a new ID (in a real app, this would be from the backend)
partner['id'] = (partners.length + 1).toString();
// By default, new partners are active
partner['is_active'] = true;
partners.add(partner);
Get.back();
Get.snackbar(
'Mitra Ditambahkan',
'Mitra baru telah berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
void updatePartner(String id, Map<String, dynamic> updatedPartner) {
final index = partners.indexWhere((partner) => partner['id'] == id);
if (index != -1) {
// Preserve the ID and active status
updatedPartner['id'] = id;
updatedPartner['is_active'] = partners[index]['is_active'];
partners[index] = updatedPartner;
Get.back();
Get.snackbar(
'Mitra Diperbarui',
'Informasi mitra telah berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
);
}
}
void deletePartner(String id) {
final index = partners.indexWhere((partner) => partner['id'] == id);
if (index != -1) {
partners.removeAt(index);
Get.back();
Get.snackbar(
'Mitra Dihapus',
'Mitra telah berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
);
}
}
}

View File

@ -0,0 +1,147 @@
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
class PetugasBumdesDashboardController extends GetxController {
AuthProvider? _authProvider;
// Reactive variables
final userEmail = ''.obs;
final currentTabIndex = 0.obs;
// Revenue Statistics
final totalPendapatanBulanIni = 'Rp 8.500.000'.obs;
final totalPendapatanBulanLalu = 'Rp 7.200.000'.obs;
final persentaseKenaikan = '18%'.obs;
final isKenaikanPositif = true.obs;
// Revenue by Category
final pendapatanSewa = 'Rp 5.200.000'.obs;
final persentaseSewa = 100.obs;
// Revenue Trends (last 6 months)
final trendPendapatan = [4.2, 5.1, 4.8, 6.2, 7.2, 8.5].obs; // in millions
// Status Counters for Sewa Aset
final terlaksanaCount = 5.obs;
final dijadwalkanCount = 1.obs;
final aktifCount = 1.obs;
final dibatalkanCount = 3.obs;
// Additional Sewa Aset Status Counters
final menungguPembayaranCount = 2.obs;
final periksaPembayaranCount = 1.obs;
final diterimaCount = 3.obs;
final pembayaranDendaCount = 1.obs;
final periksaPembayaranDendaCount = 0.obs;
final selesaiCount = 4.obs;
// Status counts for Sewa
final pengajuanSewaCount = 5.obs;
final pemasanganCountSewa = 3.obs;
final sewaAktifCount = 10.obs;
final tagihanAktifCountSewa = 7.obs;
final periksaPembayaranCountSewa = 2.obs;
@override
void onInit() {
super.onInit();
try {
_authProvider = Get.find<AuthProvider>();
userEmail.value = _authProvider?.currentUser?.email ?? 'Tidak ada email';
} catch (e) {
print('Error finding AuthProvider: $e');
userEmail.value = 'Tidak ada email';
}
// In a real app, these counts would be fetched from backend
// loadStatusCounts();
print('✅ PetugasBumdesDashboardController initialized successfully');
}
// Method to load status counts from backend
// Future<void> loadStatusCounts() async {
// try {
// final response = await _asetProvider.getSewaStatusCounts();
// if (response != null) {
// terlaksanaCount.value = response['terlaksana'] ?? 0;
// dijadwalkanCount.value = response['dijadwalkan'] ?? 0;
// aktifCount.value = response['aktif'] ?? 0;
// dibatalkanCount.value = response['dibatalkan'] ?? 0;
// menungguPembayaranCount.value = response['menunggu_pembayaran'] ?? 0;
// periksaPembayaranCount.value = response['periksa_pembayaran'] ?? 0;
// diterimaCount.value = response['diterima'] ?? 0;
// pembayaranDendaCount.value = response['pembayaran_denda'] ?? 0;
// periksaPembayaranDendaCount.value = response['periksa_pembayaran_denda'] ?? 0;
// selesaiCount.value = response['selesai'] ?? 0;
// }
// } catch (e) {
// print('Error loading status counts: $e');
// }
// }
void changeTab(int index) {
try {
currentTabIndex.value = index;
// Navigate to the appropriate page based on the tab index
switch (index) {
case 0:
// Navigate to Dashboard
Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD);
break;
case 1:
// Navigate to Aset page
navigateToAset();
break;
case 2:
// Navigate to Paket page
navigateToPaket();
break;
case 3:
// Navigate to Sewa page
navigateToSewa();
break;
}
} catch (e) {
print('Error changing tab: $e');
}
}
void navigateToAset() {
try {
Get.offAllNamed(Routes.PETUGAS_ASET);
} catch (e) {
print('Error navigating to Aset: $e');
}
}
void navigateToPaket() {
try {
Get.offAllNamed(Routes.PETUGAS_PAKET);
} catch (e) {
print('Error navigating to Paket: $e');
}
}
void navigateToSewa() {
try {
Get.offAllNamed(Routes.PETUGAS_SEWA);
} catch (e) {
print('Error navigating to Sewa: $e');
}
}
void logout() async {
try {
if (_authProvider != null) {
await _authProvider!.signOut();
}
Get.offAllNamed(Routes.LOGIN);
} catch (e) {
print('Error during logout: $e');
// Still try to navigate to login even if sign out fails
Get.offAllNamed(Routes.LOGIN);
}
}
}

View File

@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PetugasManajemenBumdesController extends GetxController {
// Reactive variables
final RxInt selectedTabIndex = 0.obs;
final RxBool isLoading = false.obs;
// Tab options
final List<String> tabOptions = ['Akun Bank', 'Mitra'];
// Sample data for Bank Accounts
final RxList<Map<String, dynamic>> bankAccounts =
<Map<String, dynamic>>[
{
'bankName': 'Bank BRI',
'accountName': 'BUMDes Sejahtera',
'accountNumber': '123456789',
'isPrimary': true,
},
{
'bankName': 'Bank BNI',
'accountName': 'BUMDes Sejahtera',
'accountNumber': '987654321',
'isPrimary': false,
},
].obs;
// Sample data for Partners
final RxList<Map<String, dynamic>> partners =
<Map<String, dynamic>>[
{
'name': 'CV Maju Jaya',
'email': 'majujaya@example.com',
'phone': '081234567890',
'address': 'Jl. Maju No. 123, Kecamatan Berkah',
'isActive': true,
},
{
'name': 'PT Sentosa',
'email': 'sentosa@example.com',
'phone': '089876543210',
'address': 'Jl. Sentosa No. 456, Kecamatan Damai',
'isActive': false,
},
].obs;
@override
void onInit() {
super.onInit();
loadData();
}
void loadData() {
isLoading.value = true;
// Simulate loading data from API
Future.delayed(const Duration(milliseconds: 500), () {
// Data already loaded with sample data
isLoading.value = false;
});
}
void changeTab(int index) {
selectedTabIndex.value = index;
}
void setPrimaryBankAccount(int index) {
// Set all accounts to non-primary first
for (var i = 0; i < bankAccounts.length; i++) {
bankAccounts[i]['isPrimary'] = false;
}
// Set the selected account as primary
bankAccounts[index]['isPrimary'] = true;
// Force UI refresh
bankAccounts.refresh();
Get.snackbar(
'Sukses',
'Rekening utama berhasil diubah',
snackPosition: SnackPosition.BOTTOM,
);
}
void togglePartnerStatus(int index) {
// Toggle the active status
partners[index]['isActive'] = !partners[index]['isActive'];
// Force UI refresh
partners.refresh();
Get.snackbar(
'Sukses',
'Status mitra berhasil diubah',
snackPosition: SnackPosition.BOTTOM,
);
}
void addBankAccount(Map<String, dynamic> account) {
// Set as primary if it's the first account
if (bankAccounts.isEmpty) {
account['isPrimary'] = true;
} else {
account['isPrimary'] = false;
}
bankAccounts.add(account);
Get.snackbar(
'Sukses',
'Rekening bank berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
void updateBankAccount(int index, Map<String, dynamic> updatedAccount) {
// Preserve the primary status
updatedAccount['isPrimary'] = bankAccounts[index]['isPrimary'];
bankAccounts[index] = updatedAccount;
bankAccounts.refresh();
Get.snackbar(
'Sukses',
'Rekening bank berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
);
}
void deleteBankAccount(int index) {
// Check if the account to be deleted is primary
final isPrimary = bankAccounts[index]['isPrimary'];
// Remove the account
bankAccounts.removeAt(index);
// If the deleted account was primary and there are other accounts, set the first one as primary
if (isPrimary && bankAccounts.isNotEmpty) {
bankAccounts[0]['isPrimary'] = true;
}
bankAccounts.refresh();
Get.snackbar(
'Sukses',
'Rekening bank berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
);
}
void addPartner(Map<String, dynamic> partner) {
partners.add(partner);
Get.snackbar(
'Sukses',
'Mitra berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
void updatePartner(int index, Map<String, dynamic> updatedPartner) {
partners[index] = updatedPartner;
partners.refresh();
Get.snackbar(
'Sukses',
'Mitra berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
);
}
void deletePartner(int index) {
partners.removeAt(index);
partners.refresh();
Get.snackbar(
'Sukses',
'Mitra berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
);
}
}

View File

@ -0,0 +1,253 @@
import 'package:get/get.dart';
import 'package:intl/intl.dart';
class PetugasPaketController extends GetxController {
final isLoading = false.obs;
final searchQuery = ''.obs;
final selectedCategory = 'Semua'.obs;
final sortBy = 'Terbaru'.obs;
// Kategori untuk filter
final categories = <String>[
'Semua',
'Pesta',
'Rapat',
'Olahraga',
'Pernikahan',
'Lainnya',
];
// Opsi pengurutan
final sortOptions = <String>[
'Terbaru',
'Terlama',
'Harga Tertinggi',
'Harga Terendah',
'Nama A-Z',
'Nama Z-A',
];
// Data dummy paket
final paketList = <Map<String, dynamic>>[].obs;
final filteredPaketList = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
loadPaketData();
}
// Format harga ke Rupiah
String formatPrice(int price) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
return formatter.format(price);
}
// Load data paket dummy
Future<void> loadPaketData() async {
isLoading.value = true;
await Future.delayed(const Duration(milliseconds: 800)); // Simulasi loading
paketList.value = [
{
'id': '1',
'nama': 'Paket Pesta Ulang Tahun',
'kategori': 'Pesta',
'harga': 500000,
'deskripsi':
'Paket lengkap untuk acara ulang tahun. Termasuk 5 meja, 20 kursi, backdrop, dan sound system.',
'tersedia': true,
'created_at': '2023-08-10',
'items': [
{'nama': 'Meja Panjang', 'jumlah': 5},
{'nama': 'Kursi Plastik', 'jumlah': 20},
{'nama': 'Sound System', 'jumlah': 1},
{'nama': 'Backdrop', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_ultah.jpg',
},
{
'id': '2',
'nama': 'Paket Rapat Sedang',
'kategori': 'Rapat',
'harga': 300000,
'deskripsi':
'Paket untuk rapat sedang. Termasuk 1 meja rapat besar, 10 kursi, proyektor, dan screen.',
'tersedia': true,
'created_at': '2023-09-05',
'items': [
{'nama': 'Meja Rapat', 'jumlah': 1},
{'nama': 'Kursi Kantor', 'jumlah': 10},
{'nama': 'Proyektor', 'jumlah': 1},
{'nama': 'Screen', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_rapat.jpg',
},
{
'id': '3',
'nama': 'Paket Pesta Pernikahan',
'kategori': 'Pernikahan',
'harga': 1500000,
'deskripsi':
'Paket lengkap untuk acara pernikahan. Termasuk 20 meja, 100 kursi, sound system, dekorasi, dan tenda.',
'tersedia': true,
'created_at': '2023-10-12',
'items': [
{'nama': 'Meja Bundar', 'jumlah': 20},
{'nama': 'Kursi Tamu', 'jumlah': 100},
{'nama': 'Sound System Besar', 'jumlah': 1},
{'nama': 'Tenda 10x10', 'jumlah': 2},
{'nama': 'Set Dekorasi Pengantin', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_nikah.jpg',
},
{
'id': '4',
'nama': 'Paket Olahraga Voli',
'kategori': 'Olahraga',
'harga': 200000,
'deskripsi':
'Paket perlengkapan untuk turnamen voli. Termasuk net, bola, dan tiang voli.',
'tersedia': false,
'created_at': '2023-07-22',
'items': [
{'nama': 'Net Voli', 'jumlah': 1},
{'nama': 'Bola Voli', 'jumlah': 3},
{'nama': 'Tiang Voli', 'jumlah': 2},
],
'gambar': 'https://example.com/images/paket_voli.jpg',
},
{
'id': '5',
'nama': 'Paket Pesta Anak',
'kategori': 'Pesta',
'harga': 350000,
'deskripsi':
'Paket untuk pesta ulang tahun anak-anak. Termasuk 3 meja, 15 kursi, dekorasi tema, dan sound system kecil.',
'tersedia': true,
'created_at': '2023-11-01',
'items': [
{'nama': 'Meja Anak', 'jumlah': 3},
{'nama': 'Kursi Anak', 'jumlah': 15},
{'nama': 'Set Dekorasi Tema', 'jumlah': 1},
{'nama': 'Sound System Kecil', 'jumlah': 1},
],
'gambar': 'https://example.com/images/paket_anak.jpg',
},
];
filterPaket();
isLoading.value = false;
}
// Filter paket berdasarkan search query dan kategori
void filterPaket() {
filteredPaketList.value =
paketList.where((paket) {
final matchesQuery =
paket['nama'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
) ||
paket['deskripsi'].toString().toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
final matchesCategory =
selectedCategory.value == 'Semua' ||
paket['kategori'] == selectedCategory.value;
return matchesQuery && matchesCategory;
}).toList();
// Sort the filtered list
sortFilteredList();
}
// Sort the filtered list
void sortFilteredList() {
switch (sortBy.value) {
case 'Terbaru':
filteredPaketList.sort(
(a, b) => b['created_at'].compareTo(a['created_at']),
);
break;
case 'Terlama':
filteredPaketList.sort(
(a, b) => a['created_at'].compareTo(b['created_at']),
);
break;
case 'Harga Tertinggi':
filteredPaketList.sort((a, b) => b['harga'].compareTo(a['harga']));
break;
case 'Harga Terendah':
filteredPaketList.sort((a, b) => a['harga'].compareTo(b['harga']));
break;
case 'Nama A-Z':
filteredPaketList.sort((a, b) => a['nama'].compareTo(b['nama']));
break;
case 'Nama Z-A':
filteredPaketList.sort((a, b) => b['nama'].compareTo(a['nama']));
break;
}
}
// Set search query dan filter paket
void setSearchQuery(String query) {
searchQuery.value = query;
filterPaket();
}
// Set kategori dan filter paket
void setCategory(String category) {
selectedCategory.value = category;
filterPaket();
}
// Set opsi pengurutan dan filter paket
void setSortBy(String option) {
sortBy.value = option;
sortFilteredList();
}
// Tambah paket baru
void addPaket(Map<String, dynamic> paket) {
paketList.add(paket);
filterPaket();
Get.back();
Get.snackbar(
'Sukses',
'Paket baru berhasil ditambahkan',
snackPosition: SnackPosition.BOTTOM,
);
}
// Edit paket
void editPaket(String id, Map<String, dynamic> updatedPaket) {
final index = paketList.indexWhere((element) => element['id'] == id);
if (index >= 0) {
paketList[index] = updatedPaket;
filterPaket();
Get.back();
Get.snackbar(
'Sukses',
'Paket berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
);
}
}
// Hapus paket
void deletePaket(String id) {
paketList.removeWhere((element) => element['id'] == id);
filterPaket();
Get.snackbar(
'Sukses',
'Paket berhasil dihapus',
snackPosition: SnackPosition.BOTTOM,
);
}
}

View File

@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PetugasSewaController extends GetxController {
// Reactive variables
final isLoading = true.obs;
final searchQuery = ''.obs;
final orderIdQuery = ''.obs;
final selectedStatusFilter = 'Semua'.obs;
final filteredSewaList = <Map<String, dynamic>>[].obs;
// Filter options
final List<String> statusFilters = [
'Semua',
'Menunggu Pembayaran',
'Periksa Pembayaran',
'Diterima',
'Dikembalikan',
'Selesai',
'Dibatalkan',
];
// Mock data for sewa list
final RxList<Map<String, dynamic>> sewaList = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
// Add listeners to update filtered list when any filter changes
ever(searchQuery, (_) => _updateFilteredList());
ever(orderIdQuery, (_) => _updateFilteredList());
ever(selectedStatusFilter, (_) => _updateFilteredList());
ever(sewaList, (_) => _updateFilteredList());
// Load initial data
loadSewaData();
}
// Update filtered list based on current filters
void _updateFilteredList() {
filteredSewaList.value =
sewaList.where((sewa) {
// Apply search filter
final matchesSearch = sewa['nama_warga']
.toString()
.toLowerCase()
.contains(searchQuery.value.toLowerCase());
// Apply order ID filter if provided
final matchesOrderId =
orderIdQuery.value.isEmpty ||
sewa['order_id'].toString().toLowerCase().contains(
orderIdQuery.value.toLowerCase(),
);
// Apply status filter if not 'Semua'
final matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value;
return matchesSearch && matchesOrderId && matchesStatus;
}).toList();
}
// Load sewa data (mock data for now)
Future<void> loadSewaData() async {
isLoading.value = true;
try {
// Simulate API call delay
await Future.delayed(const Duration(milliseconds: 800));
// Populate with mock data
sewaList.assignAll([
{
'id': '1',
'order_id': 'SWA-001',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-02-05',
'tanggal_selesai': '2025-02-10',
'total_biaya': 45000,
'status': 'Diterima',
'photo_url': 'https://example.com/photo1.jpg',
},
{
'id': '2',
'order_id': 'SWA-002',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-02-15',
'tanggal_selesai': '2025-02-20',
'total_biaya': 30000,
'status': 'Selesai',
'photo_url': 'https://example.com/photo2.jpg',
},
{
'id': '3',
'order_id': 'SWA-003',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-02-25',
'tanggal_selesai': '2025-03-01',
'total_biaya': 35000,
'status': 'Menunggu Pembayaran',
'photo_url': 'https://example.com/photo3.jpg',
},
{
'id': '4',
'order_id': 'SWA-004',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-05',
'tanggal_selesai': '2025-03-08',
'total_biaya': 20000,
'status': 'Periksa Pembayaran',
'photo_url': 'https://example.com/photo4.jpg',
},
{
'id': '5',
'order_id': 'SWA-005',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-12',
'tanggal_selesai': '2025-03-14',
'total_biaya': 15000,
'status': 'Dibatalkan',
'photo_url': 'https://example.com/photo5.jpg',
},
{
'id': '6',
'order_id': 'SWA-006',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-18',
'tanggal_selesai': '2025-03-20',
'total_biaya': 25000,
'status': 'Pembayaran Denda',
'photo_url': 'https://example.com/photo6.jpg',
},
{
'id': '7',
'order_id': 'SWA-007',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-03-25',
'tanggal_selesai': '2025-03-28',
'total_biaya': 40000,
'status': 'Periksa Denda',
'photo_url': 'https://example.com/photo7.jpg',
},
{
'id': '8',
'order_id': 'SWA-008',
'nama_warga': 'Sukimin',
'nama_aset': 'Mobil Pickup',
'tanggal_mulai': '2025-04-02',
'tanggal_selesai': '2025-04-05',
'total_biaya': 10000,
'status': 'Dikembalikan',
'photo_url': 'https://example.com/photo8.jpg',
},
]);
} catch (e) {
print('Error loading sewa data: $e');
} finally {
isLoading.value = false;
}
}
// Update search query
void setSearchQuery(String query) {
searchQuery.value = query;
}
// Update order ID query
void setOrderIdQuery(String query) {
orderIdQuery.value = query;
}
// Update status filter
void setStatusFilter(String status) {
selectedStatusFilter.value = status;
applyFilters();
}
void resetFilters() {
selectedStatusFilter.value = 'Semua';
searchQuery.value = '';
filteredSewaList.value = sewaList;
}
void applyFilters() {
filteredSewaList.value =
sewaList.where((sewa) {
bool matchesStatus =
selectedStatusFilter.value == 'Semua' ||
sewa['status'] == selectedStatusFilter.value;
bool matchesSearch =
searchQuery.value.isEmpty ||
sewa['nama_warga'].toLowerCase().contains(
searchQuery.value.toLowerCase(),
);
return matchesStatus && matchesSearch;
}).toList();
}
// Format price to rupiah
String formatPrice(num price) {
return 'Rp ${price.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
// Get color based on status
Color getStatusColor(String status) {
switch (status) {
case 'Menunggu Pembayaran':
return Colors.orange;
case 'Periksa Pembayaran':
return Colors.amber.shade700;
case 'Diterima':
return Colors.blue;
case 'Pembayaran Denda':
return Colors.deepOrange;
case 'Periksa Denda':
return Colors.red.shade600;
case 'Dikembalikan':
return Colors.teal;
case 'Sedang Disewa':
return Colors.green;
case 'Selesai':
return Colors.purple;
case 'Dibatalkan':
return Colors.red;
default:
return Colors.grey;
}
}
// Handle sewa approval (from "Periksa Pembayaran" to "Diterima")
void approveSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
final currentStatus = sewa['status'];
if (currentStatus == 'Periksa Pembayaran') {
sewa['status'] = 'Diterima';
} else if (currentStatus == 'Periksa Denda') {
sewa['status'] = 'Selesai';
} else if (currentStatus == 'Menunggu Pembayaran') {
sewa['status'] = 'Periksa Pembayaran';
}
sewaList[index] = sewa;
sewaList.refresh();
}
}
// Handle sewa rejection or cancellation
void rejectSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Dibatalkan';
sewaList[index] = sewa;
sewaList.refresh();
}
}
// Request payment for penalty
void requestPenaltyPayment(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Pembayaran Denda';
sewaList[index] = sewa;
sewaList.refresh();
}
}
// Mark penalty payment as requiring inspection
void markPenaltyForInspection(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Periksa Denda';
sewaList[index] = sewa;
sewaList.refresh();
}
}
// Handle sewa completion
void completeSewa(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Selesai';
sewaList[index] = sewa;
sewaList.refresh();
}
}
// Mark rental as returned
void markAsReturned(String id) {
final index = sewaList.indexWhere((sewa) => sewa['id'] == id);
if (index != -1) {
final sewa = Map<String, dynamic>.from(sewaList[index]);
sewa['status'] = 'Dikembalikan';
sewaList[index] = sewa;
sewaList.refresh();
}
}
}

View File

@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PetugasTambahAsetController extends GetxController {
// Form controllers
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final quantityController = TextEditingController();
final unitOfMeasureController = TextEditingController();
final pricePerHourController = TextEditingController();
final maxHourController = TextEditingController();
final pricePerDayController = TextEditingController();
final maxDayController = TextEditingController();
// Dropdown and toggle values
final selectedCategory = 'Sewa'.obs;
final selectedStatus = 'Tersedia'.obs;
// Replace single selection with multiple selections
final timeOptions = {'Per Jam': true.obs, 'Per Hari': false.obs};
// Category options
final categoryOptions = ['Sewa', 'Langganan'];
final statusOptions = ['Tersedia', 'Pemeliharaan'];
// Images
final selectedImages = <String>[].obs;
// Form validation
final isFormValid = false.obs;
final isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
// Set default values
quantityController.text = '1';
unitOfMeasureController.text = 'Unit';
// Listen to field changes for validation
nameController.addListener(validateForm);
descriptionController.addListener(validateForm);
quantityController.addListener(validateForm);
pricePerHourController.addListener(validateForm);
pricePerDayController.addListener(validateForm);
}
@override
void onClose() {
// Dispose controllers
nameController.dispose();
descriptionController.dispose();
quantityController.dispose();
unitOfMeasureController.dispose();
pricePerHourController.dispose();
maxHourController.dispose();
pricePerDayController.dispose();
maxDayController.dispose();
super.onClose();
}
// Change selected category
void setCategory(String category) {
selectedCategory.value = category;
validateForm();
}
// Change selected status
void setStatus(String status) {
selectedStatus.value = status;
validateForm();
}
// Toggle time option
void toggleTimeOption(String option) {
timeOptions[option]?.value = !(timeOptions[option]?.value ?? false);
// Ensure at least one option is selected
bool anySelected = false;
timeOptions.forEach((key, value) {
if (value.value) anySelected = true;
});
// If none selected, force this one to remain selected
if (!anySelected) {
timeOptions[option]?.value = true;
}
validateForm();
}
// Add image to the list (in a real app, this would handle file upload)
void addImage(String imagePath) {
selectedImages.add(imagePath);
validateForm();
}
// Remove image from the list
void removeImage(int index) {
if (index >= 0 && index < selectedImages.length) {
selectedImages.removeAt(index);
validateForm();
}
}
// Validate form fields
void validateForm() {
// Basic validation
bool basicValid =
nameController.text.isNotEmpty &&
descriptionController.text.isNotEmpty &&
quantityController.text.isNotEmpty &&
int.tryParse(quantityController.text) != null;
// Time option validation
bool perHourValid =
!timeOptions['Per Jam']!.value ||
(pricePerHourController.text.isNotEmpty &&
int.tryParse(pricePerHourController.text) != null);
bool perDayValid =
!timeOptions['Per Hari']!.value ||
(pricePerDayController.text.isNotEmpty &&
int.tryParse(pricePerDayController.text) != null);
// At least one time option must be selected
bool anyTimeOptionSelected = false;
timeOptions.forEach((key, value) {
if (value.value) anyTimeOptionSelected = true;
});
isFormValid.value =
basicValid && perHourValid && perDayValid && anyTimeOptionSelected;
}
// Submit form and save asset
Future<void> saveAsset() async {
if (!isFormValid.value) return;
isSubmitting.value = true;
try {
// In a real app, this would make an API call to save the asset
await Future.delayed(const Duration(seconds: 1)); // Mock API call
// Prepare asset data
final assetData = {
'nama': nameController.text,
'deskripsi': descriptionController.text,
'kategori': selectedCategory.value,
'status': selectedStatus.value,
'kuantitas': int.parse(quantityController.text),
'satuan_ukur': unitOfMeasureController.text,
'opsi_waktu_sewa':
timeOptions.entries
.where((entry) => entry.value.value)
.map((entry) => entry.key)
.toList(),
'harga_per_jam':
timeOptions['Per Jam']!.value
? int.parse(pricePerHourController.text)
: null,
'max_jam':
timeOptions['Per Jam']!.value && maxHourController.text.isNotEmpty
? int.parse(maxHourController.text)
: null,
'harga_per_hari':
timeOptions['Per Hari']!.value
? int.parse(pricePerDayController.text)
: null,
'max_hari':
timeOptions['Per Hari']!.value && maxDayController.text.isNotEmpty
? int.parse(maxDayController.text)
: null,
'gambar': selectedImages,
};
// Log the data (in a real app, this would be sent to an API)
print('Asset data: $assetData');
// Return to the asset list page
Get.back();
// Show success message
Get.snackbar(
'Berhasil',
'Aset berhasil ditambahkan',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
} catch (e) {
// Show error message
Get.snackbar(
'Gagal',
'Terjadi kesalahan: ${e.toString()}',
backgroundColor: Colors.red,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
} finally {
isSubmitting.value = false;
}
}
// For demonstration purposes: add sample image
void addSampleImage() {
addImage('assets/images/sample_asset_${selectedImages.length + 1}.jpg');
}
}

View File

@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class PetugasTambahPaketController extends GetxController {
// Form controllers
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final priceController = TextEditingController();
final itemQuantityController = TextEditingController();
// Dropdown and toggle values
final selectedCategory = 'Bulanan'.obs;
final selectedStatus = 'Aktif'.obs;
// Category options
final categoryOptions = ['Bulanan', 'Tahunan', 'Premium', 'Bisnis'];
final statusOptions = ['Aktif', 'Nonaktif'];
// Images
final selectedImages = <String>[].obs;
// For package name and description
final packageNameController = TextEditingController();
final packageDescriptionController = TextEditingController();
final packagePriceController = TextEditingController();
// For items/assets in the package
final RxList<Map<String, dynamic>> packageItems =
<Map<String, dynamic>>[].obs;
// For asset selection
final RxList<Map<String, dynamic>> availableAssets =
<Map<String, dynamic>>[].obs;
final Rx<int?> selectedAsset = Rx<int?>(null);
final RxBool isLoadingAssets = false.obs;
// Form validation
final isFormValid = false.obs;
final isSubmitting = false.obs;
@override
void onInit() {
super.onInit();
// Listen to field changes for validation
nameController.addListener(validateForm);
descriptionController.addListener(validateForm);
priceController.addListener(validateForm);
// Load available assets when the controller initializes
fetchAvailableAssets();
}
@override
void onClose() {
// Dispose controllers
nameController.dispose();
descriptionController.dispose();
priceController.dispose();
itemQuantityController.dispose();
packageNameController.dispose();
packageDescriptionController.dispose();
packagePriceController.dispose();
super.onClose();
}
// Change selected category
void setCategory(String category) {
selectedCategory.value = category;
validateForm();
}
// Change selected status
void setStatus(String status) {
selectedStatus.value = status;
validateForm();
}
// Add image to the list (in a real app, this would handle file upload)
void addImage(String imagePath) {
selectedImages.add(imagePath);
validateForm();
}
// Remove image from the list
void removeImage(int index) {
if (index >= 0 && index < selectedImages.length) {
selectedImages.removeAt(index);
validateForm();
}
}
// Fetch available assets from the API or local data
void fetchAvailableAssets() {
isLoadingAssets.value = true;
// This is a mock implementation - replace with actual API call
Future.delayed(const Duration(seconds: 1), () {
availableAssets.value = [
{'id': 1, 'nama': 'Laptop Dell XPS', 'stok': 5},
{'id': 2, 'nama': 'Proyektor Epson', 'stok': 3},
{'id': 3, 'nama': 'Meja Kantor', 'stok': 10},
{'id': 4, 'nama': 'Kursi Ergonomis', 'stok': 15},
{'id': 5, 'nama': 'Printer HP LaserJet', 'stok': 2},
{'id': 6, 'nama': 'AC Panasonic 1PK', 'stok': 8},
];
isLoadingAssets.value = false;
});
}
// Set the selected asset
void setSelectedAsset(int? assetId) {
selectedAsset.value = assetId;
}
// Get remaining stock for an asset (considering current selections)
int getRemainingStock(int assetId) {
// Find the asset in available assets
final asset = availableAssets.firstWhere(
(item) => item['id'] == assetId,
orElse: () => <String, dynamic>{},
);
if (asset.isEmpty) return 0;
// Get total stock
final totalStock = asset['stok'] as int;
// Calculate how many of this asset are already in the package
int alreadySelected = 0;
for (var item in packageItems) {
if (item['asetId'] == assetId) {
alreadySelected += item['jumlah'] as int;
}
}
// Return the remaining available stock
return totalStock - alreadySelected;
}
// Add an asset to the package
void addAssetToPackage() {
if (selectedAsset.value == null || itemQuantityController.text.isEmpty) {
Get.snackbar(
'Error',
'Pilih aset dan masukkan jumlah',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
// Find the selected asset
final asset = availableAssets.firstWhere(
(item) => item['id'] == selectedAsset.value,
orElse: () => <String, dynamic>{},
);
if (asset.isEmpty) return;
// Convert quantity to int
final quantity = int.tryParse(itemQuantityController.text) ?? 0;
if (quantity <= 0) {
Get.snackbar(
'Error',
'Jumlah harus lebih dari 0',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
// Check if quantity is within limits
final remainingStock = getRemainingStock(selectedAsset.value!);
if (quantity > remainingStock) {
Get.snackbar(
'Error',
'Jumlah melebihi stok yang tersedia',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
// Add the item to package
packageItems.add({
'asetId': selectedAsset.value,
'nama': asset['nama'],
'jumlah': quantity,
'stok': asset['stok'],
});
// Clear selection
selectedAsset.value = null;
itemQuantityController.clear();
Get.snackbar(
'Sukses',
'Item berhasil ditambahkan ke paket',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
// Update an existing package item
void updatePackageItem(int index) {
if (selectedAsset.value == null || itemQuantityController.text.isEmpty) {
Get.snackbar(
'Error',
'Pilih aset dan masukkan jumlah',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
// Find the selected asset
final asset = availableAssets.firstWhere(
(item) => item['id'] == selectedAsset.value,
orElse: () => <String, dynamic>{},
);
if (asset.isEmpty) return;
// Convert quantity to int
final quantity = int.tryParse(itemQuantityController.text) ?? 0;
if (quantity <= 0) {
Get.snackbar(
'Error',
'Jumlah harus lebih dari 0',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
// If updating the same asset, check remaining stock + current quantity
final currentItem = packageItems[index];
int availableQuantity = asset['stok'] as int;
// If editing the same asset, we need to consider its current quantity
if (currentItem['asetId'] == selectedAsset.value) {
// For the same asset, we can reuse its current quantity
final alreadyUsed = packageItems
.where(
(item) =>
item['asetId'] == selectedAsset.value &&
packageItems.indexOf(item) != index,
)
.fold(0, (sum, item) => sum + (item['jumlah'] as int));
availableQuantity -= alreadyUsed;
if (quantity > availableQuantity) {
Get.snackbar(
'Error',
'Jumlah melebihi stok yang tersedia',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
} else {
// If changing to a different asset, check the new asset's remaining stock
final remainingStock = getRemainingStock(selectedAsset.value!);
if (quantity > remainingStock) {
Get.snackbar(
'Error',
'Jumlah melebihi stok yang tersedia',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
}
// Update the item
packageItems[index] = {
'asetId': selectedAsset.value,
'nama': asset['nama'],
'jumlah': quantity,
'stok': asset['stok'],
};
// Clear selection
selectedAsset.value = null;
itemQuantityController.clear();
Get.snackbar(
'Sukses',
'Item berhasil diperbarui',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
}
// Remove an item from the package
void removeItem(int index) {
packageItems.removeAt(index);
Get.snackbar(
'Dihapus',
'Item berhasil dihapus dari paket',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.orange,
colorText: Colors.white,
);
}
// Validate form fields
void validateForm() {
// Basic validation
bool basicValid =
nameController.text.isNotEmpty &&
descriptionController.text.isNotEmpty &&
priceController.text.isNotEmpty &&
int.tryParse(priceController.text) != null;
// Package should have at least one item
bool hasItems = packageItems.isNotEmpty;
isFormValid.value = basicValid && hasItems;
}
// Submit form and save package
Future<void> savePaket() async {
if (!isFormValid.value) return;
isSubmitting.value = true;
try {
// In a real app, this would make an API call to save the package
await Future.delayed(const Duration(seconds: 1)); // Mock API call
// Prepare package data
final paketData = {
'nama': nameController.text,
'deskripsi': descriptionController.text,
'kategori': selectedCategory.value,
'status': selectedStatus.value == 'Aktif',
'harga': int.parse(priceController.text),
'gambar': selectedImages,
'items': packageItems,
};
// Log the data (in a real app, this would be sent to an API)
print('Package data: $paketData');
// Return to the package list page
Get.back();
// Show success message
Get.snackbar(
'Berhasil',
'Paket berhasil ditambahkan',
backgroundColor: Colors.green,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
} catch (e) {
// Show error message
Get.snackbar(
'Gagal',
'Terjadi kesalahan: ${e.toString()}',
backgroundColor: Colors.red,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
} finally {
isSubmitting.value = false;
}
}
// Old sample method (will be replaced)
void addSampleItem() {
packageItems.add({'nama': 'Laptop Dell XPS', 'jumlah': 1});
}
// Method untuk menambahkan gambar sample
void addSampleImage() {
// Menambahkan URL gambar dummy untuk keperluan pengembangan
selectedImages.add('https://example.com/sample_image.jpg');
validateForm();
}
}

View File

@ -0,0 +1,581 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/list_pelanggan_aktif_controller.dart';
import '../../../theme/app_colors.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../routes/app_routes.dart';
class ListPelangganAktifView extends GetView<ListPelangganAktifController> {
const ListPelangganAktifView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Get dashboard controller for navigation
final dashboardController = Get.find<PetugasBumdesDashboardController>();
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: Obx(
() => Text(
'Pelanggan ${controller.serviceName.value}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
actions: [
// Actions removed
],
),
drawer: PetugasSideNavbar(controller: dashboardController),
drawerEdgeDragWidth: 60,
drawerScrimColor: Colors.black.withOpacity(0.6),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
_buildSearchBar(),
Expanded(child: _buildSubscribersList()),
],
),
);
}
Widget _buildHeader() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
offset: const Offset(0, 2),
blurRadius: 5,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(
() => Text(
'Pelanggan Aktif ${controller.serviceName.value}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
const SizedBox(height: 8),
Obx(
() => Text(
'Daftar warga yang berlangganan ${controller.serviceName.value.toLowerCase()}',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(30),
),
child: Row(
children: [
const Icon(
Icons.people_alt_rounded,
size: 16,
color: Colors.green,
),
const SizedBox(width: 6),
Obx(
() => Text(
'${controller.pelangganList.length} Pelanggan Aktif',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.green,
),
),
),
],
),
),
],
),
],
),
);
}
Widget _buildSearchBar() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.white,
child: TextField(
onChanged: controller.updateSearchQuery,
decoration: InputDecoration(
hintText: 'Cari pelanggan...',
prefixIcon: const Icon(Icons.search, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
),
);
}
Widget _buildSubscribersList() {
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.filteredPelangganList.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredPelangganList.length,
itemBuilder: (context, index) {
final pelanggan = controller.filteredPelangganList[index];
return _buildPelangganCard(pelanggan);
},
);
});
}
Widget _buildPelangganCard(Map<String, dynamic> pelanggan) {
final statusColor = Color(controller.getStatusColor(pelanggan['status']));
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: InkWell(
onTap:
() => Get.toNamed(
Routes.LIST_TAGIHAN_PERIODE,
arguments: {
'pelanggan': pelanggan,
'serviceName': controller.serviceName.value,
},
),
borderRadius: BorderRadius.circular(16),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 25,
backgroundColor: AppColorsPetugas.babyBlueBright,
child: Text(
pelanggan['nama'].substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pelanggan['nama'],
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
pelanggan['alamat'],
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(Icons.check_circle, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
pelanggan['status'],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildInfoItem(
icon: Icons.calendar_month,
label: 'Mulai',
value: pelanggan['tanggal_mulai'],
),
_buildInfoItem(
icon: Icons.payment,
label: 'Tagihan',
value: pelanggan['tagihan'],
),
_buildInfoItem(
icon: Icons.phone,
label: 'Telepon',
value: pelanggan['telepon'],
),
],
),
),
],
),
),
);
}
Widget _buildInfoItem({
required IconData icon,
required String label,
required String value,
}) {
return Column(
children: [
Icon(icon, size: 16, color: Colors.grey.shade600),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
],
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_alt_outlined,
size: 60,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'Tidak ada pelanggan aktif',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Text(
'Belum ada warga yang berlangganan layanan ini',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
);
}
void _showPelangganDetails(Map<String, dynamic> pelanggan) {
final statusColor = Color(controller.getStatusColor(pelanggan['status']));
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColorsPetugas.navyBlue,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
CircleAvatar(
radius: 25,
backgroundColor: Colors.white,
child: Text(
pelanggan['nama'].substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pelanggan['nama'],
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
pelanggan['alamat'],
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
Icons.check_circle,
size: 14,
color: statusColor,
),
const SizedBox(width: 4),
Text(
pelanggan['status'],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
const SizedBox(height: 20),
const Text(
'Detail Pelanggan',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildDetailRow(
icon: Icons.calendar_month,
label: 'Tanggal Mulai',
value: pelanggan['tanggal_mulai'],
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.event_busy,
label: 'Tanggal Berakhir',
value: pelanggan['tanggal_berakhir'],
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.payment,
label: 'Pembayaran Terakhir',
value: pelanggan['pembayaran_terakhir'],
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.receipt_long,
label: 'Tagihan',
value: pelanggan['tagihan'],
),
const SizedBox(height: 20),
const Text(
'Kontak',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildDetailRow(
icon: Icons.phone,
label: 'Telepon',
value: pelanggan['telepon'],
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.email,
label: 'Email',
value: pelanggan['email'],
),
const SizedBox(height: 20),
if (pelanggan['catatan'] != null) ...[
const Text(
'Catatan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text(
pelanggan['catatan'],
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade800,
),
),
),
const SizedBox(height: 20),
],
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close),
label: const Text('Tutup'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: AppColorsPetugas.navyBlue),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,720 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/list_petugas_mitra_controller.dart';
import '../../../theme/app_colors_petugas.dart';
class ListPetugasMitraView extends GetView<ListPetugasMitraController> {
const ListPetugasMitraView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: const Text(
'Petugas Mitra',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.help_outline),
onPressed: () {
_showHelpDialog(context);
},
),
],
),
body: SafeArea(
child: Column(
children: [
// Search Bar
_buildSearchBar(),
// List of Partners
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.filteredPartners.isEmpty) {
return _buildEmptyState();
}
return _buildPartnersList();
}),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_showAddPartnerDialog(context);
},
backgroundColor: AppColorsPetugas.blueGrotto,
child: const Icon(Icons.add),
),
);
}
Widget _buildSearchBar() {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.white,
child: TextField(
onChanged: (value) {
controller.searchQuery.value = value;
},
decoration: InputDecoration(
hintText: 'Cari petugas mitra...',
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: Obx(() {
return controller.searchQuery.value.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.searchQuery.value = '';
},
)
: const SizedBox.shrink();
}),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(vertical: 0),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 80, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Belum ada petugas mitra',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Text(
'Tambahkan petugas mitra dengan menekan tombol "+" di bawah',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildPartnersList() {
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredPartners.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final partner = controller.filteredPartners[index];
return _buildPartnerCard(partner);
},
);
}
Widget _buildPartnerCard(Map<String, dynamic> partner) {
final isActive = partner['is_active'] as bool;
return Card(
elevation: 2,
shadowColor: Colors.black.withOpacity(0.1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor:
isActive ? Colors.green.shade100 : Colors.red.shade100,
child: Icon(
Icons.person,
color:
isActive ? Colors.green.shade700 : Colors.red.shade700,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
partner['name'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
partner['role'],
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: isActive ? Colors.green.shade50 : Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Text(
isActive ? 'Aktif' : 'Nonaktif',
style: TextStyle(
color:
isActive
? Colors.green.shade700
: Colors.red.shade700,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
_handleMenuAction(value, partner);
},
itemBuilder:
(BuildContext context) => [
PopupMenuItem<String>(
value: 'toggle_status',
child: Row(
children: [
Icon(
isActive ? Icons.toggle_off : Icons.toggle_on,
color:
isActive
? Colors.red.shade700
: Colors.green.shade700,
size: 18,
),
const SizedBox(width: 8),
Text(isActive ? 'Nonaktifkan' : 'Aktifkan'),
],
),
),
const PopupMenuItem<String>(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit, color: Colors.blue, size: 18),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red, size: 18),
SizedBox(width: 8),
Text('Hapus'),
],
),
),
],
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
_buildInfoRow(Icons.phone, 'Kontak', partner['contact']),
const SizedBox(height: 12),
_buildInfoRow(Icons.location_on, 'Alamat', partner['address']),
const SizedBox(height: 12),
_buildInfoRow(
Icons.calendar_today,
'Tanggal Bergabung',
partner['join_date'],
),
],
),
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 16, color: Colors.grey.shade600),
const SizedBox(width: 8),
Text(
'$label:',
style: TextStyle(fontSize: 13, color: Colors.grey.shade700),
),
const SizedBox(width: 4),
Expanded(
child: Text(
value,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
),
],
);
}
void _handleMenuAction(String action, Map<String, dynamic> partner) {
switch (action) {
case 'toggle_status':
controller.togglePartnerStatus(partner['id']);
break;
case 'edit':
_showEditPartnerDialog(Get.context!, partner);
break;
case 'delete':
_showDeleteConfirmationDialog(Get.context!, partner);
break;
}
}
void _showHelpDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Bantuan Petugas Mitra',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 16),
_buildHelpItem(
Icons.add_circle_outline,
'Tambah Mitra',
'Tekan tombol + untuk menambah petugas mitra baru',
),
const SizedBox(height: 12),
_buildHelpItem(
Icons.toggle_on,
'Aktif/Nonaktif',
'Ubah status aktif petugas mitra melalui menu opsi',
),
const SizedBox(height: 12),
_buildHelpItem(
Icons.edit,
'Edit Data',
'Ubah informasi petugas mitra melalui menu opsi',
),
const SizedBox(height: 12),
_buildHelpItem(
Icons.delete,
'Hapus',
'Hapus petugas mitra melalui menu opsi',
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Mengerti'),
),
),
],
),
),
);
},
);
}
Widget _buildHelpItem(IconData icon, String title, String description) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: AppColorsPetugas.blueGrotto, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
description,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
),
],
);
}
void _showAddPartnerDialog(BuildContext context) {
final nameController = TextEditingController();
final contactController = TextEditingController();
final addressController = TextEditingController();
final roleController = TextEditingController();
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tambah Petugas Mitra',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
_buildTextField(nameController, 'Nama Lengkap', Icons.person),
const SizedBox(height: 12),
_buildTextField(
contactController,
'Nomor Kontak',
Icons.phone,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
_buildTextField(
addressController,
'Alamat',
Icons.location_on,
maxLines: 2,
),
const SizedBox(height: 12),
_buildTextField(roleController, 'Jabatan', Icons.work),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColorsPetugas.navyBlue),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
'Batal',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
if (nameController.text.isEmpty ||
contactController.text.isEmpty ||
addressController.text.isEmpty ||
roleController.text.isEmpty) {
Get.snackbar(
'Error',
'Harap isi semua data',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
final newPartner = {
'id':
DateTime.now().millisecondsSinceEpoch
.toString(),
'name': nameController.text,
'contact': contactController.text,
'address': addressController.text,
'role': roleController.text,
'is_active': true,
'join_date':
'${DateTime.now().day} ${_getMonthName(DateTime.now().month)} ${DateTime.now().year}',
};
controller.addPartner(newPartner);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Simpan'),
),
),
],
),
],
),
),
),
);
},
);
}
void _showEditPartnerDialog(
BuildContext context,
Map<String, dynamic> partner,
) {
final nameController = TextEditingController(text: partner['name']);
final contactController = TextEditingController(text: partner['contact']);
final addressController = TextEditingController(text: partner['address']);
final roleController = TextEditingController(text: partner['role']);
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Edit Petugas Mitra',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 20),
_buildTextField(nameController, 'Nama Lengkap', Icons.person),
const SizedBox(height: 12),
_buildTextField(
contactController,
'Nomor Kontak',
Icons.phone,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
_buildTextField(
addressController,
'Alamat',
Icons.location_on,
maxLines: 2,
),
const SizedBox(height: 12),
_buildTextField(roleController, 'Jabatan', Icons.work),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColorsPetugas.navyBlue),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
'Batal',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
if (nameController.text.isEmpty ||
contactController.text.isEmpty ||
addressController.text.isEmpty ||
roleController.text.isEmpty) {
Get.snackbar(
'Error',
'Harap isi semua data',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
final updatedPartner = {
'id': partner['id'],
'name': nameController.text,
'contact': contactController.text,
'address': addressController.text,
'role': roleController.text,
'is_active': partner['is_active'],
'join_date': partner['join_date'],
};
controller.editPartner(
partner['id'],
updatedPartner,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Simpan'),
),
),
],
),
],
),
),
),
);
},
);
}
void _showDeleteConfirmationDialog(
BuildContext context,
Map<String, dynamic> partner,
) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Konfirmasi Penghapusan'),
content: Text(
'Apakah Anda yakin ingin menghapus petugas mitra "${partner['name']}"?',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Batal'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
controller.deletePartner(partner['id']);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Hapus'),
),
],
);
},
);
}
Widget _buildTextField(
TextEditingController controller,
String label,
IconData icon, {
TextInputType? keyboardType,
int maxLines = 1,
}) {
return TextField(
controller: controller,
keyboardType: keyboardType,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
);
}
String _getMonthName(int month) {
const months = [
'',
'Januari',
'Februari',
'Maret',
'April',
'Mei',
'Juni',
'Juli',
'Agustus',
'September',
'Oktober',
'November',
'Desember',
];
return months[month];
}
}

View File

@ -0,0 +1,691 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/list_tagihan_periode_controller.dart';
import '../../../theme/app_colors.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../routes/app_routes.dart';
class ListTagihanPeriodeView extends GetView<ListTagihanPeriodeController> {
const ListTagihanPeriodeView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Get dashboard controller for navigation
final dashboardController = Get.find<PetugasBumdesDashboardController>();
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: Text(
'Riwayat Tagihan',
style: const TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
),
drawer: PetugasSideNavbar(controller: dashboardController),
drawerEdgeDragWidth: 60,
drawerScrimColor: Colors.black.withOpacity(0.6),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeader(), Expanded(child: _buildPeriodeList())],
),
);
}
Widget _buildHeader() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
offset: const Offset(0, 2),
blurRadius: 5,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() {
final pelanggan = controller.pelangganData.value;
final nama = pelanggan['nama'] ?? 'Pelanggan';
return Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: AppColorsPetugas.babyBlueBright,
child: Text(
nama.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nama,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Obx(
() => Text(
'Pelanggan ${controller.serviceName.value}',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
const Icon(
Icons.check_circle,
size: 14,
color: Colors.green,
),
const SizedBox(width: 4),
Text(
'Aktif',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.green,
),
),
],
),
),
],
);
}),
const SizedBox(height: 16),
const Text(
'Riwayat Tagihan Bulanan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'Daftar periode tagihan dan status pembayaran',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildPeriodeList() {
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.filteredPeriodeList.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredPeriodeList.length,
itemBuilder: (context, index) {
final periode = controller.filteredPeriodeList[index];
return _buildPeriodeCard(periode);
},
);
});
}
Widget _buildPeriodeCard(Map<String, dynamic> periode) {
final statusColor = Color(
controller.getStatusColor(periode['status_pembayaran']),
);
final isCurrent = periode['is_current'] ?? false;
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
border:
isCurrent
? Border.all(color: AppColorsPetugas.blueGrotto, width: 2)
: null,
),
child: InkWell(
onTap: () {
Get.snackbar(
'Informasi',
'Detail tagihan untuk periode ini tidak tersedia',
backgroundColor: Colors.orange.withOpacity(0.1),
colorText: Colors.orange.shade800,
duration: const Duration(seconds: 3),
snackPosition: SnackPosition.BOTTOM,
margin: const EdgeInsets.all(8),
);
},
borderRadius: BorderRadius.circular(16),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color:
isCurrent
? AppColorsPetugas.babyBlueBright.withOpacity(0.3)
: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
periode['bulan'].substring(0, 3),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
Text(
periode['tahun'],
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.navyBlue,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Periode ${controller.getPeriodeString(periode)}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.calendar_today,
size: 14,
color: Colors.grey.shade600,
),
const SizedBox(width: 4),
Text(
'Jatuh tempo: 20 ${periode['bulan']} ${periode['tahun']}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
periode['status_pembayaran'].toLowerCase() == 'lunas'
? Icons.check_circle
: Icons.pending,
size: 14,
color: statusColor,
),
const SizedBox(width: 4),
Text(
periode['status_pembayaran'],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Nominal',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
periode['nominal'],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
),
if (periode['status_pembayaran'].toLowerCase() == 'lunas')
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Tanggal Bayar',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 2),
Text(
periode['tanggal_pembayaran'],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
],
)
else
TextButton.icon(
onPressed: () {},
icon: Icon(
Icons.payment,
size: 16,
color: AppColorsPetugas.blueGrotto,
),
label: Text(
'Bayar Sekarang',
style: TextStyle(color: AppColorsPetugas.blueGrotto),
),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(color: AppColorsPetugas.blueGrotto),
),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
),
),
],
),
),
if (isCurrent)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright.withOpacity(0.3),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
child: Center(
child: Text(
'Periode Berjalan',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.navyBlue,
),
),
),
),
],
),
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long, size: 60, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Tidak ada riwayat tagihan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 8),
Text(
'Pelanggan belum memiliki riwayat tagihan',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
);
}
void _showPeriodeDetails(Map<String, dynamic> periode) {
final statusColor = Color(
controller.getStatusColor(periode['status_pembayaran']),
);
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColorsPetugas.navyBlue,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
periode['bulan'].substring(0, 3),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
Text(
periode['tahun'],
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.navyBlue,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Detail Tagihan',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Periode ${controller.getPeriodeString(periode)}',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
),
),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(
periode['status_pembayaran'].toLowerCase() ==
'lunas'
? Icons.check_circle
: Icons.pending,
size: 14,
color: statusColor,
),
const SizedBox(width: 4),
Text(
periode['status_pembayaran'],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
const SizedBox(height: 20),
const Text(
'Informasi Tagihan',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildDetailRow(
icon: Icons.person,
label: 'Pelanggan',
value:
controller.pelangganData.value['nama'] ?? 'Pelanggan',
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.calendar_today,
label: 'Periode',
value: controller.getPeriodeString(periode),
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.attach_money,
label: 'Nominal',
value: periode['nominal'],
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.event,
label: 'Jatuh Tempo',
value: '20 ${periode['bulan']} ${periode['tahun']}',
),
if (periode['status_pembayaran'].toLowerCase() ==
'lunas') ...[
const SizedBox(height: 20),
const Text(
'Informasi Pembayaran',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildDetailRow(
icon: Icons.date_range,
label: 'Tanggal Pembayaran',
value: periode['tanggal_pembayaran'],
),
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.payment,
label: 'Metode Pembayaran',
value: periode['metode_pembayaran'],
),
if (periode['keterangan'] != null) ...[
const SizedBox(height: 12),
_buildDetailRow(
icon: Icons.info_outline,
label: 'Keterangan',
value: periode['keterangan'],
),
],
],
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.close),
label: const Text('Tutup'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildDetailRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: AppColorsPetugas.navyBlue),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,518 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_bumdes_cbp_controller.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../theme/app_colors_petugas.dart';
import '../../../routes/app_routes.dart';
class PetugasBumdesCbpView extends GetView<PetugasBumdesCbpController> {
const PetugasBumdesCbpView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
title: const Text(
'BUMDes CBP',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
),
drawer: _buildDrawer(),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Text
const Text(
'Pengelolaan BUMDes CBP',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
const SizedBox(height: 8),
Text(
'Kelola informasi akun bank dan petugas mitra BUMDes',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const SizedBox(height: 24),
// Main Content
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
// Bank Account Card
_buildInfoCard(
title: 'Rekening Bank',
icon: Icons.account_balance_outlined,
primaryInfo:
'${controller.bankAccounts.length} Rekening Terdaftar',
secondaryInfo:
controller.bankAccounts.isNotEmpty
? 'Rekening Utama: ${controller.bankAccounts.firstWhere((acc) => acc['is_primary'] == true, orElse: () => {'bank_name': 'Tidak ada'})['bank_name']}'
: 'Belum ada rekening utama',
gradient: const LinearGradient(
colors: [Color(0xFF0072B5), Color(0xFF0088CC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showBankAccountsPage,
),
const SizedBox(height: 16),
// Partners Card
_buildInfoCard(
title: 'Petugas Mitra',
icon: Icons.people_outline_rounded,
primaryInfo: '${controller.partners.length} Mitra',
secondaryInfo:
'${controller.partners.where((p) => p['is_active'] == true).length} Mitra Aktif',
gradient: const LinearGradient(
colors: [Color(0xFF00B4D8), Color(0xFF48CAE4)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showPartnersPage,
),
],
),
),
),
],
),
),
),
bottomNavigationBar: _buildBottomNavigationBar(),
);
}
Widget _buildInfoCard({
required String title,
required IconData icon,
required String primaryInfo,
required String secondaryInfo,
required Gradient gradient,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: gradient.colors.first.withOpacity(0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: Colors.white, size: 30),
const SizedBox(width: 12),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
primaryInfo,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
secondaryInfo,
style: TextStyle(
color: Colors.white.withOpacity(0.85),
fontSize: 14,
),
),
const SizedBox(height: 10),
Align(
alignment: Alignment.bottomRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Lihat Detail',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
SizedBox(width: 4),
Icon(Icons.arrow_forward, color: Colors.white, size: 12),
],
),
),
),
],
),
),
),
);
}
Widget _buildBottomNavigationBar() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(0),
topRight: Radius.circular(0),
),
child: BottomNavigationBar(
currentIndex: 5, // BUMDes tab
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: AppColorsPetugas.blueGrotto,
unselectedItemColor: Colors.grey,
selectedLabelStyle: const TextStyle(fontSize: 12),
unselectedLabelStyle: const TextStyle(fontSize: 12),
onTap: (index) {
// Use the dashboard controller to handle tab navigation
// This is typically provided by the parent Dashboard
final dashboardController =
Get.find<PetugasBumdesDashboardController>();
dashboardController.changeTab(index);
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard_outlined),
activeIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.inventory_2_outlined),
activeIcon: Icon(Icons.inventory_2),
label: 'Aset',
),
BottomNavigationBarItem(
icon: Icon(Icons.archive_outlined),
activeIcon: Icon(Icons.archive),
label: 'Paket',
),
BottomNavigationBarItem(
icon: Icon(Icons.assignment_outlined),
activeIcon: Icon(Icons.assignment),
label: 'Sewa',
),
BottomNavigationBarItem(
icon: Icon(Icons.subscriptions_outlined),
activeIcon: Icon(Icons.subscriptions),
label: 'Langganan',
),
BottomNavigationBarItem(
icon: Icon(Icons.business_outlined),
activeIcon: Icon(Icons.business),
label: 'BUMDes',
),
],
),
),
);
}
Widget _buildDrawer() {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: BoxDecoration(color: AppColorsPetugas.navyBlue),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const CircleAvatar(
backgroundColor: Colors.white,
radius: 30,
child: Icon(Icons.person, size: 40, color: Colors.blueGrey),
),
const SizedBox(height: 10),
const Text(
'Admin BUMDes',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
'admin@bumdes.desa.id',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 12,
),
),
],
),
),
ListTile(
leading: const Icon(Icons.dashboard_outlined),
title: const Text('Dashboard'),
onTap: () {
Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD);
},
),
ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: const Text('Kelola Aset'),
onTap: () {
Get.offAllNamed(Routes.PETUGAS_ASET);
},
),
ListTile(
leading: const Icon(Icons.feed_outlined),
title: const Text('Kelola Paket'),
onTap: () {
Get.offAllNamed(Routes.PETUGAS_PAKET);
},
),
ListTile(
leading: const Icon(Icons.assignment_outlined),
title: const Text('Kelola Permintaan Sewa'),
onTap: () {
Get.offAllNamed(Routes.PETUGAS_SEWA);
},
),
ListTile(
leading: const Icon(Icons.subscriptions_outlined),
title: const Text('Kelola Langganan'),
onTap: () {
Get.offAllNamed(Routes.PETUGAS_LANGGANAN);
},
),
ListTile(
leading: const Icon(Icons.business_outlined),
title: const Text('BUMDes CBP'),
tileColor: Colors.blue.shade50,
onTap: () {
Get.back();
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: () {
// Implement logout
Get.offAllNamed(Routes.LOGIN);
},
),
],
),
);
}
// Method to handle navigation to bank accounts management
void _showBankAccountsPage() {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
padding: const EdgeInsets.all(20),
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.account_balance,
color: AppColorsPetugas.blueGrotto,
size: 24,
),
const SizedBox(width: 10),
const Text(
'Rekening Bank',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
Obx(
() =>
controller.bankAccounts.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: Text(
'Belum ada rekening yang terdaftar',
style: TextStyle(color: Colors.grey),
),
),
)
: Column(
children:
controller.bankAccounts
.map(
(account) => _buildBankAccountItem(account),
)
.toList(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Get.back();
// Show the full-screen bank accounts page
Get.snackbar(
'Informasi',
'Menuju halaman kelola rekening bank',
backgroundColor: AppColorsPetugas.blueGrotto,
colorText: Colors.white,
);
},
icon: const Icon(Icons.arrow_forward),
label: const Text('Lihat Semua Rekening'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
),
),
);
}
Widget _buildBankAccountItem(Map<String, dynamic> account) {
final isPrimary = account['is_primary'] as bool;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isPrimary ? AppColorsPetugas.blueGrotto : Colors.grey.shade300,
width: isPrimary ? 2 : 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.credit_card,
color: AppColorsPetugas.blueGrotto,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
account['bank_name'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (isPrimary) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Utama',
style: TextStyle(
color: AppColorsPetugas.blueGrotto,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
account['account_number'],
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
],
),
),
],
),
);
}
// Method to handle navigation to partners management
void _showPartnersPage() {
// Navigate to the ListPetugasMitraView
Get.toNamed(Routes.LIST_PETUGAS_MITRA);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,914 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_manajemen_bumdes_controller.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasManajemenBumdesView
extends GetView<PetugasManajemenBumdesController> {
const PetugasManajemenBumdesView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final dashboardController = Get.find<PetugasBumdesDashboardController>();
return Scaffold(
appBar: AppBar(
title: const Text('Manajemen BUMDes'),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Get.back(),
),
),
body: Obx(
() =>
controller.isLoading.value
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_buildTabBar(),
Expanded(
child: Obx(() {
switch (controller.selectedTabIndex.value) {
case 0:
return _buildProfileTab();
case 1:
return _buildBankAccountTab();
case 2:
return _buildPartnerTab();
default:
return _buildProfileTab();
}
}),
),
],
),
),
bottomNavigationBar: _buildBottomNav(dashboardController),
);
}
Widget _buildTabBar() {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Color(0x29000000),
offset: Offset(0, 3),
blurRadius: 6,
),
],
),
child: Row(
children: [
_buildTab(0, 'Profile'),
_buildTab(1, 'Rekening Bank'),
_buildTab(2, 'Mitra'),
],
),
);
}
Widget _buildTab(int index, String title) {
final isSelected = controller.selectedTabIndex.value == index;
return Expanded(
child: GestureDetector(
onTap: () => controller.changeTab(index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color:
isSelected ? AppColorsPetugas.navyBlue : Colors.transparent,
width: 3,
),
),
),
child: Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color:
isSelected ? AppColorsPetugas.navyBlue : Colors.grey.shade600,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
),
),
),
);
}
Widget _buildProfileTab() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Profile BUMDes',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
_buildProfileForm(),
],
),
);
}
Widget _buildProfileForm() {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
_buildProfileField('Nama BUMDes', 'BUMDes Sejahtera'),
_buildProfileField('Alamat', 'Jl. Desa No. 123, Kecamatan Makmur'),
_buildProfileField('Email', 'bumdes.sejahtera@gmail.com'),
_buildProfileField('Telepon', '081234567890'),
_buildProfileField(
'Deskripsi',
'BUMDes yang bergerak dalam bidang penyewaan aset dan paket untuk masyarakat desa.',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
minimumSize: const Size(double.infinity, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Edit Profile'),
),
],
),
),
);
}
Widget _buildProfileField(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
Text(value, style: const TextStyle(fontSize: 14)),
const Divider(),
],
),
);
}
Widget _buildBankAccountTab() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Rekening Bank',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
ElevatedButton.icon(
onPressed: () => _showAddBankAccountDialog(),
icon: const Icon(Icons.add, size: 16),
label: const Text('Tambah'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: controller.bankAccounts.length,
itemBuilder: (context, index) {
final account = controller.bankAccounts[index];
return _buildBankAccountCard(account, index);
},
),
),
],
),
);
}
Widget _buildBankAccountCard(Map<String, dynamic> account, int index) {
final isPrimary = account['isPrimary'] as bool;
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isPrimary ? AppColorsPetugas.navyBlue : Colors.transparent,
width: isPrimary ? 2 : 0,
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
account['bankName'],
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
PopupMenuButton(
itemBuilder:
(context) => [
if (!isPrimary)
PopupMenuItem(
value: 'primary',
child: const Text('Jadikan Utama'),
),
const PopupMenuItem(value: 'edit', child: Text('Edit')),
const PopupMenuItem(
value: 'delete',
child: Text('Hapus'),
),
],
onSelected: (value) {
switch (value) {
case 'primary':
controller.setPrimaryBankAccount(index);
break;
case 'edit':
_showEditBankAccountDialog(account, index);
break;
case 'delete':
_showDeleteBankAccountDialog(index);
break;
}
},
),
],
),
const SizedBox(height: 8),
_buildBankAccountInfo('Nama Pemilik', account['accountName']),
_buildBankAccountInfo('Nomor Rekening', account['accountNumber']),
if (isPrimary)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColorsPetugas.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Rekening Utama',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.navyBlue,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildBankAccountInfo(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
],
),
);
}
Widget _buildPartnerTab() {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Mitra BUMDes',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
ElevatedButton.icon(
onPressed: () => _showAddPartnerDialog(),
icon: const Icon(Icons.add, size: 16),
label: const Text('Tambah'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: controller.partners.length,
itemBuilder: (context, index) {
final partner = controller.partners[index];
return _buildPartnerCard(partner, index);
},
),
),
],
),
);
}
Widget _buildPartnerCard(Map<String, dynamic> partner, int index) {
final isActive = partner['isActive'] as bool;
return Card(
elevation: 2,
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
partner['name'],
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Row(
children: [
Switch(
value: isActive,
onChanged:
(value) => controller.togglePartnerStatus(index),
activeColor: AppColorsPetugas.navyBlue,
),
PopupMenuButton(
itemBuilder:
(context) => [
const PopupMenuItem(
value: 'edit',
child: Text('Edit'),
),
const PopupMenuItem(
value: 'delete',
child: Text('Hapus'),
),
],
onSelected: (value) {
switch (value) {
case 'edit':
_showEditPartnerDialog(partner, index);
break;
case 'delete':
_showDeletePartnerDialog(index);
break;
}
},
),
],
),
],
),
const SizedBox(height: 8),
_buildPartnerInfo('Email', partner['email']),
_buildPartnerInfo('Telepon', partner['phone']),
_buildPartnerInfo('Alamat', partner['address']),
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
isActive
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
isActive ? 'Aktif' : 'Tidak Aktif',
style: TextStyle(
fontSize: 12,
color: isActive ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
}
Widget _buildPartnerInfo(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
label,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
],
),
);
}
Widget _buildBottomNav(PetugasBumdesDashboardController dashboardController) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildNavItem(Icons.dashboard, 'Dashboard', 0, dashboardController),
_buildNavItem(Icons.inventory, 'Aset', 1, dashboardController),
_buildNavItem(Icons.inventory_2, 'Paket', 2, dashboardController),
_buildNavItem(Icons.shopping_cart, 'Sewa', 3, dashboardController),
_buildNavItem(
Icons.subscriptions,
'Langganan',
4,
dashboardController,
),
],
),
),
);
}
Widget _buildNavItem(
IconData icon,
String label,
int index,
PetugasBumdesDashboardController dashboardController,
) {
return InkWell(
onTap: () => dashboardController.changeTab(index),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: AppColorsPetugas.blueGrotto, size: 24),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(fontSize: 12, color: AppColorsPetugas.blueGrotto),
),
],
),
);
}
void _showAddBankAccountDialog() {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Tambah Rekening Bank',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const TextField(
decoration: InputDecoration(
labelText: 'Nama Bank',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
const TextField(
decoration: InputDecoration(
labelText: 'Nama Pemilik Rekening',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
const TextField(
decoration: InputDecoration(
labelText: 'Nomor Rekening',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Info',
'Fitur tambah rekening bank sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
),
child: const Text('Simpan'),
),
],
),
],
),
),
),
);
}
void _showEditBankAccountDialog(Map<String, dynamic> account, int index) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Edit Rekening Bank',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
decoration: const InputDecoration(
labelText: 'Nama Bank',
border: OutlineInputBorder(),
),
controller: TextEditingController(text: account['bankName']),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Nama Pemilik Rekening',
border: OutlineInputBorder(),
),
controller: TextEditingController(text: account['accountName']),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Nomor Rekening',
border: OutlineInputBorder(),
),
controller: TextEditingController(
text: account['accountNumber'],
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Info',
'Fitur edit rekening bank sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
),
child: const Text('Simpan'),
),
],
),
],
),
),
),
);
}
void _showDeleteBankAccountDialog(int index) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Hapus Rekening Bank',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text(
'Apakah Anda yakin ingin menghapus rekening bank ini?',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Info',
'Fitur hapus rekening bank sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Hapus'),
),
],
),
],
),
),
),
);
}
void _showAddPartnerDialog() {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Tambah Mitra',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const TextField(
decoration: InputDecoration(
labelText: 'Nama Mitra',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
const TextField(
decoration: InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
const TextField(
decoration: InputDecoration(
labelText: 'Telepon',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
const TextField(
decoration: InputDecoration(
labelText: 'Alamat',
border: OutlineInputBorder(),
),
maxLines: 2,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Info',
'Fitur tambah mitra sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
),
child: const Text('Simpan'),
),
],
),
],
),
),
),
);
}
void _showEditPartnerDialog(Map<String, dynamic> partner, int index) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Edit Mitra',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
decoration: const InputDecoration(
labelText: 'Nama Mitra',
border: OutlineInputBorder(),
),
controller: TextEditingController(text: partner['name']),
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
controller: TextEditingController(text: partner['email']),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Telepon',
border: OutlineInputBorder(),
),
controller: TextEditingController(text: partner['phone']),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: 'Alamat',
border: OutlineInputBorder(),
),
controller: TextEditingController(text: partner['address']),
maxLines: 2,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Info',
'Fitur edit mitra sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.navyBlue,
),
child: const Text('Simpan'),
),
],
),
],
),
),
),
);
}
void _showDeletePartnerDialog(int index) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Hapus Mitra',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text(
'Apakah Anda yakin ingin menghapus mitra ini?',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Get.back();
Get.snackbar(
'Info',
'Fitur hapus mitra sedang dalam pengembangan',
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Hapus'),
),
],
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,700 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_paket_controller.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import '../../../routes/app_routes.dart';
class PetugasPaketView extends GetView<PetugasPaketController> {
const PetugasPaketView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Get dashboard controller for navigation
final dashboardController = Get.find<PetugasBumdesDashboardController>();
return WillPopScope(
onWillPop: () async {
// Saat back button ditekan, kembali ke dashboard
dashboardController.changeTab(0);
return false;
},
child: Scaffold(
appBar: AppBar(
title: const Text(
'Manajemen Paket',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.sort, size: 22),
onPressed: () => _showSortingBottomSheet(context),
tooltip: 'Urutkan',
),
const SizedBox(width: 8),
],
),
drawer: PetugasSideNavbar(controller: dashboardController),
drawerEdgeDragWidth: 60,
drawerScrimColor: Colors.black.withOpacity(0.6),
backgroundColor: AppColorsPetugas.babyBlueBright,
body: Column(
children: [_buildSearchBar(), Expanded(child: _buildPaketList())],
),
bottomNavigationBar: Obx(
() => PetugasBumdesBottomNavbar(
selectedIndex: dashboardController.currentTabIndex.value,
onItemTapped: (index) => dashboardController.changeTab(index),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET),
label: Text(
'Tambah Paket',
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColorsPetugas.blueGrotto,
),
),
icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto),
backgroundColor: AppColorsPetugas.babyBlueBright,
),
),
);
}
Widget _buildSearchBar() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: AppColorsPetugas.shadowColor,
blurRadius: 10,
offset: const Offset(0, 1),
),
],
),
child: TextField(
onChanged: controller.setSearchQuery,
decoration: InputDecoration(
hintText: 'Cari paket...',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixIcon: Icon(
Icons.search,
color: AppColorsPetugas.textSecondary,
size: 20,
),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
isDense: true,
),
),
);
}
Widget _buildPaketList() {
return Obx(() {
if (controller.isLoading.value) {
return Center(
child: CircularProgressIndicator(
color: AppColorsPetugas.blueGrotto,
strokeWidth: 3,
),
);
}
if (controller.filteredPaketList.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category_outlined,
size: 80,
color: AppColorsPetugas.babyBlue,
),
const SizedBox(height: 24),
Text(
'Tidak ada paket ditemukan',
style: TextStyle(
fontSize: 16,
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET),
icon: const Icon(Icons.add),
label: const Text('Tambah Paket'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: controller.loadPaketData,
color: AppColorsPetugas.blueGrotto,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: controller.filteredPaketList.length,
itemBuilder: (context, index) {
final paket = controller.filteredPaketList[index];
return _buildPaketCard(context, paket);
},
),
);
});
}
Widget _buildPaketCard(BuildContext context, Map<String, dynamic> paket) {
final isAvailable = paket['tersedia'] == true;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColorsPetugas.shadowColor,
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPaketDetails(context, paket),
child: Row(
children: [
// Paket image or icon
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
child: Center(
child: Icon(
_getPaketIcon(paket['kategori']),
color: AppColorsPetugas.navyBlue,
size: 32,
),
),
),
// Paket info
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Name and price
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
paket['nama'],
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: AppColorsPetugas.navyBlue,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Rp ${_formatPrice(paket['harga'])}',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
),
),
],
),
),
// Status badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
isAvailable
? AppColorsPetugas.successLight
: AppColorsPetugas.errorLight,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
isAvailable
? AppColorsPetugas.success
: AppColorsPetugas.error,
width: 1,
),
),
child: Text(
isAvailable ? 'Aktif' : 'Nonaktif',
style: TextStyle(
fontSize: 10,
color:
isAvailable
? AppColorsPetugas.success
: AppColorsPetugas.error,
fontWeight: FontWeight.w500,
),
),
),
// Action icons
const SizedBox(width: 12),
Row(
children: [
// Edit icon
GestureDetector(
onTap:
() => _showAddEditPaketDialog(
context,
paket: paket,
),
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: AppColorsPetugas.blueGrotto
.withOpacity(0.5),
),
),
child: Icon(
Icons.edit_outlined,
color: AppColorsPetugas.blueGrotto,
size: 16,
),
),
),
const SizedBox(width: 8),
// Delete icon
GestureDetector(
onTap:
() => _showDeleteConfirmation(context, paket),
child: Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppColorsPetugas.errorLight,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: AppColorsPetugas.error.withOpacity(
0.5,
),
),
),
child: Icon(
Icons.delete_outline,
color: AppColorsPetugas.error,
size: 16,
),
),
),
],
),
],
),
),
),
],
),
),
),
),
);
}
String _formatPrice(dynamic price) {
if (price == null) return '0';
// Convert the price to string and handle formatting
String priceStr = price.toString();
// Add thousand separators
final RegExp reg = RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))');
String formatted = priceStr.replaceAllMapped(reg, (Match m) => '${m[1]}.');
return formatted;
}
IconData _getPaketIcon(String? category) {
if (category == null) return Icons.category;
switch (category.toLowerCase()) {
case 'bulanan':
return Icons.calendar_month;
case 'tahunan':
return Icons.calendar_today;
case 'premium':
return Icons.star;
case 'bisnis':
return Icons.business;
default:
return Icons.category;
}
}
void _showSortingBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Urutkan Paket',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 16),
...controller.sortOptions.map((option) {
return Obx(() {
final isSelected = option == controller.sortBy.value;
return RadioListTile<String>(
title: Text(option),
value: option,
groupValue: controller.sortBy.value,
activeColor: AppColorsPetugas.blueGrotto,
onChanged: (value) {
if (value != null) {
controller.setSortBy(value);
Navigator.pop(context);
}
},
);
});
}).toList(),
],
),
);
},
);
}
void _showPaketDetails(BuildContext context, Map<String, dynamic> paket) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return Container(
padding: const EdgeInsets.all(16),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
paket['nama'],
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
),
IconButton(
icon: Icon(Icons.close, color: AppColorsPetugas.blueGrotto),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView(
children: [
Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailItem('Kategori', paket['kategori']),
_buildDetailItem(
'Harga',
controller.formatPrice(paket['harga']),
),
_buildDetailItem(
'Status',
paket['tersedia'] ? 'Tersedia' : 'Tidak Tersedia',
),
_buildDetailItem('Deskripsi', paket['deskripsi']),
],
),
),
),
const SizedBox(height: 16),
Text(
'Item dalam Paket',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 8),
Card(
margin: EdgeInsets.zero,
child: ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: paket['items'].length,
separatorBuilder:
(context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = paket['items'][index];
return ListTile(
leading: CircleAvatar(
backgroundColor: AppColorsPetugas.babyBlue,
child: Icon(
Icons.inventory_2_outlined,
color: AppColorsPetugas.blueGrotto,
size: 16,
),
),
title: Text(item['nama']),
trailing: Text(
'${item['jumlah']} unit',
style: TextStyle(
color: AppColorsPetugas.blueGrotto,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
Get.toNamed(
Routes.PETUGAS_TAMBAH_PAKET,
arguments: paket,
);
},
icon: const Icon(Icons.edit),
label: const Text('Edit'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColorsPetugas.blueGrotto,
side: BorderSide(color: AppColorsPetugas.blueGrotto),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
_showDeleteConfirmation(context, paket);
},
icon: const Icon(Icons.delete),
label: const Text('Hapus'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
);
},
);
}
Widget _buildDetailItem(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 14, color: AppColorsPetugas.blueGrotto),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.navyBlue,
),
),
],
),
);
}
void _showAddEditPaketDialog(
BuildContext context, {
Map<String, dynamic>? paket,
}) {
final isEditing = paket != null;
// This would be implemented with proper form validation in a real app
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
isEditing ? 'Edit Paket' : 'Tambah Paket Baru',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
content: const Text(
'Form pengelolaan paket akan ditampilkan di sini dengan field untuk nama, kategori, harga, deskripsi, status, dan item-item dalam paket.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Batal',
style: TextStyle(color: Colors.grey.shade600),
),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
// In a real app, we would save the form data
Get.snackbar(
isEditing ? 'Paket Diperbarui' : 'Paket Ditambahkan',
isEditing
? 'Paket berhasil diperbarui'
: 'Paket baru berhasil ditambahkan',
backgroundColor: AppColorsPetugas.success,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
),
child: Text(isEditing ? 'Simpan' : 'Tambah'),
),
],
);
},
);
}
void _showDeleteConfirmation(
BuildContext context,
Map<String, dynamic> paket,
) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(
'Konfirmasi Hapus',
style: TextStyle(color: AppColorsPetugas.navyBlue),
),
content: Text(
'Apakah Anda yakin ingin menghapus paket "${paket['nama']}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Batal',
style: TextStyle(color: Colors.grey.shade600),
),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
controller.deletePaket(paket['id']);
Get.snackbar(
'Paket Dihapus',
'Paket berhasil dihapus dari sistem',
backgroundColor: AppColorsPetugas.error,
colorText: Colors.white,
snackPosition: SnackPosition.BOTTOM,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.error,
),
child: const Text('Hapus'),
),
],
);
},
);
}
}

View File

@ -0,0 +1,682 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/petugas_sewa_controller.dart';
import '../../../theme/app_colors_petugas.dart';
import '../widgets/petugas_bumdes_bottom_navbar.dart';
import '../widgets/petugas_side_navbar.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
import 'petugas_detail_sewa_view.dart';
class PetugasSewaView extends StatefulWidget {
const PetugasSewaView({Key? key}) : super(key: key);
@override
State<PetugasSewaView> createState() => _PetugasSewaViewState();
}
class _PetugasSewaViewState extends State<PetugasSewaView>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late PetugasSewaController controller;
late PetugasBumdesDashboardController dashboardController;
@override
void initState() {
super.initState();
controller = Get.find<PetugasSewaController>();
dashboardController = Get.find<PetugasBumdesDashboardController>();
_tabController = TabController(
length: controller.statusFilters.length,
vsync: this,
);
// Add listener to sync tab selection with controller's filter
_tabController.addListener(_onTabChanged);
// Listen to controller's filter changes
ever(controller.selectedStatusFilter, _onFilterChanged);
}
void _onTabChanged() {
if (!_tabController.indexIsChanging) {
final selectedStatus = controller.statusFilters[_tabController.index];
controller.setStatusFilter(selectedStatus);
}
}
void _onFilterChanged(String status) {
final index = controller.statusFilters.indexOf(status);
if (index != -1 && index != _tabController.index) {
_tabController.animateTo(index);
}
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
dashboardController.changeTab(0);
return false;
},
child: Scaffold(
appBar: AppBar(
title: const Text(
'Manajemen Sewa',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
backgroundColor: AppColorsPetugas.navyBlue,
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.filter_alt_outlined, size: 22),
onPressed: () => _showFilterBottomSheet(),
tooltip: 'Filter',
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: const BoxDecoration(
color: AppColorsPetugas.navyBlue,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
child: TabBar(
controller: _tabController,
isScrollable: true,
indicator: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
),
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
labelColor: Colors.white,
unselectedLabelColor: Colors.white.withOpacity(0.7),
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w400,
fontSize: 14,
),
tabs:
controller.statusFilters
.map(
(status) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Tab(text: status),
),
)
.toList(),
dividerColor: Colors.transparent,
),
),
),
),
drawer: PetugasSideNavbar(controller: dashboardController),
drawerEdgeDragWidth: 60,
drawerScrimColor: Colors.black.withOpacity(0.6),
backgroundColor: Colors.grey.shade50,
body: Column(
children: [
_buildSearchSection(),
Expanded(
child: TabBarView(
controller: _tabController,
children:
controller.statusFilters.map((status) {
return _buildSewaListForStatus(status);
}).toList(),
),
),
],
),
bottomNavigationBar: Obx(
() => PetugasBumdesBottomNavbar(
selectedIndex: dashboardController.currentTabIndex.value,
onItemTapped: (index) => dashboardController.changeTab(index),
),
),
),
);
}
Widget _buildSearchSection() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: TextField(
onChanged: (value) {
controller.setSearchQuery(value);
controller.setOrderIdQuery(value);
},
decoration: InputDecoration(
hintText: 'Cari nama warga atau ID pesanan...',
hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14),
prefixIcon: Container(
padding: const EdgeInsets.all(12),
child: Icon(
Icons.search_rounded,
color: AppColorsPetugas.blueGrotto,
size: 22,
),
),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(50),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(50),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(50),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.zero,
isDense: true,
suffixIcon: Icon(
Icons.tune_rounded,
color: AppColorsPetugas.textSecondary,
size: 20,
),
),
),
);
}
Widget _buildSewaListForStatus(String status) {
return Obx(() {
if (controller.isLoading.value) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: AppColorsPetugas.blueGrotto,
strokeWidth: 3,
),
const SizedBox(height: 16),
Text(
'Memuat data...',
style: TextStyle(
color: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
final filteredList =
status == 'Semua'
? controller.filteredSewaList
: status == 'Periksa Pembayaran'
? controller.sewaList
.where(
(sewa) =>
sewa['status'] == 'Periksa Pembayaran' ||
sewa['status'] == 'Pembayaran Denda' ||
sewa['status'] == 'Periksa Denda',
)
.toList()
: controller.sewaList
.where((sewa) => sewa['status'] == status)
.toList();
if (filteredList.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
shape: BoxShape.circle,
),
child: Icon(
Icons.inventory_2_outlined,
size: 70,
color: AppColorsPetugas.blueGrotto,
),
),
const SizedBox(height: 24),
Text(
'Tidak ada sewa ditemukan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 12),
Text(
status == 'Semua'
? 'Belum ada data sewa untuk kriteria yang dipilih'
: status == 'Periksa Pembayaran'
? 'Belum ada data sewa yang perlu pembayaran diverifikasi atau memiliki denda'
: 'Belum ada data sewa dengan status "$status"',
style: TextStyle(
fontSize: 14,
color: AppColorsPetugas.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
);
}
return RefreshIndicator(
onRefresh: controller.loadSewaData,
color: AppColorsPetugas.blueGrotto,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredList.length,
itemBuilder: (context, index) {
final sewa = filteredList[index];
return _buildSewaCard(context, sewa);
},
),
);
});
}
Widget _buildSewaCard(BuildContext context, Map<String, dynamic> sewa) {
final statusColor = controller.getStatusColor(sewa['status']);
final status = sewa['status'];
// Get appropriate icon for status
IconData statusIcon;
switch (status) {
case 'Menunggu Pembayaran':
statusIcon = Icons.payments_outlined;
break;
case 'Periksa Pembayaran':
statusIcon = Icons.fact_check_outlined;
break;
case 'Diterima':
statusIcon = Icons.check_circle_outlined;
break;
case 'Pembayaran Denda':
statusIcon = Icons.money_off_csred_outlined;
break;
case 'Periksa Denda':
statusIcon = Icons.assignment_late_outlined;
break;
case 'Dikembalikan':
statusIcon = Icons.assignment_return_outlined;
break;
case 'Selesai':
statusIcon = Icons.task_alt_outlined;
break;
case 'Dibatalkan':
statusIcon = Icons.cancel_outlined;
break;
default:
statusIcon = Icons.help_outline_rounded;
}
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () => Get.to(() => PetugasDetailSewaView(sewa: sewa)),
borderRadius: BorderRadius.circular(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row(
children: [
// Customer Circle Avatar
CircleAvatar(
radius: 24,
backgroundColor: AppColorsPetugas.babyBlueLight,
child: Text(
sewa['nama_warga'].substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto,
),
),
),
const SizedBox(width: 16),
// Customer details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sewa['nama_warga'],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 2),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
statusIcon,
size: 12,
color: statusColor,
),
const SizedBox(width: 4),
Text(
status,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
const SizedBox(width: 8),
Text(
'#${sewa['order_id']}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
],
),
],
),
),
// Price
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
controller.formatPrice(sewa['total_biaya']),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.blueGrotto,
),
),
),
],
),
),
// Divider
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Divider(height: 1, color: Colors.grey.shade200),
),
// Asset details
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Row(
children: [
// Asset icon
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.inventory_2_outlined,
size: 20,
color: AppColorsPetugas.blueGrotto,
),
),
const SizedBox(width: 12),
// Asset name and duration
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sewa['nama_aset'],
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 2),
Row(
children: [
Icon(
Icons.calendar_today_rounded,
size: 12,
color: AppColorsPetugas.textSecondary,
),
const SizedBox(width: 4),
Text(
'${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
),
),
],
),
],
),
),
// Chevron icon
Icon(
Icons.chevron_right_rounded,
color: AppColorsPetugas.textSecondary,
size: 20,
),
],
),
),
],
),
),
),
);
}
void _showFilterBottomSheet() {
Get.bottomSheet(
Container(
padding: const EdgeInsets.all(20),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Filter',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Get.back(),
),
],
),
const SizedBox(height: 20),
const Text(
'Status',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Obx(() {
return Wrap(
spacing: 8,
children:
controller.statusFilters.map((status) {
final isSelected =
status == controller.selectedStatusFilter.value;
return ChoiceChip(
label: Text(status),
selected: isSelected,
selectedColor: AppColorsPetugas.blueGrotto,
backgroundColor: Colors.white,
labelStyle: TextStyle(
color:
isSelected
? Colors.white
: AppColorsPetugas.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 13,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color:
isSelected
? AppColorsPetugas.blueGrotto
: Colors.grey.shade300,
width: 1,
),
),
onSelected: (selected) {
if (selected) {
controller.setStatusFilter(status);
Get.back();
}
},
);
}).toList(),
);
}),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
OutlinedButton(
onPressed: () {
controller.resetFilters();
Get.back();
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColorsPetugas.blueGrotto),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
child: Text(
'Reset',
style: TextStyle(color: AppColorsPetugas.blueGrotto),
),
),
ElevatedButton(
onPressed: () {
controller.applyFilters();
Get.back();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
child: const Text(
'Terapkan',
style: TextStyle(color: Colors.white),
),
),
],
),
],
),
),
isDismissible: true,
enableDrag: true,
);
}
}

View File

@ -0,0 +1,871 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors_petugas.dart';
import '../controllers/petugas_tambah_aset_controller.dart';
class PetugasTambahAsetView extends GetView<PetugasTambahAsetController> {
const PetugasTambahAsetView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text(
'Tambah Aset',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: AppColorsPetugas.navyBlue,
elevation: 0,
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeaderSection(), _buildFormSection(context)],
),
),
),
bottomNavigationBar: _buildBottomBar(),
);
}
Widget _buildHeaderSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.inventory_2_outlined,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informasi Aset Baru',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Isi data dengan lengkap untuk menambahkan aset',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
],
),
],
),
);
}
Widget _buildFormSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
// Basic Information Section
_buildSectionHeader(
icon: Icons.info_outline,
title: 'Informasi Dasar',
),
const SizedBox(height: 16),
_buildTextField(
label: 'Nama Aset',
hint: 'Masukkan nama aset',
controller: controller.nameController,
isRequired: true,
prefixIcon: Icons.title,
),
const SizedBox(height: 16),
_buildTextField(
label: 'Deskripsi',
hint: 'Masukkan deskripsi aset',
controller: controller.descriptionController,
maxLines: 3,
isRequired: true,
prefixIcon: Icons.description,
),
const SizedBox(height: 24),
// Media Section
_buildSectionHeader(
icon: Icons.photo_library,
title: 'Media & Gambar',
),
const SizedBox(height: 16),
_buildImageUploader(),
const SizedBox(height: 24),
// Category Section
_buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'),
const SizedBox(height: 16),
// Category and Status as cards
Row(
children: [
Expanded(
child: _buildCategorySelect(
title: 'Kategori',
options: controller.categoryOptions,
selectedOption: controller.selectedCategory,
onChanged: controller.setCategory,
icon: Icons.inventory_2,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildCategorySelect(
title: 'Status',
options: controller.statusOptions,
selectedOption: controller.selectedStatus,
onChanged: controller.setStatus,
icon: Icons.check_circle,
),
),
],
),
const SizedBox(height: 24),
// Quantity Section
_buildSectionHeader(
icon: Icons.format_list_numbered,
title: 'Kuantitas & Pengukuran',
),
const SizedBox(height: 16),
// Quantity fields
Row(
children: [
Expanded(
flex: 2,
child: _buildTextField(
label: 'Kuantitas',
hint: 'Jumlah aset',
controller: controller.quantityController,
isRequired: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
prefixIcon: Icons.numbers,
),
),
const SizedBox(width: 12),
Expanded(
flex: 3,
child: _buildTextField(
label: 'Satuan Ukur',
hint: 'contoh: Unit, Buah',
controller: controller.unitOfMeasureController,
prefixIcon: Icons.straighten,
),
),
],
),
const SizedBox(height: 24),
// Rental Options Section
_buildSectionHeader(
icon: Icons.schedule,
title: 'Opsi Waktu & Harga Sewa',
),
const SizedBox(height: 16),
// Time Options as cards
_buildTimeOptionsCards(),
const SizedBox(height: 16),
// Rental price fields based on selection
Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Per Hour Option
if (controller.timeOptions['Per Jam']!.value)
_buildPriceCard(
title: 'Harga Per Jam',
icon: Icons.timer,
priceController: controller.pricePerHourController,
maxController: controller.maxHourController,
maxLabel: 'Maksimal Jam',
),
if (controller.timeOptions['Per Jam']!.value &&
controller.timeOptions['Per Hari']!.value)
const SizedBox(height: 16),
// Per Day Option
if (controller.timeOptions['Per Hari']!.value)
_buildPriceCard(
title: 'Harga Per Hari',
icon: Icons.calendar_today,
priceController: controller.pricePerDayController,
maxController: controller.maxDayController,
maxLabel: 'Maksimal Hari',
),
],
),
),
const SizedBox(height: 40),
],
),
);
}
Widget _buildTimeOptionsCards() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
children:
controller.timeOptions.entries.map((entry) {
final option = entry.key;
final isSelected = entry.value;
return Obx(
() => Material(
color: Colors.transparent,
child: InkWell(
onTap: () => controller.toggleTimeOption(option),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color:
isSelected.value
? AppColorsPetugas.blueGrotto.withOpacity(
0.1,
)
: Colors.grey.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
option == 'Per Jam'
? Icons.hourglass_bottom
: Icons.calendar_today,
color:
isSelected.value
? AppColorsPetugas.blueGrotto
: Colors.grey,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color:
isSelected.value
? AppColorsPetugas.navyBlue
: Colors.grey.shade700,
),
),
const SizedBox(height: 2),
Text(
option == 'Per Jam'
? 'Sewa aset dengan basis perhitungan per jam'
: 'Sewa aset dengan basis perhitungan per hari',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Checkbox(
value: isSelected.value,
onChanged:
(_) => controller.toggleTimeOption(option),
activeColor: AppColorsPetugas.blueGrotto,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
);
}).toList(),
),
);
}
Widget _buildPriceCard({
required String title,
required IconData icon,
required TextEditingController priceController,
required TextEditingController maxController,
required String maxLabel,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.navyBlue,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Harga Sewa',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: priceController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'Masukkan harga',
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
prefixText: 'Rp ',
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
maxLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 8),
TextFormField(
controller: maxController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: 'Opsional',
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
],
),
),
],
),
],
),
);
}
Widget _buildSectionHeader({required IconData icon, required String title}) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 20),
),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
],
);
}
Widget _buildTextField({
required String label,
required String hint,
required TextEditingController controller,
bool isRequired = false,
int maxLines = 1,
TextInputType keyboardType = TextInputType.text,
List<TextInputFormatter>? inputFormatters,
String? prefixText,
IconData? prefixIcon,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
if (isRequired) ...[
const SizedBox(width: 4),
Text(
'*',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
],
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
prefixText: prefixText,
prefixIcon:
prefixIcon != null
? Icon(
prefixIcon,
size: 20,
color: AppColorsPetugas.textSecondary,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColorsPetugas.blueGrotto,
width: 1.5,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
],
);
}
Widget _buildCategorySelect({
required String title,
required List<String> options,
required RxString selectedOption,
required Function(String) onChanged,
required IconData icon,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Obx(
() => DropdownButtonFormField<String>(
value: selectedOption.value,
decoration: InputDecoration(
prefixIcon: Icon(
icon,
color: AppColorsPetugas.blueGrotto,
size: 20,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
),
items:
options.map((option) {
return DropdownMenuItem(
value: option,
child: Text(
option,
style: TextStyle(
color: AppColorsPetugas.textPrimary,
fontSize: 14,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) onChanged(value);
},
icon: Icon(
Icons.keyboard_arrow_down_rounded,
color: AppColorsPetugas.blueGrotto,
),
dropdownColor: Colors.white,
),
),
),
],
);
}
Widget _buildImageUploader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Unggah Foto Aset',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 4),
Text(
'Tambahkan foto aset untuk informasi visual.',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 16),
Obx(
() => Wrap(
spacing: 12,
runSpacing: 12,
children: [
// Add button
GestureDetector(
onTap: () => controller.addSampleImage(),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColorsPetugas.babyBlue,
width: 1,
style: BorderStyle.solid,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate_outlined,
color: AppColorsPetugas.blueGrotto,
size: 32,
),
const SizedBox(height: 8),
Text(
'Tambah Foto',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.blueGrotto,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
// Image previews
...controller.selectedImages.asMap().entries.map((entry) {
final index = entry.key;
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
width: 100,
height: 100,
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
Icons.image,
color: AppColorsPetugas.blueGrotto,
size: 40,
),
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller.removeImage(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Icon(
Icons.close,
color: AppColorsPetugas.error,
size: 16,
),
),
),
),
],
),
);
}),
],
),
),
],
),
);
}
Widget _buildBottomBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Row(
children: [
OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.arrow_back),
label: const Text('Batal'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColorsPetugas.textSecondary,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
side: BorderSide(color: AppColorsPetugas.divider),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() {
final isValid = controller.isFormValid.value;
final isSubmitting = controller.isSubmitting.value;
return ElevatedButton.icon(
onPressed:
isValid && !isSubmitting ? controller.saveAsset : null,
icon:
isSubmitting
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save),
label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Aset'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
disabledBackgroundColor: AppColorsPetugas.textLight,
),
);
}),
),
],
),
);
}
}

View File

@ -0,0 +1,932 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors_petugas.dart';
import '../controllers/petugas_tambah_paket_controller.dart';
class PetugasTambahPaketView extends GetView<PetugasTambahPaketController> {
const PetugasTambahPaketView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text(
'Tambah Paket',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: AppColorsPetugas.navyBlue,
elevation: 0,
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_buildHeaderSection(), _buildFormSection(context)],
),
),
),
bottomNavigationBar: _buildBottomBar(),
);
}
Widget _buildHeaderSection() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.category,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Informasi Paket Baru',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'Isi data dengan lengkap untuk menambahkan paket',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.8),
),
),
],
),
),
],
),
],
),
);
}
Widget _buildFormSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
// Basic Information Section
_buildSectionHeader(
icon: Icons.info_outline,
title: 'Informasi Dasar',
),
const SizedBox(height: 16),
_buildTextField(
label: 'Nama Paket',
hint: 'Masukkan nama paket',
controller: controller.nameController,
isRequired: true,
prefixIcon: Icons.title,
),
const SizedBox(height: 16),
_buildTextField(
label: 'Deskripsi',
hint: 'Masukkan deskripsi paket',
controller: controller.descriptionController,
maxLines: 3,
isRequired: true,
prefixIcon: Icons.description,
),
const SizedBox(height: 24),
// Media Section
_buildSectionHeader(
icon: Icons.photo_library,
title: 'Media & Gambar',
),
const SizedBox(height: 16),
_buildImageUploader(),
const SizedBox(height: 24),
// Category Section
_buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'),
const SizedBox(height: 16),
// Category and Status as cards
Row(
children: [
Expanded(
child: _buildCategorySelect(
title: 'Kategori',
options: controller.categoryOptions,
selectedOption: controller.selectedCategory,
onChanged: controller.setCategory,
icon: Icons.category,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildCategorySelect(
title: 'Status',
options: controller.statusOptions,
selectedOption: controller.selectedStatus,
onChanged: controller.setStatus,
icon: Icons.check_circle,
),
),
],
),
const SizedBox(height: 24),
// Price Section
_buildSectionHeader(
icon: Icons.monetization_on,
title: 'Harga Paket',
),
const SizedBox(height: 16),
_buildTextField(
label: 'Harga Paket',
hint: 'Masukkan harga paket',
controller: controller.priceController,
isRequired: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
prefixText: 'Rp ',
prefixIcon: Icons.payments,
),
const SizedBox(height: 24),
// Package Items Section
_buildSectionHeader(
icon: Icons.inventory_2,
title: 'Item dalam Paket',
),
const SizedBox(height: 16),
_buildPackageItems(),
const SizedBox(height: 40),
],
),
);
}
Widget _buildPackageItems() {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Item Paket',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
ElevatedButton.icon(
onPressed: () => _showAddItemDialog(),
icon: const Icon(Icons.add),
label: const Text('Tambah Item'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.babyBlueLight,
foregroundColor: AppColorsPetugas.blueGrotto,
),
),
],
),
const SizedBox(height: 16),
Obx(
() =>
controller.packageItems.isEmpty
? const Center(
child: Text(
'Belum ada item dalam paket',
style: TextStyle(fontStyle: FontStyle.italic),
),
)
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.packageItems.length,
itemBuilder: (context, index) {
final item = controller.packageItems[index];
return Card(
elevation: 1,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(item['nama'] ?? 'Item Paket'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Jumlah: ${item['jumlah']}'),
if (item['stok'] != null)
Text('Stok tersedia: ${item['stok']}'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(
Icons.edit,
color: Colors.blue,
),
onPressed: () => _showEditItemDialog(index),
),
IconButton(
icon: const Icon(
Icons.delete,
color: Colors.red,
),
onPressed:
() => controller.removeItem(index),
),
],
),
),
);
},
),
),
],
),
),
);
}
void _showAddItemDialog() {
// Reset controllers
controller.selectedAsset.value = null;
controller.itemQuantityController.clear();
// Fetch available assets
controller.fetchAvailableAssets();
Get.dialog(
Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Obx(() {
if (controller.isLoadingAssets.value) {
return const SizedBox(
height: 150,
child: Center(child: CircularProgressIndicator()),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Tambah Item ke Paket',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Asset dropdown
DropdownButtonFormField<int>(
value: controller.selectedAsset.value,
decoration: const InputDecoration(
labelText: 'Pilih Aset',
border: OutlineInputBorder(),
),
hint: const Text('Pilih Aset'),
items:
controller.availableAssets.map((asset) {
return DropdownMenuItem<int>(
value: asset['id'] as int,
child: Text(
'${asset['nama']} (Stok: ${asset['stok']})',
),
);
}).toList(),
onChanged: (value) {
controller.setSelectedAsset(value);
},
),
const SizedBox(height: 16),
// Quantity field
Obx(() {
// Calculate max quantity based on selected asset
String? helperText;
if (controller.selectedAsset.value != null) {
final remaining = controller.getRemainingStock(
controller.selectedAsset.value!,
);
helperText = 'Maksimal: $remaining unit';
}
return TextFormField(
controller: controller.itemQuantityController,
decoration: InputDecoration(
labelText: 'Jumlah',
border: const OutlineInputBorder(),
helperText: helperText,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
);
}),
const SizedBox(height: 24),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
controller.addAssetToPackage();
Get.back();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.babyBlueLight,
foregroundColor: AppColorsPetugas.blueGrotto,
),
child: const Text('Tambah'),
),
],
),
],
);
}),
),
),
);
}
void _showEditItemDialog(int index) {
final item = controller.packageItems[index];
// Set controllers
controller.selectedAsset.value = item['asetId'];
controller.itemQuantityController.text = item['jumlah'].toString();
// Fetch available assets
controller.fetchAvailableAssets();
Get.dialog(
Dialog(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Obx(() {
if (controller.isLoadingAssets.value) {
return const SizedBox(
height: 150,
child: Center(child: CircularProgressIndicator()),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Edit Item Paket',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Asset dropdown
DropdownButtonFormField<int>(
value: controller.selectedAsset.value,
decoration: const InputDecoration(
labelText: 'Pilih Aset',
border: OutlineInputBorder(),
),
hint: const Text('Pilih Aset'),
items:
controller.availableAssets.map((asset) {
return DropdownMenuItem<int>(
value: asset['id'] as int,
child: Text(
'${asset['nama']} (Stok: ${asset['stok']})',
),
);
}).toList(),
onChanged: (value) {
controller.setSelectedAsset(value);
},
),
const SizedBox(height: 16),
// Quantity field
Obx(() {
// Calculate max quantity based on selected asset
String? helperText;
if (controller.selectedAsset.value != null) {
// Get the appropriate max quantity for editing
final currentItem = controller.packageItems[index];
final isCurrentAsset =
currentItem['asetId'] == controller.selectedAsset.value;
int maxQuantity;
if (isCurrentAsset) {
// For same asset, include current quantity in calculation
final asset = controller.availableAssets.firstWhere(
(a) => a['id'] == controller.selectedAsset.value,
orElse: () => {'stok': 0},
);
final totalUsed = controller.packageItems
.where(
(item) =>
item['asetId'] ==
controller.selectedAsset.value &&
controller.packageItems.indexOf(item) != index,
)
.fold(
0,
(sum, item) => sum + (item['jumlah'] as int),
);
maxQuantity = (asset['stok'] as int) - totalUsed;
} else {
// For different asset, use remaining stock
maxQuantity = controller.getRemainingStock(
controller.selectedAsset.value!,
);
}
helperText = 'Maksimal: $maxQuantity unit';
}
return TextFormField(
controller: controller.itemQuantityController,
decoration: InputDecoration(
labelText: 'Jumlah',
border: const OutlineInputBorder(),
helperText: helperText,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
);
}),
const SizedBox(height: 24),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Batal'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
controller.updatePackageItem(index);
Get.back();
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.babyBlueLight,
foregroundColor: AppColorsPetugas.blueGrotto,
),
child: const Text('Simpan'),
),
],
),
],
);
}),
),
),
);
}
Widget _buildSectionHeader({required IconData icon, required String title}) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColorsPetugas.blueGrotto.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 20),
),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColorsPetugas.navyBlue,
),
),
],
);
}
Widget _buildTextField({
required String label,
required String hint,
required TextEditingController controller,
bool isRequired = false,
int maxLines = 1,
TextInputType keyboardType = TextInputType.text,
List<TextInputFormatter>? inputFormatters,
String? prefixText,
IconData? prefixIcon,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
if (isRequired) ...[
const SizedBox(width: 4),
Text(
'*',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
],
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
maxLines: maxLines,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: AppColorsPetugas.textLight),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
prefixText: prefixText,
prefixIcon:
prefixIcon != null
? Icon(
prefixIcon,
size: 20,
color: AppColorsPetugas.textSecondary,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColorsPetugas.blueGrotto,
width: 1.5,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
],
);
}
Widget _buildCategorySelect({
required String title,
required List<String> options,
required RxString selectedOption,
required Function(String) onChanged,
required IconData icon,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColorsPetugas.textPrimary,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Obx(
() => DropdownButtonFormField<String>(
value: selectedOption.value,
decoration: InputDecoration(
prefixIcon: Icon(
icon,
color: AppColorsPetugas.blueGrotto,
size: 20,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
filled: true,
fillColor: AppColorsPetugas.babyBlueBright,
),
items:
options.map((option) {
return DropdownMenuItem(
value: option,
child: Text(
option,
style: TextStyle(
color: AppColorsPetugas.textPrimary,
fontSize: 14,
),
),
);
}).toList(),
onChanged: (value) {
if (value != null) onChanged(value);
},
icon: Icon(
Icons.keyboard_arrow_down_rounded,
color: AppColorsPetugas.blueGrotto,
),
dropdownColor: Colors.white,
),
),
),
],
);
}
Widget _buildImageUploader() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Unggah Foto Paket',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColorsPetugas.navyBlue,
),
),
const SizedBox(height: 4),
Text(
'Tambahkan foto paket untuk informasi visual.',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.textSecondary,
),
),
const SizedBox(height: 16),
Obx(
() => Wrap(
spacing: 12,
runSpacing: 12,
children: [
// Add button
GestureDetector(
onTap: () => controller.addSampleImage(),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueBright,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColorsPetugas.babyBlue,
width: 1,
style: BorderStyle.solid,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_photo_alternate_outlined,
color: AppColorsPetugas.blueGrotto,
size: 32,
),
const SizedBox(height: 8),
Text(
'Tambah Foto',
style: TextStyle(
fontSize: 12,
color: AppColorsPetugas.blueGrotto,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
// Image previews
...controller.selectedImages.asMap().entries.map((entry) {
final index = entry.key;
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColorsPetugas.babyBlueLight,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
width: 100,
height: 100,
color: AppColorsPetugas.babyBlueLight,
child: Center(
child: Icon(
Icons.image,
color: AppColorsPetugas.blueGrotto,
size: 40,
),
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller.removeImage(index),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Icon(
Icons.close,
color: AppColorsPetugas.error,
size: 16,
),
),
),
),
],
),
);
}),
],
),
),
],
),
);
}
Widget _buildBottomBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -5),
),
],
),
child: Row(
children: [
OutlinedButton.icon(
onPressed: () => Get.back(),
icon: const Icon(Icons.arrow_back),
label: const Text('Batal'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColorsPetugas.textSecondary,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
side: BorderSide(color: AppColorsPetugas.divider),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Obx(() {
final isValid = controller.isFormValid.value;
final isSubmitting = controller.isSubmitting.value;
return ElevatedButton.icon(
onPressed:
isValid && !isSubmitting ? controller.savePaket : null,
icon:
isSubmitting
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.save),
label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Paket'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColorsPetugas.blueGrotto,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
disabledBackgroundColor: AppColorsPetugas.textLight,
),
);
}),
),
],
),
);
}
}

View File

@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../routes/app_routes.dart';
import '../../../theme/app_colors.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasBumdesBottomNavbar extends StatelessWidget {
final int selectedIndex;
final Function(int) onItemTapped;
const PetugasBumdesBottomNavbar({
super.key,
required this.selectedIndex,
required this.onItemTapped,
});
@override
Widget build(BuildContext context) {
return Container(
height: 76,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.07),
blurRadius: 14,
offset: const Offset(0, -2),
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(
context: context,
icon: Icons.dashboard_outlined,
activeIcon: Icons.dashboard,
label: 'Dashboard',
isSelected: selectedIndex == 0,
onTap: () => onItemTapped(0),
),
_buildNavItem(
context: context,
icon: Icons.inventory_2_outlined,
activeIcon: Icons.inventory_2,
label: 'Aset',
isSelected: selectedIndex == 1,
onTap: () => onItemTapped(1),
),
_buildNavItem(
context: context,
icon: Icons.category_outlined,
activeIcon: Icons.category,
label: 'Paket',
isSelected: selectedIndex == 2,
onTap: () => onItemTapped(2),
),
_buildNavItem(
context: context,
icon: Icons.shopping_cart_outlined,
activeIcon: Icons.shopping_cart,
label: 'Sewa',
isSelected: selectedIndex == 3,
onTap: () => onItemTapped(3),
),
],
),
);
}
// Modern navigation item for bottom bar
Widget _buildNavItem({
required BuildContext context,
required IconData icon,
required IconData activeIcon,
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
final primaryColor = AppColors.primary;
final tabWidth = MediaQuery.of(context).size.width / 4;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
splashColor: primaryColor.withOpacity(0.1),
highlightColor: primaryColor.withOpacity(0.05),
child: SizedBox(
width: tabWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Indicator line at top
AnimatedContainer(
duration: const Duration(milliseconds: 250),
height: 2,
width: tabWidth * 0.5,
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: isSelected ? primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
// Icon with animated scale effect when selected
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.all(isSelected ? 8 : 0),
decoration: BoxDecoration(
color:
isSelected
? primaryColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isSelected ? activeIcon : icon,
color: isSelected ? primaryColor : Colors.grey.shade400,
size: 22,
),
),
const SizedBox(height: 4),
// Label with animated opacity
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? primaryColor : Colors.grey.shade500,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../theme/app_colors.dart';
import '../controllers/petugas_bumdes_dashboard_controller.dart';
class PetugasSideNavbar extends StatelessWidget {
final PetugasBumdesDashboardController controller;
const PetugasSideNavbar({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return Drawer(
backgroundColor: Colors.white,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(0),
bottomRight: Radius.circular(0),
),
),
child: Column(
children: [
_buildHeader(),
Expanded(child: _buildMenu()),
_buildFooter(context),
],
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.fromLTRB(20, 60, 20, 20),
color: AppColors.primary,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.white,
child: Icon(Icons.person, color: AppColors.primary, size: 36),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Petugas BUMDes',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Obx(
() => Text(
controller.userEmail.value,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
],
),
);
}
Widget _buildMenu() {
return Obx(
() => ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_buildSectionHeader('Menu Utama'),
_buildMenuItem(
icon: Icons.dashboard_outlined,
activeIcon: Icons.dashboard,
title: 'Dashboard',
subtitle: 'Ringkasan aktivitas',
isSelected: controller.currentTabIndex.value == 0,
onTap: () => controller.changeTab(0),
),
_buildMenuItem(
icon: Icons.inventory_2_outlined,
activeIcon: Icons.inventory_2,
title: 'Aset',
subtitle: 'Kelola aset BUMDes',
isSelected: controller.currentTabIndex.value == 1,
onTap: () => controller.changeTab(1),
),
_buildMenuItem(
icon: Icons.category_outlined,
activeIcon: Icons.category,
title: 'Paket',
subtitle: 'Kelola paket aset',
isSelected: controller.currentTabIndex.value == 2,
onTap: () => controller.changeTab(2),
),
_buildMenuItem(
icon: Icons.shopping_cart_outlined,
activeIcon: Icons.shopping_cart,
title: 'Sewa',
subtitle: 'Kelola sewa aset',
isSelected: controller.currentTabIndex.value == 3,
onTap: () => controller.changeTab(3),
),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
title,
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
);
}
Widget _buildMenuItem({
required IconData icon,
required IconData activeIcon,
required String title,
required String subtitle,
required bool isSelected,
required VoidCallback onTap,
}) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isSelected ? AppColors.primarySoft : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color:
isSelected
? AppColors.primary.withOpacity(0.15)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
isSelected ? activeIcon : icon,
color: isSelected ? AppColors.primary : Colors.grey.shade600,
size: 20,
),
),
title: Text(
title,
style: TextStyle(
color: isSelected ? AppColors.primary : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
fontSize: 15,
),
),
subtitle: Text(
subtitle,
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
trailing:
isSelected
? Container(
width: 4,
height: 36,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(10),
),
)
: null,
onTap: onTap,
),
);
}
Widget _buildFooter(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.shade200,
blurRadius: 4,
offset: const Offset(0, -1),
),
],
),
child: Column(
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.logout, color: Colors.red.shade400, size: 20),
),
title: const Text(
'Keluar',
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 15),
),
subtitle: const Text(
'Keluar dari aplikasi',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
onTap: () => _showLogoutConfirmation(context),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'© 2025 BumRent App',
style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.asset(
'assets/images/logo.png',
width: 24,
height: 24,
),
),
],
),
],
),
);
}
void _showLogoutConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Konfirmasi Keluar'),
content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
foregroundColor: Colors.grey.shade700,
),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
controller.logout();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade400,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Keluar'),
),
],
);
},
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
class PetugasMitraDashboardController extends GetxController {
final AuthProvider _authProvider = Get.find<AuthProvider>();
// Observable user data
final userEmail = ''.obs;
final currentTabIndex = 0.obs;
@override
void onInit() {
super.onInit();
_loadUserEmail();
}
// Load user email from auth provider
Future<void> _loadUserEmail() async {
try {
final user = _authProvider.currentUser;
userEmail.value = user?.email ?? 'User';
} catch (e) {
debugPrint('Error loading user email: $e');
}
}
// Change tab index
void changeTab(int index) {
currentTabIndex.value = index;
}
// Logout function
void logout() async {
try {
await _authProvider.signOut();
Get.offAllNamed(Routes.LOGIN);
} catch (e) {
debugPrint('Error signing out: $e');
Get.snackbar(
'Error',
'Gagal keluar dari aplikasi',
snackPosition: SnackPosition.BOTTOM,
);
}
}
}

View File

@ -0,0 +1,28 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../../../routes/app_routes.dart';
class SplashController extends GetxController {
late Timer _timer;
@override
void onInit() {
super.onInit();
debugPrint('SplashController onInit called');
// Menggunakan Timer alih-alih Future.delayed
_timer = Timer(const Duration(seconds: 3), () {
debugPrint('Timer completed, navigating to LOGIN');
// Gunakan Get.offAll untuk menghapus semua rute sebelumnya
Get.offAllNamed(Routes.LOGIN);
});
}
@override
void onClose() {
// Pastikan timer dibatalkan saat controller ditutup
_timer.cancel();
super.onClose();
}
}

View File

@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dart:math' as math;
import 'dart:ui';
import '../controllers/splash_controller.dart';
import '../../../theme/app_colors.dart';
class SplashView extends GetView<SplashController> {
const SplashView({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [
AppColors.primaryLight.withOpacity(0.1),
AppColors.background,
AppColors.accentLight.withOpacity(0.1),
],
),
),
child: Stack(
children: [
// Pattern overlay
Opacity(
opacity: 0.03,
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/pattern.png'),
repeat: ImageRepeat.repeat,
scale: 4.0,
),
),
),
),
// Accent circles
Positioned(
top: -40,
right: -20,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.primary.withOpacity(0.2),
Colors.transparent,
],
),
),
),
),
Positioned(
bottom: -50,
left: -30,
child: Container(
width: 180,
height: 180,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.accent.withOpacity(0.2),
Colors.transparent,
],
),
),
),
),
// Main content
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animated Logo
TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(seconds: 1),
curve: Curves.easeOutBack,
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: Image.asset(
'assets/images/logo.png',
width: 180,
height: 180,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Icon(
Icons.business,
size: 100,
color: AppColors.primary,
);
},
),
);
},
),
const SizedBox(height: 40),
// Animated loading indicator
_DelayedAnimation(
delay: 400,
child: Column(
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
strokeWidth: 3,
),
),
const SizedBox(height: 16),
],
),
),
],
),
),
],
),
),
);
}
}
// Animation helper class
class _DelayedAnimation extends StatefulWidget {
final Widget child;
final int delay;
const _DelayedAnimation({required this.child, required this.delay});
@override
_DelayedAnimationState createState() => _DelayedAnimationState();
}
class _DelayedAnimationState extends State<_DelayedAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _animOffset;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
final curve = CurvedAnimation(
parent: _controller,
curve: Curves.decelerate,
);
_animOffset = Tween<Offset>(
begin: const Offset(0.0, 0.35),
end: Offset.zero,
).animate(curve);
Future.delayed(Duration(milliseconds: widget.delay), () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _controller,
child: SlideTransition(position: _animOffset, child: widget.child),
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/auth_provider.dart';
import '../controllers/order_sewa_aset_controller.dart';
class OrderSewaAsetBinding extends Bindings {
@override
void dependencies() {
debugPrint('⚡ OrderSewaAsetBinding: dependencies called');
final box = GetStorage();
// Ensure providers are registered
if (!Get.isRegistered<AsetProvider>()) {
debugPrint('⚡ Registering AsetProvider');
Get.put(AsetProvider(), permanent: true);
}
if (!Get.isRegistered<AuthProvider>()) {
debugPrint('⚡ Registering AuthProvider');
Get.put(AuthProvider(), permanent: true);
}
// Check if we have the asetId in arguments
final args = Get.arguments;
debugPrint('⚡ Arguments received in binding: $args');
String? asetId;
if (args != null && args.containsKey('asetId') && args['asetId'] != null) {
asetId = args['asetId'].toString();
if (asetId.isNotEmpty) {
debugPrint('✅ Valid asetId found in arguments: $asetId');
// Simpan ID di storage untuk digunakan saat hot reload
box.write('current_aset_id', asetId);
debugPrint('💾 Saved asetId to GetStorage in binding: $asetId');
} else {
debugPrint('⚠️ Warning: Empty asetId found in arguments');
}
} else {
debugPrint(
'⚠️ Warning: No valid asetId found in arguments, checking storage',
);
// Cek apakah ada ID tersimpan di storage
if (box.hasData('current_aset_id')) {
asetId = box.read<String>('current_aset_id');
debugPrint('📦 Found asetId in GetStorage: $asetId');
}
}
// Only delete the existing controller if we're not in a hot reload situation
if (Get.isRegistered<OrderSewaAsetController>()) {
// Check if we're going through a hot reload by looking at the controller's state
final existingController = Get.find<OrderSewaAsetController>();
if (existingController.aset.value == null) {
// Controller exists but doesn't have data, likely a fresh navigation or reload
debugPrint('⚡ Removing old OrderSewaAsetController without data');
Get.delete<OrderSewaAsetController>(force: true);
// Use put instead of lazyPut to ensure controller is created immediately
debugPrint('⚡ Creating new OrderSewaAsetController');
Get.put(OrderSewaAsetController());
} else {
// Controller exists and has data, leave it alone during hot reload
debugPrint(
'🔥 Hot reload detected, preserving existing controller with data',
);
}
} else {
// No controller exists, create a new one
debugPrint('⚡ Creating new OrderSewaAsetController (first time)');
Get.put(OrderSewaAsetController());
}
}
}

View File

@ -0,0 +1,22 @@
import 'package:get/get.dart';
import '../controllers/order_sewa_paket_controller.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/sewa_provider.dart';
class OrderSewaPaketBinding extends Bindings {
@override
void dependencies() {
// Ensure providers are registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider());
}
if (!Get.isRegistered<SewaProvider>()) {
Get.put(SewaProvider());
}
Get.lazyPut<OrderSewaPaketController>(
() => OrderSewaPaketController(),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import '../controllers/pembayaran_sewa_controller.dart';
class PembayaranSewaBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<PembayaranSewaController>(() => PembayaranSewaController());
}
}

View File

@ -0,0 +1,16 @@
import 'package:get/get.dart';
import '../controllers/sewa_aset_controller.dart';
import '../../../data/providers/aset_provider.dart';
class SewaAsetBinding extends Bindings {
@override
void dependencies() {
// Register AsetProvider if not already registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register SewaAsetController
Get.lazyPut<SewaAsetController>(() => SewaAsetController());
}
}

View File

@ -0,0 +1,34 @@
import 'package:get/get.dart';
import '../controllers/warga_sewa_controller.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../../../services/navigation_service.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../data/providers/aset_provider.dart';
class WargaSewaBinding extends Bindings {
@override
void dependencies() {
// Ensure NavigationService is registered and set to Sewa tab
if (Get.isRegistered<NavigationService>()) {
final navService = Get.find<NavigationService>();
navService.setNavIndex(1); // Set to Sewa tab
}
// Ensure AuthProvider is registered
if (!Get.isRegistered<AuthProvider>()) {
Get.put(AuthProvider(), permanent: true);
}
// Ensure AsetProvider is registered
if (!Get.isRegistered<AsetProvider>()) {
Get.put(AsetProvider(), permanent: true);
}
// Register WargaDashboardController if not already registered
if (!Get.isRegistered<WargaDashboardController>()) {
Get.put(WargaDashboardController());
}
Get.lazyPut<WargaSewaController>(() => WargaSewaController());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:intl/intl.dart';
import 'package:flutter_logs/flutter_logs.dart';
import '../../../data/models/paket_model.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/providers/sewa_provider.dart';
import '../../../services/service_manager.dart';
import '../../../services/navigation_service.dart';
class OrderSewaPaketController extends GetxController {
// Dependencies
final AsetProvider asetProvider = Get.find<AsetProvider>();
final SewaProvider sewaProvider = Get.find<SewaProvider>();
final NavigationService navigationService = ServiceManager().navigationService;
// State variables
final paket = Rx<PaketModel?>(null);
final paketImages = RxList<String>([]);
final isLoading = RxBool(true);
final isPhotosLoading = RxBool(true);
final selectedSatuanWaktu = Rx<Map<String, dynamic>?>(null);
final selectedDate = RxString('');
final selectedStartDate = Rx<DateTime?>(null);
final selectedEndDate = Rx<DateTime?>(null);
final selectedStartTime = RxInt(-1);
final selectedEndTime = RxInt(-1);
final formattedDateRange = RxString('');
final formattedTimeRange = RxString('');
final totalPrice = RxDouble(0.0);
final kuantitas = RxInt(1);
final isSubmitting = RxBool(false);
// Format currency
final currencyFormat = NumberFormat.currency(
locale: 'id',
symbol: 'Rp',
decimalDigits: 0,
);
@override
void onInit() {
super.onInit();
FlutterLogs.logInfo("OrderSewaPaketController", "onInit", "Initializing OrderSewaPaketController");
// Get the paket ID from arguments
final Map<String, dynamic> args = Get.arguments ?? {};
final String? paketId = args['id'];
if (paketId != null) {
loadPaketData(paketId);
} else {
debugPrint('❌ No paket ID provided in arguments');
isLoading.value = false;
}
}
// Handle hot reload - restore state if needed
void handleHotReload() {
if (paket.value == null) {
final Map<String, dynamic> args = Get.arguments ?? {};
final String? paketId = args['id'];
if (paketId != null) {
// Try to get from cache first
final cachedPaket = GetStorage().read('cached_paket_$paketId');
if (cachedPaket != null) {
debugPrint('🔄 Hot reload: Restoring paket from cache');
paket.value = cachedPaket;
loadPaketPhotos(paketId);
initializePriceOptions();
} else {
loadPaketData(paketId);
}
}
}
}
// Load paket data from API
Future<void> loadPaketData(String id) async {
try {
isLoading.value = true;
debugPrint('🔍 Loading paket data for ID: $id');
// First check if we have it in cache
final cachedPaket = GetStorage().read('cached_paket_$id');
if (cachedPaket != null) {
debugPrint('✅ Found cached paket data');
paket.value = cachedPaket;
await loadPaketPhotos(id);
initializePriceOptions();
} else {
// Get all pakets and filter for the one we need
final List<dynamic> allPakets = await asetProvider.getPakets();
final rawPaket = allPakets.firstWhere(
(paket) => paket['id'] == id,
orElse: () => null,
);
// Declare loadedPaket outside the if block for wider scope
PaketModel? loadedPaket;
if (rawPaket != null) {
// Convert to PaketModel
try {
// Handle Map directly - pakets from getPakets() are always maps
loadedPaket = PaketModel.fromMap(rawPaket);
debugPrint('✅ Successfully converted paket to PaketModel');
} catch (e) {
debugPrint('❌ Error converting paket map to PaketModel: $e');
// Fallback using our helper methods
loadedPaket = PaketModel(
id: getPaketId(rawPaket),
nama: getPaketNama(rawPaket),
deskripsi: getPaketDeskripsi(rawPaket),
harga: getPaketHarga(rawPaket),
kuantitas: getPaketKuantitas(rawPaket),
foto_paket: getPaketMainPhoto(rawPaket),
satuanWaktuSewa: getPaketSatuanWaktuSewa(rawPaket),
);
debugPrint('✅ Created PaketModel using helper methods');
}
// Update the state with the loaded paket
if (loadedPaket != null) {
debugPrint('✅ Loaded paket: ${loadedPaket.nama}');
paket.value = loadedPaket;
// Cache for future use
GetStorage().write('cached_paket_$id', loadedPaket);
// Load photos for this paket
await loadPaketPhotos(id);
// Set initial pricing option
initializePriceOptions();
// Ensure we have at least one photo if available
if (paketImages.isEmpty) {
String? mainPhoto = getPaketMainPhoto(paket.value);
if (mainPhoto != null && mainPhoto.isNotEmpty) {
paketImages.add(mainPhoto);
debugPrint('✅ Added main paket photo: $mainPhoto');
}
}
}
} else {
debugPrint('❌ No paket found with id: $id');
}
}
// Calculate the total price if we have a paket loaded
if (paket.value != null) {
calculateTotalPrice();
debugPrint('💰 Total price calculated: ${totalPrice.value}');
}
} catch (e) {
debugPrint('❌ Error loading paket data: $e');
} finally {
isLoading.value = false;
}
}
// Helper methods to safely access paket properties
String? getPaketId(dynamic paket) {
if (paket == null) return null;
try {
return paket.id ?? paket['id'];
} catch (_) {
return null;
}
}
String? getPaketNama(dynamic paket) {
if (paket == null) return null;
try {
return paket.nama ?? paket['nama'];
} catch (_) {
return null;
}
}
String? getPaketDeskripsi(dynamic paket) {
if (paket == null) return null;
try {
return paket.deskripsi ?? paket['deskripsi'];
} catch (_) {
return null;
}
}
double getPaketHarga(dynamic paket) {
if (paket == null) return 0.0;
try {
var harga = paket.harga ?? paket['harga'] ?? 0;
return double.tryParse(harga.toString()) ?? 0.0;
} catch (_) {
return 0.0;
}
}
int getPaketKuantitas(dynamic paket) {
if (paket == null) return 1;
try {
var qty = paket.kuantitas ?? paket['kuantitas'] ?? 1;
return int.tryParse(qty.toString()) ?? 1;
} catch (_) {
return 1;
}
}
String? getPaketMainPhoto(dynamic paket) {
if (paket == null) return null;
try {
return paket.foto_paket ?? paket['foto_paket'];
} catch (_) {
return null;
}
}
List<dynamic> getPaketSatuanWaktuSewa(dynamic paket) {
if (paket == null) return [];
try {
return paket.satuanWaktuSewa ?? paket['satuanWaktuSewa'] ?? [];
} catch (_) {
return [];
}
}
// Load photos for the paket
Future<void> loadPaketPhotos(String paketId) async {
try {
isPhotosLoading.value = true;
final photos = await asetProvider.getFotoPaket(paketId);
if (photos != null && photos.isNotEmpty) {
paketImages.clear();
for (var photo in photos) {
try {
if (photo.fotoPaket != null && photo.fotoPaket.isNotEmpty) {
paketImages.add(photo.fotoPaket);
} else if (photo.fotoAset != null && photo.fotoAset.isNotEmpty) {
paketImages.add(photo.fotoAset);
}
} catch (e) {
var fotoUrl = photo['foto_paket'] ?? photo['foto_aset'];
if (fotoUrl != null && fotoUrl.isNotEmpty) {
paketImages.add(fotoUrl);
}
}
}
}
} finally {
isPhotosLoading.value = false;
}
}
// Initialize price options
void initializePriceOptions() {
if (paket.value == null) return;
final satuanWaktuSewa = getPaketSatuanWaktuSewa(paket.value);
if (satuanWaktuSewa.isNotEmpty) {
// Default to the first option
selectSatuanWaktu(satuanWaktuSewa.first);
}
}
// Select satuan waktu
void selectSatuanWaktu(Map<String, dynamic> satuanWaktu) {
selectedSatuanWaktu.value = satuanWaktu;
// Reset date and time selections
selectedStartDate.value = null;
selectedEndDate.value = null;
selectedStartTime.value = -1;
selectedEndTime.value = -1;
selectedDate.value = '';
formattedDateRange.value = '';
formattedTimeRange.value = '';
calculateTotalPrice();
}
// Check if the rental is daily
bool isDailyRental() {
final namaSatuan = selectedSatuanWaktu.value?['nama_satuan_waktu'] ?? '';
return namaSatuan.toString().toLowerCase().contains('hari');
}
// Select date range for daily rental
void selectDateRange(DateTime start, DateTime end) {
selectedStartDate.value = start;
selectedEndDate.value = end;
// Format the date range
final formatter = DateFormat('d MMM yyyy', 'id');
if (start.year == end.year && start.month == end.month && start.day == end.day) {
formattedDateRange.value = formatter.format(start);
} else {
formattedDateRange.value = '${formatter.format(start)} - ${formatter.format(end)}';
}
selectedDate.value = formatter.format(start);
calculateTotalPrice();
}
// Select date for hourly rental
void selectDate(DateTime date) {
selectedStartDate.value = date;
selectedDate.value = DateFormat('d MMM yyyy', 'id').format(date);
calculateTotalPrice();
}
// Select time range for hourly rental
void selectTimeRange(int start, int end) {
selectedStartTime.value = start;
selectedEndTime.value = end;
// Format the time range
final startTime = '$start:00';
final endTime = '$end:00';
formattedTimeRange.value = '$startTime - $endTime';
calculateTotalPrice();
}
// Calculate total price
void calculateTotalPrice() {
if (selectedSatuanWaktu.value == null) {
totalPrice.value = 0.0;
return;
}
final basePrice = double.tryParse(selectedSatuanWaktu.value!['harga'].toString()) ?? 0.0;
if (isDailyRental()) {
if (selectedStartDate.value != null && selectedEndDate.value != null) {
final days = selectedEndDate.value!.difference(selectedStartDate.value!).inDays + 1;
totalPrice.value = basePrice * days;
} else {
totalPrice.value = basePrice;
}
} else {
if (selectedStartTime.value >= 0 && selectedEndTime.value >= 0) {
final hours = selectedEndTime.value - selectedStartTime.value;
totalPrice.value = basePrice * hours;
} else {
totalPrice.value = basePrice;
}
}
// Multiply by quantity
totalPrice.value *= kuantitas.value;
}
// Format price as currency
String formatPrice(double price) {
return currencyFormat.format(price);
}
// Submit order
Future<void> submitOrder() async {
try {
if (paket.value == null || selectedSatuanWaktu.value == null) {
Get.snackbar(
'Error',
'Data paket tidak lengkap',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
if ((isDailyRental() && (selectedStartDate.value == null || selectedEndDate.value == null)) ||
(!isDailyRental() && (selectedStartDate.value == null || selectedStartTime.value < 0 || selectedEndTime.value < 0))) {
Get.snackbar(
'Error',
'Silakan pilih waktu sewa',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
return;
}
isSubmitting.value = true;
// Prepare order data
final Map<String, dynamic> orderData = {
'id_paket': paket.value!.id,
'id_satuan_waktu_sewa': selectedSatuanWaktu.value!['id'],
'tanggal_mulai': selectedStartDate.value!.toIso8601String(),
'tanggal_selesai': selectedEndDate.value?.toIso8601String() ?? selectedStartDate.value!.toIso8601String(),
'jam_mulai': isDailyRental() ? null : selectedStartTime.value,
'jam_selesai': isDailyRental() ? null : selectedEndTime.value,
'total_harga': totalPrice.value,
'kuantitas': kuantitas.value,
};
// Submit the order
final result = await sewaProvider.createPaketOrder(orderData);
if (result != null) {
Get.snackbar(
'Sukses',
'Pesanan berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
// Navigate to payment page
navigationService.navigateToPembayaranSewa(result['id']);
} else {
Get.snackbar(
'Error',
'Gagal membuat pesanan',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
} catch (e) {
debugPrint('❌ Error submitting order: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan: $e',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isSubmitting.value = false;
}
}
// Handle back button press
void onBackPressed() {
navigationService.navigateToSewaAset();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,471 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../../../data/providers/aset_provider.dart';
import '../../../data/models/aset_model.dart';
import '../../../routes/app_routes.dart';
import '../../../data/models/pesanan_model.dart';
import '../../../data/models/satuan_waktu_model.dart';
import '../../../data/models/satuan_waktu_sewa_model.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../data/providers/pesanan_provider.dart';
import '../../../services/navigation_service.dart';
import '../../../services/service_manager.dart';
import 'package:get_storage/get_storage.dart';
class SewaAsetController extends GetxController
with GetSingleTickerProviderStateMixin {
final AsetProvider _asetProvider = Get.find<AsetProvider>();
final AuthProvider authProvider = Get.find<AuthProvider>();
final PesananProvider pesananProvider = Get.put(PesananProvider());
final NavigationService navigationService = Get.find<NavigationService>();
final box = GetStorage();
// Tab controller
late TabController tabController;
// Reactive tab index
final currentTabIndex = 0.obs;
// State variables
final asets = <AsetModel>[].obs;
final filteredAsets = <AsetModel>[].obs;
// Paket-related variables
final pakets = RxList<dynamic>([]);
final filteredPakets = RxList<dynamic>([]);
final isLoadingPakets = false.obs;
final isLoading = true.obs;
// Search controller
final TextEditingController searchController = TextEditingController();
// Reactive variables
final isOrdering = false.obs;
final selectedAset = Rx<AsetModel?>(null);
final selectedSatuanWaktuSewa = Rx<SatuanWaktuSewaModel?>(null);
final selectedDurasi = 1.obs;
final totalHarga = 0.obs;
final selectedDate = DateTime.now().obs;
final selectedTime = '08:00'.obs;
final satuanWaktuDropdownItems =
<DropdownMenuItem<SatuanWaktuSewaModel>>[].obs;
// Flag untuk menangani hot reload
final hasInitialized = false.obs;
@override
void onInit() {
super.onInit();
debugPrint('🚀 SewaAsetController: onInit called');
// Initialize tab controller
tabController = TabController(length: 2, vsync: this);
// Listen for tab changes
tabController.addListener(() {
currentTabIndex.value = tabController.index;
// Load packages data when switching to package tab for the first time
if (currentTabIndex.value == 1 && pakets.isEmpty) {
loadPakets();
}
});
loadAsets();
searchController.addListener(() {
if (currentTabIndex.value == 0) {
filterAsets(searchController.text);
} else {
filterPakets(searchController.text);
}
});
hasInitialized.value = true;
}
@override
void onReady() {
super.onReady();
debugPrint('🚀 SewaAsetController: onReady called');
}
@override
void onClose() {
debugPrint('🧹 SewaAsetController: onClose called');
searchController.dispose();
tabController.dispose();
super.onClose();
}
// Method untuk menangani hot reload
void handleHotReload() {
debugPrint('🔥 Hot reload detected in SewaAsetController');
if (!hasInitialized.value) {
debugPrint('🔄 Reinitializing SewaAsetController after hot reload');
loadAsets();
if (currentTabIndex.value == 1) {
loadPakets();
}
hasInitialized.value = true;
}
}
// Method untuk menangani tombol back
void onBackPressed() {
debugPrint('🔙 Back button pressed in SewaAsetView');
navigationService.backFromSewaAset();
}
Future<void> loadAsets() async {
try {
isLoading.value = true;
final sewaAsets = await _asetProvider.getSewaAsets();
// Debug data satuan waktu sewa yang diterima
debugPrint('===== DEBUG ASET & SATUAN WAKTU SEWA =====');
for (var aset in sewaAsets) {
debugPrint('Aset: ${aset.nama} (ID: ${aset.id})');
if (aset.satuanWaktuSewa.isEmpty) {
debugPrint(' - Tidak ada satuan waktu sewa yang terkait');
} else {
debugPrint(
' - Memiliki ${aset.satuanWaktuSewa.length} satuan waktu sewa:',
);
for (var sws in aset.satuanWaktuSewa) {
debugPrint(' * ID: ${sws['id']}');
debugPrint(' Aset ID: ${sws['aset_id']}');
debugPrint(' Satuan Waktu ID: ${sws['satuan_waktu_id']}');
debugPrint(' Harga: ${sws['harga']}');
debugPrint(' Nama Satuan Waktu: ${sws['nama_satuan_waktu']}');
debugPrint(' -----');
}
}
debugPrint('=====================================');
}
asets.assignAll(sewaAsets);
filteredAsets.assignAll(sewaAsets);
// Tambahkan log info tentang jumlah aset yang berhasil dimuat
debugPrint('Loaded ${sewaAsets.length} aset sewa successfully');
} catch (e) {
debugPrint('Error loading asets: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data aset',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoading.value = false;
}
}
void filterAsets(String query) {
if (query.isEmpty) {
filteredAsets.assignAll(asets);
} else {
filteredAsets.assignAll(
asets
.where(
(aset) => aset.nama.toLowerCase().contains(query.toLowerCase()),
)
.toList(),
);
}
}
void refreshAsets() {
loadAsets();
}
String formatPrice(dynamic price) {
if (price == null) return 'Rp 0';
// Handle different types
num numericPrice;
if (price is int || price is double) {
numericPrice = price;
} else if (price is String) {
numericPrice = double.tryParse(price) ?? 0;
} else {
return 'Rp 0';
}
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
return formatter.format(numericPrice);
}
void selectAset(AsetModel aset) {
selectedAset.value = aset;
// Reset related values
selectedSatuanWaktuSewa.value = null;
selectedDurasi.value = 1;
totalHarga.value = 0;
// Prepare dropdown items for satuan waktu sewa
updateSatuanWaktuDropdown();
}
void updateSatuanWaktuDropdown() {
satuanWaktuDropdownItems.clear();
if (selectedAset.value != null &&
selectedAset.value!.satuanWaktuSewa.isNotEmpty) {
for (var item in selectedAset.value!.satuanWaktuSewa) {
final satuanWaktuSewa = SatuanWaktuSewaModel.fromJson(item);
satuanWaktuDropdownItems.add(
DropdownMenuItem<SatuanWaktuSewaModel>(
value: satuanWaktuSewa,
child: Text(
'${satuanWaktuSewa.namaSatuanWaktu ?? "Unknown"} - Rp${NumberFormat.decimalPattern('id').format(satuanWaktuSewa.harga)}',
),
),
);
}
}
}
void selectSatuanWaktu(SatuanWaktuSewaModel? satuanWaktuSewa) {
selectedSatuanWaktuSewa.value = satuanWaktuSewa;
calculateTotalPrice();
}
void updateDurasi(int durasi) {
if (durasi < 1) durasi = 1;
selectedDurasi.value = durasi;
calculateTotalPrice();
}
void calculateTotalPrice() {
if (selectedSatuanWaktuSewa.value != null) {
totalHarga.value =
selectedSatuanWaktuSewa.value!.harga * selectedDurasi.value;
} else {
totalHarga.value = 0;
}
}
void pickDate(DateTime date) {
selectedDate.value = date;
}
void pickTime(String time) {
selectedTime.value = time;
}
// Helper method to show error snackbar
void _showError(String message) {
Get.snackbar(
'Error',
message,
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
// Method untuk melakukan pemesanan
Future<void> placeOrderAset() async {
if (selectedAset.value == null) {
_showError('Silakan pilih aset terlebih dahulu');
return;
}
if (selectedSatuanWaktuSewa.value == null) {
_showError('Silakan pilih satuan waktu sewa');
return;
}
if (selectedDurasi.value <= 0) {
_showError('Durasi sewa harus lebih dari 0');
return;
}
final userId = authProvider.getCurrentUserId();
if (userId == null) {
_showError('Anda belum login, silakan login terlebih dahulu');
return;
}
try {
final result = await _asetProvider.orderAset(
userId: userId,
asetId: selectedAset.value!.id,
satuanWaktuSewaId: selectedSatuanWaktuSewa.value!.id,
durasi: selectedDurasi.value,
totalHarga: totalHarga.value,
);
if (result) {
Get.snackbar(
'Sukses',
'Pesanan berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
resetSelections();
} else {
_showError('Gagal membuat pesanan');
}
} catch (e) {
_showError('Terjadi kesalahan: $e');
}
}
// Method untuk reset pilihan setelah pemesanan berhasil
void resetSelections() {
selectedAset.value = null;
selectedSatuanWaktuSewa.value = null;
selectedDurasi.value = 1;
totalHarga.value = 0;
}
// Load packages data from paket table
Future<void> loadPakets() async {
try {
isLoadingPakets.value = true;
// Call the provider method to get paket data
final paketData = await _asetProvider.getPakets();
// Debug paket data
debugPrint('===== DEBUG PAKET & SATUAN WAKTU SEWA =====');
for (var paket in paketData) {
debugPrint('Paket: ${paket['nama']} (ID: ${paket['id']})');
if (paket['satuanWaktuSewa'] == null ||
paket['satuanWaktuSewa'].isEmpty) {
debugPrint(' - Tidak ada satuan waktu sewa yang terkait');
} else {
debugPrint(
' - Memiliki ${paket['satuanWaktuSewa'].length} satuan waktu sewa:',
);
for (var sws in paket['satuanWaktuSewa']) {
debugPrint(' * ID: ${sws['id']}');
debugPrint(' Paket ID: ${sws['paket_id']}');
debugPrint(' Satuan Waktu ID: ${sws['satuan_waktu_id']}');
debugPrint(' Harga: ${sws['harga']}');
debugPrint(' Nama Satuan Waktu: ${sws['nama_satuan_waktu']}');
debugPrint(' -----');
}
}
debugPrint('=====================================');
}
pakets.assignAll(paketData);
filteredPakets.assignAll(paketData);
debugPrint('Loaded ${paketData.length} paket successfully');
} catch (e) {
debugPrint('Error loading pakets: $e');
Get.snackbar(
'Error',
'Terjadi kesalahan saat memuat data paket',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoadingPakets.value = false;
}
}
// Method to filter pakets based on search query
void filterPakets(String query) {
if (query.isEmpty) {
filteredPakets.assignAll(pakets);
} else {
filteredPakets.assignAll(
pakets
.where(
(paket) => paket['nama'].toString().toLowerCase().contains(
query.toLowerCase(),
),
)
.toList(),
);
}
}
void refreshPakets() {
loadPakets();
}
// Method to load paket data
Future<void> loadPaketData() async {
try {
isLoadingPakets.value = true;
final result = await _asetProvider.getPakets();
if (result != null) {
pakets.clear();
filteredPakets.clear();
pakets.addAll(result);
filteredPakets.addAll(result);
}
} catch (e) {
debugPrint('Error loading pakets: $e');
Get.snackbar(
'Error',
'Gagal memuat data paket. Silakan coba lagi nanti.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
} finally {
isLoadingPakets.value = false;
}
}
// Method for placing an order for a paket
Future<void> placeOrderPaket({
required String paketId,
required String satuanWaktuSewaId,
required int durasi,
required int totalHarga,
}) async {
debugPrint('===== PLACE ORDER PAKET =====');
debugPrint('paketId: $paketId');
debugPrint('satuanWaktuSewaId: $satuanWaktuSewaId');
debugPrint('durasi: $durasi');
debugPrint('totalHarga: $totalHarga');
final userId = authProvider.getCurrentUserId();
if (userId == null) {
_showError('Anda belum login, silakan login terlebih dahulu');
return;
}
try {
final result = await _asetProvider.orderPaket(
userId: userId,
paketId: paketId,
satuanWaktuSewaId: satuanWaktuSewaId,
durasi: durasi,
totalHarga: totalHarga,
);
if (result) {
Get.snackbar(
'Sukses',
'Pesanan paket berhasil dibuat',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
} else {
_showError('Gagal membuat pesanan paket');
}
} catch (e) {
_showError('Terjadi kesalahan: $e');
}
}
}

View File

@ -0,0 +1,180 @@
import 'package:get/get.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
class WargaDashboardController extends GetxController {
// Dependency injection
final AuthProvider _authProvider = Get.find<AuthProvider>();
final NavigationService navigationService = Get.find<NavigationService>();
// User data
final userName = 'Pengguna Warga'.obs;
final userRole = 'Warga'.obs;
final userAvatar = Rx<String?>(null);
final userEmail = ''.obs;
final userNik = ''.obs;
final userPhone = ''.obs;
final userAddress = ''.obs;
// Navigation state is now managed by NavigationService
// Sample data (would be loaded from API)
final activeRentals = <Map<String, dynamic>>[].obs;
// Active bills
final activeBills = <Map<String, dynamic>>[].obs;
// Active penalties
final activePenalties = <Map<String, dynamic>>[].obs;
@override
void onInit() {
super.onInit();
// Set navigation index to Home (0)
navigationService.setNavIndex(0);
// Load user data
_loadUserData();
// Load sample data
_loadSampleData();
// Load dummy data for bills and penalties
loadDummyData();
// Load unpaid rentals
loadUnpaidRentals();
}
Future<void> _loadUserData() async {
try {
// Get the full name from warga_desa table
final fullName = await _authProvider.getUserFullName();
if (fullName != null && fullName.isNotEmpty) {
userName.value = fullName;
}
// Get the avatar URL
final avatar = await _authProvider.getUserAvatar();
userAvatar.value = avatar;
// Get the role name
final roleId = await _authProvider.getUserRoleId();
if (roleId != null) {
final roleName = await _authProvider.getRoleName(roleId);
if (roleName != null) {
userRole.value = roleName;
}
}
// Load additional user data
// In a real app, these would come from the API/database
userEmail.value = await _authProvider.getUserEmail() ?? '';
userNik.value = await _authProvider.getUserNIK() ?? '';
userPhone.value = await _authProvider.getUserPhone() ?? '';
userAddress.value = await _authProvider.getUserAddress() ?? '';
} catch (e) {
print('Error loading user data: $e');
}
}
void _loadSampleData() {
// Clear any existing data
activeRentals.clear();
// Load active rentals from API
// For now, using sample data
activeRentals.add({
'id': '1',
'name': 'Kursi',
'time': '24 April 2023, 10:00 - 12:00',
'duration': '2 jam',
'price': 'Rp50.000',
'can_extend': true,
});
}
void extendRental(String rentalId) {
// Implementasi untuk memperpanjang sewa
// Seharusnya melakukan API call ke backend
}
void endRental(String rentalId) {
// Implementasi untuk mengakhiri sewa
// Seharusnya melakukan API call ke backend
}
void navigateToRentals() {
// Navigate to SewaAset using the navigation service
navigationService.toSewaAset();
}
void refreshData() {
// Refresh data from repository
_loadSampleData();
loadDummyData();
}
void onNavItemTapped(int index) {
if (navigationService.currentNavIndex.value == index) {
return; // Don't do anything if same tab
}
navigationService.setNavIndex(index);
switch (index) {
case 0:
// Already on Home tab
break;
case 1:
// Navigate to Sewa page
navigationService.toWargaSewa();
break;
}
}
void logout() async {
await _authProvider.signOut();
navigationService.toLogin();
}
void loadDummyData() {
// Dummy active bills
activeBills.clear();
activeBills.add({
'id': '1',
'title': 'Tagihan Air',
'due_date': '30 Apr 2023',
'amount': 'Rp 125.000',
});
activeBills.add({
'id': '2',
'title': 'Sewa Aula Desa',
'due_date': '15 Apr 2023',
'amount': 'Rp 350.000',
});
// Dummy active penalties
activePenalties.clear();
activePenalties.add({
'id': '1',
'title': 'Keterlambatan Sewa Traktor',
'days_late': '7',
'amount': 'Rp 75.000',
});
}
Future<void> loadUnpaidRentals() async {
try {
final results = await _authProvider.getSewaAsetByStatus([
'MENUNGGU PEMBAYARAN',
'PEMBAYARANAN DENDA',
]);
activeBills.value = results;
} catch (e) {
print('Error loading unpaid rentals: $e');
}
}
}

View File

@ -0,0 +1,710 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
import '../../../data/providers/auth_provider.dart';
import '../../../data/providers/aset_provider.dart';
class WargaSewaController extends GetxController
with GetSingleTickerProviderStateMixin {
late TabController tabController;
// Get navigation service
final NavigationService navigationService = Get.find<NavigationService>();
// Get auth provider for user data and sewa_aset queries
final AuthProvider authProvider = Get.find<AuthProvider>();
// Get aset provider for asset data
final AsetProvider asetProvider = Get.find<AsetProvider>();
// Observable lists for different rental statuses
final rentals = <Map<String, dynamic>>[].obs;
final pendingRentals = <Map<String, dynamic>>[].obs;
final acceptedRentals = <Map<String, dynamic>>[].obs;
final completedRentals = <Map<String, dynamic>>[].obs;
final cancelledRentals = <Map<String, dynamic>>[].obs;
// Loading states
final isLoading = false.obs;
final isLoadingPending = false.obs;
final isLoadingAccepted = false.obs;
final isLoadingCompleted = false.obs;
final isLoadingCancelled = false.obs;
@override
void onInit() {
super.onInit();
// Ensure tab index is set to Sewa (1)
navigationService.setNavIndex(1);
// Initialize tab controller with 6 tabs
tabController = TabController(length: 6, vsync: this);
// Set initial tab and ensure tab view is updated
tabController.index = 0;
// Load real rental data for all tabs
loadRentalsData();
loadPendingRentals();
loadAcceptedRentals();
loadCompletedRentals();
loadCancelledRentals();
// Listen to tab changes to update state if needed
tabController.addListener(() {
// Update selected tab index when changed via swipe
final int currentIndex = tabController.index;
debugPrint('Tab changed to index: $currentIndex');
// Load data for the selected tab if not already loaded
switch (currentIndex) {
case 0: // Belum Bayar
if (rentals.isEmpty && !isLoading.value) {
loadRentalsData();
}
break;
case 1: // Pending
if (pendingRentals.isEmpty && !isLoadingPending.value) {
loadPendingRentals();
}
break;
case 2: // Diterima
if (acceptedRentals.isEmpty && !isLoadingAccepted.value) {
loadAcceptedRentals();
}
break;
case 3: // Aktif
// Add Aktif tab logic when needed
break;
case 4: // Selesai
if (completedRentals.isEmpty && !isLoadingCompleted.value) {
loadCompletedRentals();
}
break;
case 5: // Dibatalkan
if (cancelledRentals.isEmpty && !isLoadingCancelled.value) {
loadCancelledRentals();
}
break;
}
});
}
@override
void onReady() {
super.onReady();
// Ensure nav index is set to Sewa (1) when the controller is ready
// This helps maintain correct state during hot reload
navigationService.setNavIndex(1);
}
@override
void onClose() {
tabController.dispose();
super.onClose();
}
// Load real data from sewa_aset table
Future<void> loadRentalsData() async {
try {
isLoading.value = true;
// Clear existing data
rentals.clear();
// Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA"
final sewaAsetList = await authProvider.getSewaAsetByStatus([
'MENUNGGU PEMBAYARAN',
'PEMBAYARAN DENDA'
]);
debugPrint('Fetched ${sewaAsetList.length} sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to rentals list
rentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'MENUNGGU PEMBAYARAN',
'totalPrice': totalPrice,
'countdown': '00:59:59', // Default countdown
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${rentals.length} rental records');
} catch (e) {
debugPrint('Error loading rentals data: $e');
} finally {
isLoading.value = false;
}
}
// Navigation methods
void navigateToRentals() {
navigationService.toSewaAset();
}
void onNavItemTapped(int index) {
if (navigationService.currentNavIndex.value == index) return;
navigationService.setNavIndex(index);
switch (index) {
case 0:
// Navigate to Home
Get.offNamed(Routes.WARGA_DASHBOARD);
break;
case 1:
// Already on Sewa tab
break;
case 2:
// Navigate to Langganan
Get.offNamed(Routes.LANGGANAN);
break;
}
}
// Actions
void cancelRental(String id) {
Get.snackbar(
'Info',
'Pembatalan berhasil',
snackPosition: SnackPosition.BOTTOM,
);
}
// Navigate to payment page with the selected rental data
void viewRentalDetail(Map<String, dynamic> rental) {
debugPrint('Navigating to payment page with rental ID: ${rental['id']}');
// Navigate to payment page with rental data
Get.toNamed(
Routes.PEMBAYARAN_SEWA,
arguments: {
'orderId': rental['id'],
'rentalData': rental,
},
);
}
void payRental(String id) {
Get.snackbar(
'Info',
'Navigasi ke halaman pembayaran',
snackPosition: SnackPosition.BOTTOM,
);
}
// Load data for the Selesai tab (status: SELESAI)
Future<void> loadCompletedRentals() async {
try {
isLoadingCompleted.value = true;
// Clear existing data
completedRentals.clear();
// Get sewa_aset data with status "SELESAI"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['SELESAI']);
debugPrint('Fetched ${sewaAsetList.length} completed sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to completed rentals list
completedRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'SELESAI',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${completedRentals.length} completed rental records');
} catch (e) {
debugPrint('Error loading completed rentals data: $e');
} finally {
isLoadingCompleted.value = false;
}
}
// Load data for the Dibatalkan tab (status: DIBATALKAN)
Future<void> loadCancelledRentals() async {
try {
isLoadingCancelled.value = true;
// Clear existing data
cancelledRentals.clear();
// Get sewa_aset data with status "DIBATALKAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['DIBATALKAN']);
debugPrint('Fetched ${sewaAsetList.length} cancelled sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to cancelled rentals list
cancelledRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'DIBATALKAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
'alasanPembatalan': sewaAset['alasan_pembatalan'] ?? '-',
});
}
debugPrint('Processed ${cancelledRentals.length} cancelled rental records');
} catch (e) {
debugPrint('Error loading cancelled rentals data: $e');
} finally {
isLoadingCancelled.value = false;
}
}
// Load data for the Pending tab (status: PERIKSA PEMBAYARAN)
Future<void> loadPendingRentals() async {
try {
isLoadingPending.value = true;
// Clear existing data
pendingRentals.clear();
// Get sewa_aset data with status "PERIKSA PEMBAYARAN"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['PERIKSA PEMBAYARAN']);
debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to pending rentals list
pendingRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'PERIKSA PEMBAYARAN',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${pendingRentals.length} pending rental records');
} catch (e) {
debugPrint('Error loading pending rentals data: $e');
} finally {
isLoadingPending.value = false;
}
}
// Load data for the Diterima tab (status: DITERIMA)
Future<void> loadAcceptedRentals() async {
try {
isLoadingAccepted.value = true;
// Clear existing data
acceptedRentals.clear();
// Get sewa_aset data with status "DITERIMA"
final sewaAsetList = await authProvider.getSewaAsetByStatus(['DITERIMA']);
debugPrint('Fetched ${sewaAsetList.length} accepted sewa_aset records');
// Process each sewa_aset record
for (var sewaAset in sewaAsetList) {
// Get asset details if aset_id is available
String assetName = 'Aset';
String? imageUrl;
String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam';
if (sewaAset['aset_id'] != null) {
final asetData = await asetProvider.getAsetById(sewaAset['aset_id']);
if (asetData != null) {
assetName = asetData.nama;
imageUrl = asetData.imageUrl;
}
}
// Parse waktu mulai and waktu selesai
DateTime? waktuMulai;
DateTime? waktuSelesai;
String waktuSewa = '';
String tanggalSewa = '';
String jamMulai = '';
String jamSelesai = '';
String rentangWaktu = '';
if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) {
waktuMulai = DateTime.parse(sewaAset['waktu_mulai']);
waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']);
// Format for display
final formatTanggal = DateFormat('dd-MM-yyyy');
final formatWaktu = DateFormat('HH:mm');
final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID');
tanggalSewa = formatTanggalLengkap.format(waktuMulai);
jamMulai = formatWaktu.format(waktuMulai);
jamSelesai = formatWaktu.format(waktuSelesai);
// Format based on satuan waktu
if (namaSatuanWaktu.toLowerCase() == 'jam') {
// For hours, show time range on same day
rentangWaktu = '$jamMulai - $jamSelesai';
} else if (namaSatuanWaktu.toLowerCase() == 'hari') {
// For days, show date range
final tanggalMulai = formatTanggalLengkap.format(waktuMulai);
final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai);
rentangWaktu = '$tanggalMulai - $tanggalSelesai';
} else {
// Default format
rentangWaktu = '$jamMulai - $jamSelesai';
}
// Full time format for waktuSewa
waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - '
'${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}';
}
// Format price
String totalPrice = 'Rp 0';
if (sewaAset['total'] != null) {
final formatter = NumberFormat.currency(
locale: 'id',
symbol: 'Rp ',
decimalDigits: 0,
);
totalPrice = formatter.format(sewaAset['total']);
}
// Add to accepted rentals list
acceptedRentals.add({
'id': sewaAset['id'] ?? '',
'name': assetName,
'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg',
'jumlahUnit': sewaAset['kuantitas'] ?? 0,
'waktuSewa': waktuSewa,
'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}',
'status': sewaAset['status'] ?? 'DITERIMA',
'totalPrice': totalPrice,
'tanggalSewa': tanggalSewa,
'jamMulai': jamMulai,
'jamSelesai': jamSelesai,
'rentangWaktu': rentangWaktu,
'namaSatuanWaktu': namaSatuanWaktu,
'waktuMulai': sewaAset['waktu_mulai'],
'waktuSelesai': sewaAset['waktu_selesai'],
});
}
debugPrint('Processed ${acceptedRentals.length} accepted rental records');
} catch (e) {
debugPrint('Error loading accepted rentals data: $e');
} finally {
isLoadingAccepted.value = false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,981 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../controllers/order_sewa_paket_controller.dart';
import '../../../data/models/paket_model.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:flutter_logs/flutter_logs.dart';
import '../../../theme/app_colors.dart';
import 'package:intl/intl.dart';
class OrderSewaPaketView extends GetView<OrderSewaPaketController> {
const OrderSewaPaketView({super.key});
// Function to show confirmation dialog
void showOrderConfirmationDialog() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
final totalPrice = controller.totalPrice.value;
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
child: Container(
width: double.infinity,
padding: EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with success icon
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.primarySoft,
shape: BoxShape.circle,
),
child: Icon(
Icons.check_circle_outline_rounded,
color: AppColors.primary,
size: 40,
),
),
SizedBox(height: 20),
// Title
Text(
'Konfirmasi Pesanan',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
SizedBox(height: 6),
// Subtitle
Text(
'Periksa detail pesanan Anda',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
SizedBox(height: 24),
// Order details
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight),
),
child: Column(
children: [
// Paket name
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paket',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Text(
paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
),
],
),
Divider(height: 24, color: AppColors.divider),
// Duration info
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Durasi',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Obx(
() => Text(
controller.isDailyRental()
? controller.formattedDateRange.value
: '${controller.selectedDate.value}, ${controller.formattedTimeRange.value}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
],
),
),
],
),
Divider(height: 24, color: AppColors.divider),
// Total price info
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Obx(
() => Text(
controller.formatPrice(controller.totalPrice.value),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
],
),
),
],
),
],
),
),
SizedBox(height: 24),
// Action buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Get.back(),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: AppColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Batal',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
),
SizedBox(width: 16),
Expanded(
child: Obx(
() => ElevatedButton(
onPressed: controller.isSubmitting.value
? null
: () {
Get.back();
controller.submitOrder();
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
backgroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.isSubmitting.value
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'Pesan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
// Handle hot reload by checking if controller needs to be reset
WidgetsBinding.instance.addPostFrameCallback((_) {
// This will be called after the widget tree is built
controller.handleHotReload();
// Ensure navigation service is registered for back button functionality
if (!Get.isRegistered<NavigationService>()) {
Get.put(NavigationService());
debugPrint('✅ Created new NavigationService instance in view');
}
});
// Function to handle back button press
void handleBackButtonPress() {
debugPrint('🔙 Back button pressed - navigating to SewaAsetView');
try {
// First try to use the controller's method
controller.onBackPressed();
} catch (e) {
debugPrint('⚠️ Error handling back via controller: $e');
// Fallback to direct navigation
Get.back();
}
}
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: handleBackButtonPress,
),
title: Text(
'Pesan Paket',
style: TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
),
body: Obx(
() => controller.isLoading.value
? Center(child: CircularProgressIndicator())
: controller.paket.value == null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline_rounded,
size: 64,
color: AppColors.error,
),
SizedBox(height: 16),
Text(
'Paket tidak ditemukan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
SizedBox(height: 8),
Text(
'Silakan kembali dan pilih paket lain',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: handleBackButtonPress,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
padding: EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text('Kembali'),
),
],
),
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTopSection(),
_buildPaketDetails(),
_buildPriceOptions(),
_buildDateSelection(context),
SizedBox(height: 100), // Space for bottom bar
],
),
),
),
bottomSheet: Obx(
() => controller.isLoading.value || controller.paket.value == null
? SizedBox.shrink()
: _buildBottomBar(onTapPesan: showOrderConfirmationDialog),
),
);
}
// Build top section with paket images
Widget _buildTopSection() {
return Container(
height: 280,
width: double.infinity,
child: Stack(
children: [
// Photo gallery
Obx(
() => controller.isPhotosLoading.value
? Center(child: CircularProgressIndicator())
: controller.paketImages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported_outlined,
size: 64,
color: AppColors.textSecondary,
),
SizedBox(height: 16),
Text(
'Tidak ada foto',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
],
),
)
: PhotoViewGallery.builder(
scrollPhysics: BouncingScrollPhysics(),
builder: (BuildContext context, int index) {
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(
controller.paketImages[index],
),
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 2,
heroAttributes: PhotoViewHeroAttributes(
tag: 'paket_image_$index',
),
);
},
itemCount: controller.paketImages.length,
loadingBuilder: (context, event) => Center(
child: CircularProgressIndicator(),
),
backgroundDecoration: BoxDecoration(
color: Colors.black,
),
pageController: PageController(),
),
),
// Gradient overlay at the top for back button
Positioned(
top: 0,
left: 0,
right: 0,
height: 80,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.5),
Colors.transparent,
],
),
),
),
),
],
),
);
}
// Build paket details section
Widget _buildPaketDetails() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
return Container(
padding: EdgeInsets.all(16),
color: Colors.white,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Paket name and availability badge
Row(
children: [
Expanded(
child: Text(
paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.success.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Tersedia',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.success,
),
),
),
],
),
SizedBox(height: 16),
// Description
Text(
'Deskripsi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 8),
Text(
paketModel?.deskripsi ?? controller.getPaketDeskripsi(paket) ?? 'Tidak ada deskripsi untuk paket ini.',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.5,
),
),
],
),
);
}
// Build price options section
Widget _buildPriceOptions() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? controller.getPaketSatuanWaktuSewa(paket);
return Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Durasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Price options grid
GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: satuanWaktuSewa.length,
itemBuilder: (context, index) {
final option = satuanWaktuSewa[index];
final isSelected = controller.selectedSatuanWaktu.value != null &&
controller.selectedSatuanWaktu.value!['id'] == option['id'];
return GestureDetector(
onTap: () => controller.selectSatuanWaktu(option),
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.borderLight,
width: 1,
),
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
option['nama_satuan_waktu'] ?? 'Durasi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
SizedBox(height: 4),
Text(
controller.formatPrice(double.tryParse(option['harga'].toString()) ?? 0),
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.textSecondary,
),
),
],
),
),
);
},
),
],
),
);
}
// Build date selection section
Widget _buildDateSelection(BuildContext context) {
return Obx(
() => controller.selectedSatuanWaktu.value == null
? SizedBox.shrink()
: Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.isDailyRental() ? 'Pilih Tanggal' : 'Pilih Waktu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Date selection for daily rental
if (controller.isDailyRental())
GestureDetector(
onTap: () async {
// Show date range picker
final now = DateTime.now();
final initialStartDate = controller.selectedStartDate.value ?? now;
final initialEndDate = controller.selectedEndDate.value ?? now.add(Duration(days: 1));
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: DateTimeRange(start: initialStartDate, end: initialEndDate),
firstDate: now,
lastDate: now.add(Duration(days: 365)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDateRange(picked.start, picked.end);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.formattedDateRange.value.isEmpty
? 'Pilih tanggal sewa'
: controller.formattedDateRange.value,
style: TextStyle(
fontSize: 14,
color: controller.formattedDateRange.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
)
// Time selection for hourly rental
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date selection
GestureDetector(
onTap: () async {
final now = DateTime.now();
final initialDate = controller.selectedStartDate.value ?? now;
final DateTime? picked = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: now,
lastDate: now.add(Duration(days: 30)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDate(picked);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.selectedDate.value.isEmpty
? 'Pilih tanggal sewa'
: controller.selectedDate.value,
style: TextStyle(
fontSize: 14,
color: controller.selectedDate.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
),
SizedBox(height: 16),
// Time range selection
controller.selectedDate.value.isEmpty
? SizedBox.shrink()

View File

@ -0,0 +1,470 @@
// Build price options section
Widget _buildPriceOptions() {
final paket = controller.paket.value!;
final PaketModel? paketModel = paket is PaketModel ? paket : null;
final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? controller.getPaketSatuanWaktuSewa(paket);
return Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Durasi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Price options grid
GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: satuanWaktuSewa.length,
itemBuilder: (context, index) {
final option = satuanWaktuSewa[index];
final isSelected = controller.selectedSatuanWaktu.value != null &&
controller.selectedSatuanWaktu.value!['id'] == option['id'];
return GestureDetector(
onTap: () => controller.selectSatuanWaktu(option),
child: AnimatedContainer(
duration: Duration(milliseconds: 200),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.borderLight,
width: 1,
),
),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
option['nama_satuan_waktu'] ?? 'Durasi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
SizedBox(height: 4),
Text(
controller.formatPrice(double.tryParse(option['harga'].toString()) ?? 0),
style: TextStyle(
fontSize: 12,
color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.textSecondary,
),
),
],
),
),
);
},
),
],
),
);
}
// Build date selection section
Widget _buildDateSelection(BuildContext context) {
return Obx(
() => controller.selectedSatuanWaktu.value == null
? SizedBox.shrink()
: Container(
padding: EdgeInsets.all(16),
color: Colors.white,
margin: EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.isDailyRental() ? 'Pilih Tanggal' : 'Pilih Waktu',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
SizedBox(height: 16),
// Date selection for daily rental
if (controller.isDailyRental())
GestureDetector(
onTap: () async {
// Show date range picker
final now = DateTime.now();
final initialStartDate = controller.selectedStartDate.value ?? now;
final initialEndDate = controller.selectedEndDate.value ?? now.add(Duration(days: 1));
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: DateTimeRange(start: initialStartDate, end: initialEndDate),
firstDate: now,
lastDate: now.add(Duration(days: 365)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDateRange(picked.start, picked.end);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.formattedDateRange.value.isEmpty
? 'Pilih tanggal sewa'
: controller.formattedDateRange.value,
style: TextStyle(
fontSize: 14,
color: controller.formattedDateRange.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
)
// Time selection for hourly rental
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date selection
GestureDetector(
onTap: () async {
final now = DateTime.now();
final initialDate = controller.selectedStartDate.value ?? now;
final DateTime? picked = await showDatePicker(
context: context,
initialDate: initialDate,
firstDate: now,
lastDate: now.add(Duration(days: 30)),
builder: (context, child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: AppColors.textPrimary,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
controller.selectDate(picked);
}
},
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.calendar_today, color: AppColors.primary),
SizedBox(width: 12),
Expanded(
child: Text(
controller.selectedDate.value.isEmpty
? 'Pilih tanggal sewa'
: controller.selectedDate.value,
style: TextStyle(
fontSize: 14,
color: controller.selectedDate.value.isEmpty
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary),
],
),
),
),
SizedBox(height: 16),
// Time range selection
controller.selectedDate.value.isEmpty
? SizedBox.shrink()
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Jam',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
SizedBox(height: 12),
Row(
children: [
// Start time
Expanded(
child: GestureDetector(
onTap: () async {
// Show time picker for start time (8-20)
final List<int> availableHours = List.generate(13, (i) => i + 8);
final int? selectedHour = await showDialog<int>(
context: context,
builder: (context) => SimpleDialog(
title: Text('Pilih Jam Mulai'),
children: availableHours.map((hour) {
return SimpleDialogOption(
onPressed: () => Navigator.pop(context, hour),
child: Text('$hour:00'),
);
}).toList(),
),
);
if (selectedHour != null) {
// If end time is already selected and less than start time, reset it
if (controller.selectedEndTime.value > 0 &&
controller.selectedEndTime.value <= selectedHour) {
controller.selectedEndTime.value = -1;
}
controller.selectedStartTime.value = selectedHour;
if (controller.selectedEndTime.value > 0) {
controller.selectTimeRange(
controller.selectedStartTime.value,
controller.selectedEndTime.value,
);
}
}
},
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.access_time, size: 16, color: AppColors.primary),
SizedBox(width: 8),
Text(
controller.selectedStartTime.value < 0
? 'Jam Mulai'
: '${controller.selectedStartTime.value}:00',
style: TextStyle(
fontSize: 14,
color: controller.selectedStartTime.value < 0
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
],
),
),
),
),
SizedBox(width: 16),
// End time
Expanded(
child: GestureDetector(
onTap: () async {
if (controller.selectedStartTime.value < 0) {
Get.snackbar(
'Perhatian',
'Pilih jam mulai terlebih dahulu',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: AppColors.warning,
colorText: Colors.white,
);
return;
}
// Show time picker for end time (start+1 to 21)
final List<int> availableHours = List.generate(
21 - controller.selectedStartTime.value,
(i) => i + controller.selectedStartTime.value + 1,
);
final int? selectedHour = await showDialog<int>(
context: context,
builder: (context) => SimpleDialog(
title: Text('Pilih Jam Selesai'),
children: availableHours.map((hour) {
return SimpleDialogOption(
onPressed: () => Navigator.pop(context, hour),
child: Text('$hour:00'),
);
}).toList(),
),
);
if (selectedHour != null) {
controller.selectedEndTime.value = selectedHour;
controller.selectTimeRange(
controller.selectedStartTime.value,
controller.selectedEndTime.value,
);
}
},
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.access_time, size: 16, color: AppColors.primary),
SizedBox(width: 8),
Text(
controller.selectedEndTime.value < 0
? 'Jam Selesai'
: '${controller.selectedEndTime.value}:00',
style: TextStyle(
fontSize: 14,
color: controller.selectedEndTime.value < 0
? AppColors.textSecondary
: AppColors.textPrimary,
),
),
],
),
),
),
),
],
),
],
),
],
),
],
),
),
);
}
// Build bottom bar with total price and order button
Widget _buildBottomBar({required VoidCallback onTapPesan}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: Offset(0, -5),
),
],
),
child: SafeArea(
child: Row(
children: [
// Price info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Total',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
Obx(
() => Text(
controller.formatPrice(controller.totalPrice.value),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
],
),
),
// Order button
Obx(
() => ElevatedButton(
onPressed: controller.selectedSatuanWaktu.value == null ||
(controller.isDailyRental() &&
(controller.selectedStartDate.value == null ||
controller.selectedEndDate.value == null)) ||
(!controller.isDailyRental() &&
(controller.selectedStartDate.value == null ||
controller.selectedStartTime.value < 0 ||
controller.selectedEndTime.value < 0))
? null
: onTapPesan,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Pesan Sekarang',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
],
),
),
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,758 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../views/warga_layout.dart';
import '../../../theme/app_colors.dart';
import '../../../widgets/app_drawer.dart';
import '../../../routes/app_routes.dart';
import 'package:intl/intl.dart';
class WargaDashboardView extends GetView<WargaDashboardController> {
const WargaDashboardView({super.key});
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return WillPopScope(
onWillPop: () async => false, // Prevent back navigation
child: WargaLayout(
drawer: AppDrawer(
onNavItemTapped: controller.onNavItemTapped,
onLogout: controller.logout,
),
backgroundColor: AppColors.background,
appBar: AppBar(
elevation: 0,
backgroundColor: AppColors.primary,
title: const Text(
'Beranda',
style: TextStyle(fontWeight: FontWeight.w600),
),
centerTitle: true,
),
body: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
// Re-fetch data when pulled down
await Future.delayed(const Duration(seconds: 1));
controller.refreshData();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildUserGreetingHeader(context),
_buildActionButtons(),
_buildActiveRentalsSection(context),
const SizedBox(height: 24),
],
),
),
),
),
);
}
// Modern welcome header with user profile
Widget _buildUserGreetingHeader(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 30),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// User avatar
Obx(() {
final avatarUrl = controller.userAvatar.value;
return Container(
height: 60,
width: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
color: Colors.white.withOpacity(0.2),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(30),
child:
avatarUrl != null && avatarUrl.isNotEmpty
? Image.network(
avatarUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
_buildAvatarFallback(),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _buildAvatarFallback();
},
)
: _buildAvatarFallback(),
),
);
}),
const SizedBox(width: 16),
// Greeting and name
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getGreeting(),
style: const TextStyle(
fontSize: 15,
color: Colors.white70,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Obx(
() => Text(
controller.userName.value,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
],
),
),
),
);
}
// Action buttons in a horizontal scroll
Widget _buildActionButtons() {
// Define services - removed Langganan and Pengaduan
final services = [
{
'title': 'Sewa',
'icon': Icons.home_work_outlined,
'color': const Color(0xFF4CAF50),
'route': () => controller.navigateToRentals(),
},
{
'title': 'Bayar',
'icon': Icons.payment_outlined,
'color': const Color(0xFF2196F3),
'route': () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
},
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 10),
child: Text(
'Layanan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
// Service cards in grid
GridView.count(
crossAxisCount: 2,
childAspectRatio: 1.5,
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
mainAxisSpacing: 16,
crossAxisSpacing: 16,
children:
services
.map(
(service) => _buildServiceCard(
title: service['title'] as String,
icon: service['icon'] as IconData,
color: service['color'] as Color,
onTap: service['route'] as VoidCallback,
),
)
.toList(),
),
// Activity Summaries Section
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 10),
child: Text(
'Ringkasan Aktivitas',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
),
// Summary Cards
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
// Sewa Diterima
_buildActivityCard(
title: 'Sewa Diterima',
value: controller.activeRentals.length.toString(),
icon: Icons.check_circle_outline,
color: AppColors.success,
onTap: () => controller.navigateToRentals(),
),
const SizedBox(height: 12),
// Tagihan Aktif
_buildActivityCard(
title: 'Tagihan Aktif',
value: controller.activeBills.length.toString(),
icon: Icons.receipt_long_outlined,
color: AppColors.warning,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
),
const SizedBox(height: 12),
// Denda Aktif
_buildActivityCard(
title: 'Denda Aktif',
value: controller.activePenalties.length.toString(),
icon: Icons.warning_amber_outlined,
color: AppColors.error,
onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA),
),
],
),
),
],
);
}
Widget _buildServiceCard({
required String title,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color.withOpacity(0.7), color],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: color.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Background decoration
Positioned(
right: -15,
bottom: -15,
child: Container(
width: 90,
height: 90,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
),
),
Positioned(
left: -20,
top: -20,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
shape: BoxShape.circle,
),
),
),
// Icon and text
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(icon, color: Colors.white, size: 24),
),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
],
),
),
),
);
}
// Active rentals section with improved card design
Widget _buildActiveRentalsSection(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Sewa Diterima',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
TextButton.icon(
onPressed: () => controller.onNavItemTapped(1),
icon: const Icon(Icons.arrow_forward, size: 18),
label: const Text('Lihat Semua'),
style: TextButton.styleFrom(foregroundColor: AppColors.primary),
),
],
),
const SizedBox(height: 12),
Obx(() {
if (controller.activeRentals.isEmpty) {
return _buildEmptyState(
message: 'Belum ada sewa aset yang aktif',
icon: Icons.inventory_2_outlined,
buttonText: 'Sewa Sekarang',
onPressed: () => controller.navigateToRentals(),
);
}
return Column(
children:
controller.activeRentals
.map((rental) => _buildModernRentalCard(rental))
.toList(),
);
}),
],
),
);
}
// Empty state widget with consistent design
Widget _buildEmptyState({
required String message,
required IconData icon,
required String buttonText,
required VoidCallback onPressed,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
border: Border.all(color: Colors.grey.shade100),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 40, color: AppColors.primary),
),
const SizedBox(height: 20),
Text(
message,
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: Text(
buttonText,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
],
),
);
}
// Modern rental card with better layout
Widget _buildModernRentalCard(Map<String, dynamic> rental) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
offset: const Offset(0, 4),
blurRadius: 15,
),
],
border: Border.all(color: Colors.grey.shade100, width: 1.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with glass effect
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary.withOpacity(0.04),
AppColors.primary.withOpacity(0.08),
],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Row(
children: [
// Asset icon
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary.withOpacity(0.7),
AppColors.primary,
],
),
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.primary.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: const Icon(
Icons.local_shipping,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 16),
// Asset details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
rental['name'],
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
rental['time'],
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
// Price tag
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primary.withOpacity(0.3),
width: 1.0,
),
),
child: Text(
rental['price'],
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
),
// Details and actions
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: [
// Details row
Row(
children: [
Expanded(
child: _buildInfoItem(
icon: Icons.timer_outlined,
title: 'Durasi',
value: rental['duration'],
),
),
Expanded(
child: _buildInfoItem(
icon: Icons.calendar_today_outlined,
title: 'Status',
value: 'Diterima',
valueColor: AppColors.success,
),
),
],
),
const SizedBox(height: 16),
// Action buttons
if (rental['can_extend'])
OutlinedButton.icon(
onPressed: () => controller.extendRental(rental['id']),
icon: const Icon(Icons.update, size: 18),
label: const Text('Perpanjang'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: BorderSide(color: AppColors.primary),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
),
),
],
),
),
],
),
);
}
// Info item for displaying details
Widget _buildInfoItem({
required IconData icon,
required String title,
required String value,
Color? valueColor,
}) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
const SizedBox(height: 4),
Row(
children: [
Icon(icon, size: 16, color: AppColors.primary),
const SizedBox(width: 4),
Text(
value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: valueColor ?? AppColors.textPrimary,
),
),
],
),
],
),
);
}
// Build avatar fallback for when image is not available
Widget _buildAvatarFallback() {
return Center(child: Icon(Icons.person, color: Colors.white70, size: 30));
}
// Get appropriate greeting based on time of day
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 12) {
return 'Selamat Pagi';
} else if (hour < 17) {
return 'Selamat Siang';
} else {
return 'Selamat Malam';
}
}
// Build a summary card for activities
Widget _buildActivityCard({
required String title,
required String value,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: AppColors.textSecondary.withOpacity(0.5),
size: 16,
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../widgets/app_bottom_navbar.dart';
import '../../../services/navigation_service.dart';
import '../../../routes/app_routes.dart';
/// A wrapper layout that provides a persistent bottom navigation bar
/// and a content area for warga user pages.
class WargaLayout extends StatelessWidget {
final Widget body;
final PreferredSizeWidget? appBar;
final Widget? drawer;
final Color? backgroundColor;
final Widget? floatingActionButton;
final FloatingActionButtonLocation? floatingActionButtonLocation;
const WargaLayout({
super.key,
required this.body,
this.appBar,
this.drawer,
this.backgroundColor,
this.floatingActionButton,
this.floatingActionButtonLocation,
});
@override
Widget build(BuildContext context) {
// Access the navigation service
final navigationService = Get.find<NavigationService>();
// Single Scaffold that contains all components
return Scaffold(
backgroundColor: backgroundColor ?? Colors.grey.shade100,
appBar: appBar,
// Drawer configuration for proper overlay
drawer: drawer,
drawerEdgeDragWidth: 60, // Wider drag area for easier access
drawerEnableOpenDragGesture: true,
// Higher opacity ensures good contrast & visibility when drawer opens
drawerScrimColor: Colors.black.withOpacity(0.6),
// Main body content
body: body,
// Bottom navigation bar
bottomNavigationBar: AppBottomNavbar(
selectedIndex: navigationService.currentNavIndex.value,
onItemTapped: (index) => _handleNavigation(index, navigationService),
),
floatingActionButton: floatingActionButton,
floatingActionButtonLocation: floatingActionButtonLocation,
);
}
// Handle navigation for bottom navbar
void _handleNavigation(int index, NavigationService navigationService) {
if (navigationService.currentNavIndex.value == index) {
return; // Don't do anything if already on this tab
}
navigationService.setNavIndex(index);
// Navigate to the appropriate page
switch (index) {
case 0:
Get.offAllNamed(Routes.WARGA_DASHBOARD);
break;
case 1:
navigationService.toWargaSewa();
break;
case 2:
navigationService.toProfile();
break;
}
}
}

View File

@ -0,0 +1,455 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/warga_dashboard_controller.dart';
import '../views/warga_layout.dart';
import '../../../theme/app_colors.dart';
class WargaProfileView extends GetView<WargaDashboardController> {
const WargaProfileView({super.key});
@override
Widget build(BuildContext context) {
return WargaLayout(
appBar: AppBar(
title: const Text('Profil Saya'),
backgroundColor: AppColors.primary,
elevation: 0,
centerTitle: true,
actions: [
IconButton(
onPressed: () {
Get.snackbar(
'Info',
'Fitur edit profil akan segera tersedia',
snackPosition: SnackPosition.BOTTOM,
);
},
icon: const Icon(Icons.edit_outlined),
tooltip: 'Edit Profil',
),
],
),
backgroundColor: Colors.grey.shade100,
body: RefreshIndicator(
color: AppColors.primary,
onRefresh: () async {
await Future.delayed(const Duration(milliseconds: 500));
controller.refreshData();
return;
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
_buildProfileHeader(context),
const SizedBox(height: 16),
_buildInfoCard(context),
const SizedBox(height: 16),
_buildSettingsCard(context),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _buildProfileHeader(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary,
AppColors.primary.withBlue(AppColors.primary.blue + 30),
],
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 36),
child: Column(
children: [
// Profile picture with shadow effect
Obx(() {
final avatarUrl = controller.userAvatar.value;
return Container(
height: 110,
width: 110,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(55),
child:
avatarUrl != null && avatarUrl.isNotEmpty
? Image.network(
avatarUrl,
fit: BoxFit.cover,
errorBuilder:
(context, error, stackTrace) =>
_buildAvatarFallback(),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _buildAvatarFallback();
},
)
: _buildAvatarFallback(),
),
);
}),
const SizedBox(height: 16),
// User name with subtle text shadow
Obx(
() => Text(
controller.userName.value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
Shadow(
color: Colors.black26,
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
),
),
const SizedBox(height: 6),
// User role in a stylish chip
Obx(
() => Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.3),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withOpacity(0.5),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.verified_user,
size: 14,
color: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 6),
Text(
controller.userRole.value,
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildInfoCard(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(Icons.person_outline, color: AppColors.primary, size: 18),
const SizedBox(width: 8),
Text(
'INFORMASI PERSONAL',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
letterSpacing: 0.5,
),
),
],
),
),
const Divider(height: 1),
_buildInfoItem(
icon: Icons.email_outlined,
title: 'Email',
value:
controller.userEmail.value.isEmpty
? 'emailpengguna@example.com'
: controller.userEmail.value,
),
Divider(height: 1, color: Colors.grey.shade200),
_buildInfoItem(
icon: Icons.credit_card_outlined,
title: 'NIK',
value:
controller.userNik.value.isEmpty
? '123456789012345'
: controller.userNik.value,
),
Divider(height: 1, color: Colors.grey.shade200),
_buildInfoItem(
icon: Icons.phone_outlined,
title: 'Nomor Telepon',
value:
controller.userPhone.value.isEmpty
? '081234567890'
: controller.userPhone.value,
),
Divider(height: 1, color: Colors.grey.shade200),
_buildInfoItem(
icon: Icons.home_outlined,
title: 'Alamat Lengkap',
value:
controller.userAddress.value.isEmpty
? 'Jl. Contoh No. 123, Desa Sejahtera, Kec. Makmur, Kab. Berkah, Prov. Damai'
: controller.userAddress.value,
isMultiLine: true,
),
],
),
);
}
Widget _buildInfoItem({
required IconData icon,
required String title,
required String value,
bool isMultiLine = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment:
isMultiLine ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: AppColors.primary, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 3),
Text(
value,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
maxLines: isMultiLine ? 3 : 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
Widget _buildSettingsCard(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Icon(
Icons.settings_outlined,
color: AppColors.primary,
size: 18,
),
const SizedBox(width: 8),
Text(
'PENGATURAN',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
letterSpacing: 0.5,
),
),
],
),
),
const Divider(height: 1),
_buildActionItem(
icon: Icons.lock_outline,
title: 'Ubah Password',
iconColor: AppColors.primary,
onTap: () {
Get.snackbar(
'Info',
'Fitur Ubah Password akan segera tersedia',
snackPosition: SnackPosition.BOTTOM,
);
},
),
Divider(height: 1, color: Colors.grey.shade200),
_buildActionItem(
icon: Icons.logout,
title: 'Keluar',
iconColor: Colors.red.shade400,
isDestructive: true,
onTap: () {
_showLogoutConfirmation(context);
},
),
],
),
);
}
Widget _buildActionItem({
required IconData icon,
required String title,
required VoidCallback onTap,
Color? iconColor,
bool isDestructive = false,
}) {
final color =
isDestructive ? Colors.red.shade400 : iconColor ?? AppColors.primary;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color:
isDestructive ? Colors.red.shade400 : Colors.grey.shade800,
),
),
const Spacer(),
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20),
],
),
),
);
}
void _showLogoutConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Konfirmasi Keluar'),
content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
foregroundColor: Colors.grey.shade700,
),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
controller.logout();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade400,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Keluar'),
),
],
);
},
);
}
Widget _buildAvatarFallback() {
return Container(
color: Colors.grey.shade200,
child: Center(
child: Icon(
Icons.person,
color: AppColors.primary.withOpacity(0.7),
size: 50,
),
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../routes/app_routes.dart';
import '../../../services/navigation_service.dart';
class AppBottomNavbar extends StatelessWidget {
final int selectedIndex;
final Function(int) onItemTapped;
const AppBottomNavbar({
super.key,
required this.selectedIndex,
required this.onItemTapped,
});
@override
Widget build(BuildContext context) {
// Get navigation service to sync with drawer
final navigationService = Get.find<NavigationService>();
return Container(
height: 76,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.07),
blurRadius: 14,
offset: const Offset(0, -2),
),
],
),
child: Obx(
() => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildNavItem(
context: context,
icon: Icons.home_rounded,
activeIcon: Icons.home_rounded,
label: 'Beranda',
isSelected: navigationService.currentNavIndex.value == 0,
onTap: () {
if (navigationService.currentNavIndex.value != 0) {
onItemTapped(0);
navigationService.setNavIndex(0);
Get.offAllNamed(Routes.WARGA_DASHBOARD);
}
},
),
_buildNavItem(
context: context,
icon: Icons.inventory_outlined,
activeIcon: Icons.inventory_rounded,
label: 'Sewa',
isSelected: navigationService.currentNavIndex.value == 1,
onTap: () {
if (navigationService.currentNavIndex.value != 1) {
onItemTapped(1);
navigationService.toWargaSewa();
}
},
),
_buildNavItem(
context: context,
icon: Icons.person_outline,
activeIcon: Icons.person,
label: 'Profil',
isSelected: navigationService.currentNavIndex.value == 2,
onTap: () {
if (navigationService.currentNavIndex.value != 2) {
onItemTapped(2);
navigationService.toProfile();
}
},
),
],
),
),
);
}
// Modern navigation item for bottom bar
Widget _buildNavItem({
required BuildContext context,
required IconData icon,
required IconData activeIcon,
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final primaryColor = theme.primaryColor;
final tabWidth = MediaQuery.of(context).size.width / 3; // Changed to 3 tabs
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
splashColor: primaryColor.withOpacity(0.1),
highlightColor: primaryColor.withOpacity(0.05),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: tabWidth,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: isSelected ? primaryColor : Colors.transparent,
width: 2,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon with animated scale effect when selected
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.all(isSelected ? 8 : 0),
decoration: BoxDecoration(
color:
isSelected
? primaryColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isSelected ? activeIcon : icon,
color: isSelected ? primaryColor : Colors.grey.shade400,
size: 24,
),
),
const SizedBox(height: 4),
// Label with animated opacity
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? primaryColor : Colors.grey.shade500,
),
child: Text(label),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,517 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
class CustomDateRangePicker extends StatefulWidget {
final List<DateTime> disabledDates;
final Function(DateTime startDate, DateTime endDate) onSelectRange;
final DateTime? initialStartDate;
final DateTime? initialEndDate;
final int? maxDays; // Maximum allowed days between start and end date
final Function? onClearSelection; // Callback when selection is cleared
final bool singleDateMode; // When true, only allows selecting a single date
const CustomDateRangePicker({
super.key,
required this.disabledDates,
required this.onSelectRange,
this.initialStartDate,
this.initialEndDate,
this.maxDays,
this.onClearSelection,
this.singleDateMode = false,
});
@override
_CustomDateRangePickerState createState() => _CustomDateRangePickerState();
}
class _CustomDateRangePickerState extends State<CustomDateRangePicker> {
late DateTime _currentMonth;
DateTime? _startDate;
DateTime? _endDate;
DateTime? _hoverDate;
bool _selectionMode =
false; // true means selecting end date, false means selecting start date
// Map for O(1) lookup of disabled dates
late Set<String> _disabledDateStrings;
@override
void initState() {
super.initState();
_currentMonth = DateTime.now();
_startDate = widget.initialStartDate;
_endDate = widget.initialEndDate;
_selectionMode = _startDate != null && _endDate == null;
// Create a set of strings from disabled dates for faster lookup
_disabledDateStrings = {};
for (var date in widget.disabledDates) {
_disabledDateStrings.add('${date.year}-${date.month}-${date.day}');
}
}
// Check if a date is disabled
bool _isDisabled(DateTime date) {
final dateString = '${date.year}-${date.month}-${date.day}';
return _disabledDateStrings.contains(dateString);
}
// Check if a date is before today or is today
bool _isBeforeToday(DateTime date) {
final today = DateTime.now();
final todayDate = DateTime(today.year, today.month, today.day);
final checkDate = DateTime(date.year, date.month, date.day);
// Return true if date is before today (not including today)
return checkDate.isBefore(todayDate);
}
// Check if a date can be selected
bool _canSelectDate(DateTime date) {
return !_isDisabled(date) && !_isBeforeToday(date);
}
// Get the status of a date (start, end, in-range, disabled, normal)
String _getDateStatus(DateTime date) {
if (_isDisabled(date) || _isBeforeToday(date)) {
return 'disabled';
}
if (_startDate != null && _isSameDay(date, _startDate!)) {
return 'start';
}
if (_endDate != null && _isSameDay(date, _endDate!)) {
return 'end';
}
if (_startDate != null &&
_endDate != null &&
date.isAfter(_startDate!) &&
date.isBefore(_endDate!)) {
return 'in-range';
}
return 'normal';
}
// Check if two dates are the same day
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
// Handle date tap - now just sets start and optionally end date
void _onDateTap(DateTime date) {
if (!_canSelectDate(date)) return;
setState(() {
// If we're in single date mode, simply set both start and end date to the selected date
if (widget.singleDateMode) {
// If tapping on the already selected date, clear the selection
if (_startDate != null && _isSameDay(date, _startDate!)) {
_startDate = null;
_endDate = null;
_selectionMode = false;
if (widget.onClearSelection != null) {
widget.onClearSelection!();
}
} else {
// Set both start and end date to the selected date
_startDate = date;
_endDate = date;
// Immediately confirm selection in single date mode
Future.microtask(() => _confirmSelection());
}
return;
}
// Regular date range selection behavior (for non-single date mode)
// If tapping on the start date when already selected
if (_startDate != null && _isSameDay(date, _startDate!)) {
// If only start date is selected, clear selection
if (_endDate == null) {
_startDate = null;
_selectionMode = false;
if (widget.onClearSelection != null) {
widget.onClearSelection!();
}
return;
}
// If both dates are selected, move end date to start and clear end date
else if (!_isSameDay(_startDate!, _endDate!)) {
_startDate = _endDate;
_endDate = null;
_selectionMode = true;
return;
}
// If both dates are the same, clear both
else {
_startDate = null;
_endDate = null;
_selectionMode = false;
if (widget.onClearSelection != null) {
widget.onClearSelection!();
}
return;
}
}
// If tapping on the end date when already selected
if (_endDate != null && _isSameDay(date, _endDate!)) {
// Clear end date but keep start date
_endDate = null;
_selectionMode = true;
return;
}
if (!_selectionMode) {
// Selecting start date
_startDate = date;
_endDate = null;
_selectionMode = true;
} else {
// Selecting end date
if (date.isBefore(_startDate!)) {
// If selecting a date before start, swap them
_endDate = _startDate;
_startDate = date;
} else {
// Check if the selection exceeds the maximum allowed days
if (widget.maxDays != null) {
final daysInRange = date.difference(_startDate!).inDays + 1;
if (daysInRange > widget.maxDays!) {
// Show a message about exceeding the maximum days
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Maksimal ${widget.maxDays} hari! Anda memilih $daysInRange hari.',
),
backgroundColor: Colors.red,
),
);
return; // Don't proceed with the selection
}
}
_endDate = date;
}
// Check if any date in the range is disabled (only if we have an end date)
if (_endDate != null && !_isSameDay(_startDate!, _endDate!)) {
_checkRangeForDisabledDates();
}
}
});
}
// Check if range contains any disabled dates
bool _checkRangeForDisabledDates() {
if (_startDate == null || _endDate == null) return false;
bool hasDisabledDate = false;
for (
DateTime d = _startDate!;
!d.isAfter(_endDate!);
d = d.add(const Duration(days: 1))
) {
if (d != _startDate && d != _endDate && _isDisabled(d)) {
hasDisabledDate = true;
break;
}
}
if (hasDisabledDate) {
// Reset selection if range contains disabled date
_endDate = null;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Rentang tanggal mengandung tanggal yang tidak tersedia',
),
backgroundColor: Colors.red,
),
);
return true;
}
return false;
}
// Confirm the selection (either single day or range)
void _confirmSelection() {
if (_startDate == null) return;
// If no end date is selected, use start date as end date
_endDate ??= _startDate;
// Now notify the parent widget
widget.onSelectRange(_startDate!, _endDate!);
}
// Generate the calendar for a month
Widget _buildCalendarMonth(DateTime month) {
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final firstDayOfMonth = DateTime(month.year, month.month, 1);
final dayOfWeek = firstDayOfMonth.weekday % 7; // 0 = Sunday, 6 = Saturday
// Headers for days of week
final daysOfWeek = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
return Column(
children: [
// Month and year header
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
DateFormat('MMMM yyyy', 'id_ID').format(month),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
),
// Days of week header
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children:
daysOfWeek
.map(
(day) => SizedBox(
width: 36,
child: Text(
day,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.textSecondary,
),
),
),
)
.toList(),
),
const SizedBox(height: 8),
// Calendar days grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1,
),
itemCount: (dayOfWeek + daysInMonth),
itemBuilder: (context, index) {
// Empty cells for days before the 1st of the month
if (index < dayOfWeek) {
return const SizedBox();
}
final day = index - dayOfWeek + 1;
final date = DateTime(month.year, month.month, day);
final status = _getDateStatus(date);
return GestureDetector(
onTap: () => _onDateTap(date),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color:
status == 'in-range'
? AppColors.primarySoft
: status == 'start' || status == 'end'
? AppColors.primary
: null,
borderRadius: BorderRadius.circular(8),
),
child: Stack(
alignment: Alignment.center,
children: [
// Date number
Text(
day.toString(),
style: TextStyle(
color:
status == 'disabled'
? Colors.grey.shade400
: status == 'start' || status == 'end'
? AppColors.textOnPrimary
: AppColors.textPrimary,
fontWeight:
status == 'start' || status == 'end'
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
);
},
),
],
);
}
// Get selection status text
String? _getSelectionStatusText() {
if (widget.singleDateMode) {
if (_startDate == null) {
return 'Silakan pilih tanggal untuk sewa per jam';
} else {
return 'Tanggal dipilih: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)}';
}
}
if (_startDate == null) {
return 'Pilih tanggal mulai'; // Guide user to select start date
} else if (_endDate == null) {
return 'Tanggal mulai: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)} - Pilih tanggal akhir atau konfirmasi untuk sewa satu hari';
} else {
if (_isSameDay(_startDate!, _endDate!)) {
return 'Satu hari dipilih: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)}';
} else {
final int days = _endDate!.difference(_startDate!).inDays + 1;
return '${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)} - ${DateFormat('dd MMM yyyy', 'id_ID').format(_endDate!)} ($days hari)';
}
}
}
// Check if a date can be highlighted as potential end date during hover
bool _canBeEndDate(DateTime date) {
if (!_canSelectDate(date)) return false;
if (_startDate == null) return false;
// If date is before start date, it can't be an end date
if (date.isBefore(_startDate!)) return false;
// Check if the range would exceed the maximum days
if (widget.maxDays != null) {
final daysInRange = date.difference(_startDate!).inDays + 1;
if (daysInRange > widget.maxDays!) return false;
}
// Check if any dates in the range are disabled
for (
DateTime d = _startDate!;
!d.isAfter(date);
d = d.add(const Duration(days: 1))
) {
if (!_isSameDay(d, _startDate!) &&
!_isSameDay(d, date) &&
_isDisabled(d)) {
return false;
}
}
return true;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Selection status - only shown when a date is selected
Builder(
builder: (context) {
final statusText = _getSelectionStatusText();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
statusText ?? 'Pilih tanggal untuk memesan',
style: TextStyle(
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
);
},
),
// Display current month
_buildCalendarMonth(_currentMonth),
// Hint for deselection
if (_startDate != null)
Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 4.0),
child: Text(
"Tekan tanggal yang sudah dipilih untuk membatalkan",
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
),
// Month navigation
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.arrow_back_ios, color: AppColors.primary),
onPressed: () {
setState(() {
_currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month - 1,
);
});
},
),
IconButton(
icon: Icon(Icons.arrow_forward_ios, color: AppColors.primary),
onPressed: () {
setState(() {
_currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
);
});
},
),
],
),
),
// Controls
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('Batal'),
),
// Hide confirm button in single date mode as selection is auto-confirmed
if (!widget.singleDateMode)
ElevatedButton(
onPressed: _startDate != null ? _confirmSelection : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: AppColors.textOnPrimary,
),
child: const Text('Konfirmasi'),
),
],
),
),
],
);
}
}