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

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.