feat: UI impelemenatation
This commit is contained in:
parent
918e3c0ced
commit
5eed472971
15
.metadata
15
.metadata
|
@ -18,21 +18,6 @@ migration:
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||||
- platform: ios
|
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
- platform: linux
|
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
- platform: macos
|
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
- platform: web
|
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
- platform: windows
|
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,25 @@
|
||||||
import 'package:rijig_mobile/core/utils/exportimportview.dart';
|
import 'package:rijig_mobile/core/utils/exportimportview.dart';
|
||||||
|
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => SplashScreen()),
|
GoRoute(path: '/', builder: (context, state) => SplashScreen()),
|
||||||
GoRoute(path: '/trashview', builder: (context, state) => TestRequestPickScreen()),
|
// GoRoute(
|
||||||
GoRoute(path: '/ordersumary', builder: (context, state) => OrderSummaryScreen()),
|
// path: '/',
|
||||||
GoRoute(path: '/pinsecureinput', builder: (context, state) => SecurityCodeScreen()),
|
// builder: (context, state) => UploadKtpScreen(),
|
||||||
|
// ),
|
||||||
|
GoRoute(
|
||||||
|
path: '/trashview',
|
||||||
|
builder: (context, state) => TestRequestPickScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/ordersumary',
|
||||||
|
builder: (context, state) => OrderSummaryScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/pinsecureinput',
|
||||||
|
builder: (context, state) => SecurityCodeScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/cmapview',
|
path: '/cmapview',
|
||||||
builder: (context, state) => CollectorRouteMapScreen(),
|
builder: (context, state) => CollectorRouteMapScreen(),
|
||||||
|
@ -66,12 +80,42 @@ final router = GoRouter(
|
||||||
|
|
||||||
// Rute untuk halaman-halaman utama
|
// Rute untuk halaman-halaman utama
|
||||||
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
||||||
|
GoRoute(path: '/chatlist', builder: (context, state) => ChatListScreen()),
|
||||||
|
// Router config
|
||||||
|
GoRoute(
|
||||||
|
path: '/chatroom/:contactId',
|
||||||
|
builder: (context, state) {
|
||||||
|
final contactName = state.uri.queryParameters['name'] ?? 'Unknown';
|
||||||
|
final contactImage = state.uri.queryParameters['image'] ?? '';
|
||||||
|
final isOnline = state.uri.queryParameters['online'] == 'true';
|
||||||
|
|
||||||
|
return ChatRoomScreen(
|
||||||
|
contactName: contactName,
|
||||||
|
contactImage: contactImage,
|
||||||
|
isOnline: isOnline,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dataperforma',
|
path: '/dataperforma',
|
||||||
builder: (context, state) => DatavisualizedScreen(),
|
builder: (context, state) => DatavisualizedScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
|
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
|
||||||
GoRoute(path: '/profil', builder: (context, state) => ProfilScreen()),
|
GoRoute(path: '/profil', builder: (context, state) => ProfilScreen()),
|
||||||
|
GoRoute(path: '/akunprofil', builder: (context, state) => AccountScreen()),
|
||||||
|
GoRoute(path: '/address', builder: (context, state) => AddressScreen()),
|
||||||
|
GoRoute(
|
||||||
|
path: '/addaddress',
|
||||||
|
builder: (context, state) => AddAddressScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/editaddress',
|
||||||
|
builder: (context, state) {
|
||||||
|
dynamic address = state.extra;
|
||||||
|
return EditAddressScreen(address: address);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/aboutdetail',
|
path: '/aboutdetail',
|
||||||
|
|
|
@ -3,8 +3,12 @@ export 'package:go_router/go_router.dart';
|
||||||
export 'package:rijig_mobile/core/utils/navigation.dart';
|
export 'package:rijig_mobile/core/utils/navigation.dart';
|
||||||
export 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart';
|
export 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart';
|
||||||
export 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart';
|
export 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart';
|
||||||
|
export 'package:rijig_mobile/features/home/notification/presentation/screen/notification_screen.dart';
|
||||||
export 'package:rijig_mobile/features/home/datavisualized/presentation/screen/datavisualized_screen.dart';
|
export 'package:rijig_mobile/features/home/datavisualized/presentation/screen/datavisualized_screen.dart';
|
||||||
export 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart';
|
export 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart';
|
||||||
|
export 'package:rijig_mobile/features/profil/address/presentation/screen/address_screen.dart';
|
||||||
|
export 'package:rijig_mobile/features/profil/address/presentation/screen/add_address_screen.dart';
|
||||||
|
export 'package:rijig_mobile/features/profil/address/presentation/screen/edit_address_screen.dart';
|
||||||
export 'package:rijig_mobile/features/auth/presentation/screen/inputpin_screen.dart';
|
export 'package:rijig_mobile/features/auth/presentation/screen/inputpin_screen.dart';
|
||||||
export 'package:rijig_mobile/features/auth/presentation/screen/login_screen.dart';
|
export 'package:rijig_mobile/features/auth/presentation/screen/login_screen.dart';
|
||||||
export 'package:rijig_mobile/features/auth/presentation/screen/otp_screen.dart';
|
export 'package:rijig_mobile/features/auth/presentation/screen/otp_screen.dart';
|
||||||
|
@ -22,6 +26,8 @@ export 'package:rijig_mobile/features/auth/presentation/screen/collector/cotp_sc
|
||||||
export 'package:rijig_mobile/features/auth/presentation/screen/collector/clogin_screen.dart';
|
export 'package:rijig_mobile/features/auth/presentation/screen/collector/clogin_screen.dart';
|
||||||
export 'package:rijig_mobile/features/home/presentation/screen/collector/pickup_history_screen.dart';
|
export 'package:rijig_mobile/features/home/presentation/screen/collector/pickup_history_screen.dart';
|
||||||
export 'package:rijig_mobile/features/pickup/presentation/screen/pickup_map_screen.dart';
|
export 'package:rijig_mobile/features/pickup/presentation/screen/pickup_map_screen.dart';
|
||||||
|
export 'package:rijig_mobile/features/chat/presentation/screen/chatlist_screen.dart';
|
||||||
|
export 'package:rijig_mobile/features/profil/account/presentation/screen/account_screen.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
import 'package:rijig_mobile/widget/tabbar_custom.dart';
|
import 'package:rijig_mobile/widget/tabbar_custom.dart';
|
||||||
import 'package:rijig_mobile/widget/unhope_handler.dart';
|
import 'package:rijig_mobile/widget/unhope_handler.dart';
|
||||||
|
import 'package:timelines_plus/timelines_plus.dart';
|
||||||
|
|
||||||
class ActivityScreen extends StatefulWidget {
|
class ActivityScreen extends StatefulWidget {
|
||||||
const ActivityScreen({super.key});
|
const ActivityScreen({super.key});
|
||||||
|
@ -11,6 +12,98 @@ class ActivityScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActivityScreenState extends State<ActivityScreen> {
|
class _ActivityScreenState extends State<ActivityScreen> {
|
||||||
|
// Data contoh untuk timeline
|
||||||
|
final List<ActivityItem> processActivities = [
|
||||||
|
ActivityItem(
|
||||||
|
title: 'Pesanan Dikonfirmasi',
|
||||||
|
description: 'Pesanan Anda telah dikonfirmasi dan sedang diproses',
|
||||||
|
time: '10:30',
|
||||||
|
date: '15 Juni 2024',
|
||||||
|
status: ActivityStatus.completed,
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
),
|
||||||
|
ActivityItem(
|
||||||
|
title: 'Sedang Disiapkan',
|
||||||
|
description: 'Tim kami sedang menyiapkan pesanan Anda',
|
||||||
|
time: '11:15',
|
||||||
|
date: '15 Juni 2024',
|
||||||
|
status: ActivityStatus.inProgress,
|
||||||
|
icon: Icons.timer,
|
||||||
|
),
|
||||||
|
ActivityItem(
|
||||||
|
title: 'Siap Dikirim',
|
||||||
|
description: 'Pesanan siap untuk dikirim ke alamat tujuan',
|
||||||
|
time: '',
|
||||||
|
date: '',
|
||||||
|
status: ActivityStatus.pending,
|
||||||
|
icon: Icons.local_shipping,
|
||||||
|
),
|
||||||
|
ActivityItem(
|
||||||
|
title: 'Dalam Perjalanan',
|
||||||
|
description: 'Pesanan sedang dalam perjalanan menuju alamat Anda',
|
||||||
|
time: '',
|
||||||
|
date: '',
|
||||||
|
status: ActivityStatus.pending,
|
||||||
|
icon: Icons.directions_car,
|
||||||
|
),
|
||||||
|
ActivityItem(
|
||||||
|
title: 'Pesanan Sampai',
|
||||||
|
description: 'Pesanan telah sampai di alamat tujuan',
|
||||||
|
time: '',
|
||||||
|
date: '',
|
||||||
|
status: ActivityStatus.pending,
|
||||||
|
icon: Icons.home,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Data contoh untuk pesanan selesai
|
||||||
|
final List<CompletedOrder> completedOrders = [
|
||||||
|
CompletedOrder(
|
||||||
|
orderId: '#12340',
|
||||||
|
title: 'Paket Makanan Premium',
|
||||||
|
description: '2x Nasi Gudeg, 1x Es Teh Manis',
|
||||||
|
totalAmount: 'Rp 85.000',
|
||||||
|
completedDate: '12 Juni 2024',
|
||||||
|
completedTime: '14:30',
|
||||||
|
rating: 5,
|
||||||
|
customerNote: 'Makanan enak sekali, pengiriman cepat!',
|
||||||
|
),
|
||||||
|
CompletedOrder(
|
||||||
|
orderId: '#12339',
|
||||||
|
title: 'Paket Minuman Segar',
|
||||||
|
description: '3x Jus Jeruk, 2x Smoothie Mangga',
|
||||||
|
totalAmount: 'Rp 65.000',
|
||||||
|
completedDate: '10 Juni 2024',
|
||||||
|
completedTime: '16:45',
|
||||||
|
rating: 4,
|
||||||
|
customerNote: 'Minuman segar, kemasan bagus',
|
||||||
|
),
|
||||||
|
CompletedOrder(
|
||||||
|
orderId: '#12338',
|
||||||
|
title: 'Paket Snack Keluarga',
|
||||||
|
description: '1x Risoles, 2x Pastel, 1x Kopi',
|
||||||
|
totalAmount: 'Rp 45.000',
|
||||||
|
completedDate: '8 Juni 2024',
|
||||||
|
completedTime: '10:15',
|
||||||
|
rating: 5,
|
||||||
|
customerNote: '',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Data contoh untuk pesanan dibatalkan
|
||||||
|
final List<CancelledOrder> cancelledOrders = [
|
||||||
|
CancelledOrder(
|
||||||
|
orderId: '#12337',
|
||||||
|
title: 'Paket Lunch Box',
|
||||||
|
description: '1x Nasi Ayam Geprek, 1x Es Jeruk',
|
||||||
|
totalAmount: 'Rp 35.000',
|
||||||
|
cancelledDate: '7 Juni 2024',
|
||||||
|
cancelledTime: '13:20',
|
||||||
|
cancelReason: 'Dibatalkan oleh pelanggan',
|
||||||
|
refundStatus: 'Dikembalikan',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
|
@ -42,7 +135,7 @@ class _ActivityScreenState extends State<ActivityScreen> {
|
||||||
unselectedLabelColor: Colors.black54,
|
unselectedLabelColor: Colors.black54,
|
||||||
tabs: [
|
tabs: [
|
||||||
TabItem(title: 'Proses', count: 6),
|
TabItem(title: 'Proses', count: 6),
|
||||||
TabItem(title: 'Gak Eroh', count: 3),
|
TabItem(title: 'Selesai', count: 3),
|
||||||
TabItem(title: 'Dibatalkan', count: 1),
|
TabItem(title: 'Dibatalkan', count: 1),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -52,12 +145,845 @@ class _ActivityScreenState extends State<ActivityScreen> {
|
||||||
),
|
),
|
||||||
body: TabBarView(
|
body: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
Center(child: InfoStateWidget(type: InfoStateType.emptyData)),
|
_buildProcessTab(),
|
||||||
Center(child: InfoStateWidget(type: InfoStateType.emptyData)),
|
_buildCompletedTab(),
|
||||||
Center(child: InfoStateWidget(type: InfoStateType.emptyData)),
|
_buildCancelledTab(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCompletedTab() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: completedOrders.isEmpty
|
||||||
|
? Center(child: InfoStateWidget(type: InfoStateType.emptyData))
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: completedOrders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final order = completedOrders[index];
|
||||||
|
return _buildCompletedOrderCard(order);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompletedOrderCard(CompletedOrder order) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha:0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header dengan status
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
order.orderId,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Selesai',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Konten pesanan
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.shopping_bag,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
order.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
order.description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Selesai pada ${order.completedDate} • ${order.completedTime}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
order.totalAmount,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildRatingStars(order.rating),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Customer note jika ada
|
||||||
|
if (order.customerNote.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.shade100),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.comment,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.blue.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Catatan Pelanggan:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
order.customerNote,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.blue.shade700,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Action untuk lihat detail
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
Icons.visibility,
|
||||||
|
size: 16,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Lihat Detail',
|
||||||
|
style: TextStyle(color: primaryColor),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: primaryColor),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Action untuk pesan lagi
|
||||||
|
},
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
'Pesan Lagi',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCancelledTab() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: cancelledOrders.isEmpty
|
||||||
|
? Center(child: InfoStateWidget(type: InfoStateType.emptyData))
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: cancelledOrders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final order = cancelledOrders[index];
|
||||||
|
return _buildCancelledOrderCard(order);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCancelledOrderCard(CancelledOrder order) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.red.shade100),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha:0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header dengan status
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
order.orderId,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cancel,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Dibatalkan',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Konten pesanan
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.shopping_bag_outlined,
|
||||||
|
color: Colors.red.shade600,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
order.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
order.description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Dibatalkan pada ${order.cancelledDate} • ${order.cancelledTime}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
order.totalAmount,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
decoration: TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Info pembatalan
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange.shade100),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Alasan Pembatalan:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
order.cancelReason,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.account_balance_wallet,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Status Refund: ${order.refundStatus}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Action untuk pesan lagi
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
size: 16,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Pesan Lagi',
|
||||||
|
style: TextStyle(color: primaryColor),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: primaryColor),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRatingStars(int rating) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(5, (index) {
|
||||||
|
return Icon(
|
||||||
|
index < rating ? Icons.star : Icons.star_border,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.orange,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProcessTab() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header Card
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha:0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor.withValues(alpha:0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.shopping_bag,
|
||||||
|
color: primaryColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Pesanan #12345',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Total: Rp 150.000',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Dalam Proses',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Timeline Section
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha:0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Status Pesanan',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildTimeline(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimeline() {
|
||||||
|
return Timeline.tileBuilder(
|
||||||
|
theme: TimelineThemeData(
|
||||||
|
nodePosition: 0,
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
indicatorTheme: const IndicatorThemeData(
|
||||||
|
position: 0,
|
||||||
|
size: 20.0,
|
||||||
|
),
|
||||||
|
connectorTheme: const ConnectorThemeData(
|
||||||
|
thickness: 2.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
builder: TimelineTileBuilder.connected(
|
||||||
|
connectionDirection: ConnectionDirection.before,
|
||||||
|
itemCount: processActivities.length,
|
||||||
|
contentsBuilder: (context, index) {
|
||||||
|
return _buildTimelineContent(processActivities[index]);
|
||||||
|
},
|
||||||
|
indicatorBuilder: (context, index) {
|
||||||
|
return _buildTimelineIndicator(processActivities[index]);
|
||||||
|
},
|
||||||
|
connectorBuilder: (context, index, type) {
|
||||||
|
return SolidLineConnector(
|
||||||
|
color: index < processActivities.length - 1 &&
|
||||||
|
processActivities[index].status == ActivityStatus.completed
|
||||||
|
? primaryColor
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimelineIndicator(ActivityItem item) {
|
||||||
|
Color indicatorColor;
|
||||||
|
Widget indicatorChild;
|
||||||
|
|
||||||
|
switch (item.status) {
|
||||||
|
case ActivityStatus.completed:
|
||||||
|
indicatorColor = primaryColor;
|
||||||
|
indicatorChild = Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 12,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case ActivityStatus.inProgress:
|
||||||
|
indicatorColor = Colors.orange;
|
||||||
|
indicatorChild = Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case ActivityStatus.pending:
|
||||||
|
indicatorColor = Colors.grey.shade300;
|
||||||
|
indicatorChild = Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DotIndicator(
|
||||||
|
size: 20,
|
||||||
|
color: indicatorColor,
|
||||||
|
child: indicatorChild,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimelineContent(ActivityItem item) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, bottom: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
item.icon,
|
||||||
|
size: 20,
|
||||||
|
color: item.status == ActivityStatus.completed
|
||||||
|
? primaryColor
|
||||||
|
: item.status == ActivityStatus.inProgress
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
item.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: item.status == ActivityStatus.pending
|
||||||
|
? Colors.grey.shade500
|
||||||
|
: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 28),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: item.status == ActivityStatus.pending
|
||||||
|
? Colors.grey.shade400
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (item.time.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${item.time} • ${item.date}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Model untuk item aktivitas
|
||||||
|
class ActivityItem {
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String time;
|
||||||
|
final String date;
|
||||||
|
final ActivityStatus status;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
ActivityItem({
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.time,
|
||||||
|
required this.date,
|
||||||
|
required this.status,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model untuk pesanan selesai
|
||||||
|
class CompletedOrder {
|
||||||
|
final String orderId;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String totalAmount;
|
||||||
|
final String completedDate;
|
||||||
|
final String completedTime;
|
||||||
|
final int rating;
|
||||||
|
final String customerNote;
|
||||||
|
|
||||||
|
CompletedOrder({
|
||||||
|
required this.orderId,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.completedDate,
|
||||||
|
required this.completedTime,
|
||||||
|
required this.rating,
|
||||||
|
required this.customerNote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model untuk pesanan dibatalkan
|
||||||
|
class CancelledOrder {
|
||||||
|
final String orderId;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String totalAmount;
|
||||||
|
final String cancelledDate;
|
||||||
|
final String cancelledTime;
|
||||||
|
final String cancelReason;
|
||||||
|
final String refundStatus;
|
||||||
|
|
||||||
|
CancelledOrder({
|
||||||
|
required this.orderId,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.cancelledDate,
|
||||||
|
required this.cancelledTime,
|
||||||
|
required this.cancelReason,
|
||||||
|
required this.refundStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum untuk status aktivitas
|
||||||
|
enum ActivityStatus {
|
||||||
|
completed,
|
||||||
|
inProgress,
|
||||||
|
pending,
|
||||||
|
}
|
|
@ -85,18 +85,22 @@ class CloginScreenState extends State<CloginScreen> {
|
||||||
horizontal: double.infinity,
|
horizontal: double.infinity,
|
||||||
vertical: 50,
|
vertical: 50,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// if (cPhoneController.text.isNotEmpty) {
|
if (cPhoneController.text.isNotEmpty) {
|
||||||
// debugPrint("send otp dipencet");
|
debugPrint("send otp dipencet");
|
||||||
// await viewModel.loginOrRegister(
|
router.go(
|
||||||
// cPhoneController.text,
|
"/cverif-otp",
|
||||||
// );
|
extra: cPhoneController.text,
|
||||||
// if (viewModel.loginResponse != null) {
|
);
|
||||||
// router.go(
|
// await viewModel.loginOrRegister(
|
||||||
// "/verif-otp",
|
// cPhoneController.text,
|
||||||
// extra: cPhoneController.text,
|
// );
|
||||||
// );
|
// if (viewModel.loginResponse != null) {
|
||||||
// }
|
// router.go(
|
||||||
// }
|
// "/verif-otp",
|
||||||
|
// extra: cPhoneController.text,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// loadingTrue: viewModel.isLoading,
|
// loadingTrue: viewModel.isLoading,
|
||||||
usingRow: false,
|
usingRow: false,
|
||||||
|
|
|
@ -0,0 +1,716 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:nik_validator/nik_validator.dart';
|
||||||
|
|
||||||
|
enum KtpValidationState { initial, imageSelected, processing, success, error }
|
||||||
|
|
||||||
|
enum ProcessingStatus { idle, preprocessing, extracting, validating, completed }
|
||||||
|
|
||||||
|
class ImageProcessingData {
|
||||||
|
final Uint8List imageBytes;
|
||||||
|
final String imagePath;
|
||||||
|
|
||||||
|
ImageProcessingData(this.imageBytes, this.imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProcessedImageResult {
|
||||||
|
final String processedPath;
|
||||||
|
final bool success;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
ProcessedImageResult({
|
||||||
|
required this.processedPath,
|
||||||
|
required this.success,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class KtpData {
|
||||||
|
String identificationNumber;
|
||||||
|
String fullName;
|
||||||
|
String bloodType;
|
||||||
|
String hamlet;
|
||||||
|
String village;
|
||||||
|
String neighbourhood;
|
||||||
|
String religion;
|
||||||
|
String maritalStatus;
|
||||||
|
String job;
|
||||||
|
String citizenship;
|
||||||
|
String validUntil;
|
||||||
|
String placeOfBirth;
|
||||||
|
String dateOfBirth;
|
||||||
|
String gender;
|
||||||
|
String province;
|
||||||
|
String district;
|
||||||
|
String subDistrict;
|
||||||
|
String postalCode;
|
||||||
|
|
||||||
|
KtpData({
|
||||||
|
this.identificationNumber = '',
|
||||||
|
this.fullName = '',
|
||||||
|
this.bloodType = '',
|
||||||
|
this.hamlet = '',
|
||||||
|
this.village = '',
|
||||||
|
this.neighbourhood = '',
|
||||||
|
this.religion = '',
|
||||||
|
this.maritalStatus = '',
|
||||||
|
this.job = '',
|
||||||
|
this.citizenship = '',
|
||||||
|
this.validUntil = '',
|
||||||
|
this.placeOfBirth = '',
|
||||||
|
this.dateOfBirth = '',
|
||||||
|
this.gender = '',
|
||||||
|
this.province = '',
|
||||||
|
this.district = '',
|
||||||
|
this.subDistrict = '',
|
||||||
|
this.postalCode = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
identificationNumber = '';
|
||||||
|
fullName = '';
|
||||||
|
bloodType = '';
|
||||||
|
hamlet = '';
|
||||||
|
village = '';
|
||||||
|
neighbourhood = '';
|
||||||
|
religion = '';
|
||||||
|
maritalStatus = '';
|
||||||
|
job = '';
|
||||||
|
citizenship = '';
|
||||||
|
validUntil = '';
|
||||||
|
placeOfBirth = '';
|
||||||
|
dateOfBirth = '';
|
||||||
|
gender = '';
|
||||||
|
province = '';
|
||||||
|
district = '';
|
||||||
|
subDistrict = '';
|
||||||
|
postalCode = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasData => identificationNumber.isNotEmpty;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'identificationNumber': identificationNumber,
|
||||||
|
'fullName': fullName,
|
||||||
|
'bloodType': bloodType,
|
||||||
|
'hamlet': hamlet,
|
||||||
|
'village': village,
|
||||||
|
'neighbourhood': neighbourhood,
|
||||||
|
'religion': religion,
|
||||||
|
'maritalStatus': maritalStatus,
|
||||||
|
'job': job,
|
||||||
|
'citizenship': citizenship,
|
||||||
|
'validUntil': validUntil,
|
||||||
|
'placeOfBirth': placeOfBirth,
|
||||||
|
'dateOfBirth': dateOfBirth,
|
||||||
|
'gender': gender,
|
||||||
|
'province': province,
|
||||||
|
'district': district,
|
||||||
|
'subDistrict': subDistrict,
|
||||||
|
'postalCode': postalCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class KtpValidatorController extends ChangeNotifier {
|
||||||
|
static const int _maxImageSize = 2048;
|
||||||
|
static const int _imageQuality = 85;
|
||||||
|
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
final KtpData _ktpData = KtpData();
|
||||||
|
final Map<String, TextEditingController> _controllers = {};
|
||||||
|
|
||||||
|
File? _selectedImage;
|
||||||
|
File? _processedImage;
|
||||||
|
KtpValidationState _state = KtpValidationState.initial;
|
||||||
|
ProcessingStatus _processingStatus = ProcessingStatus.idle;
|
||||||
|
String _errorMessage = '';
|
||||||
|
String _successMessage = '';
|
||||||
|
TextRecognizer? _textRecognizer;
|
||||||
|
|
||||||
|
// Getters tetap sama
|
||||||
|
KtpValidationState get state => _state;
|
||||||
|
ProcessingStatus get processingStatus => _processingStatus;
|
||||||
|
String get errorMessage => _errorMessage;
|
||||||
|
String get successMessage => _successMessage;
|
||||||
|
File? get selectedImage => _selectedImage;
|
||||||
|
KtpData get ktpData => _ktpData;
|
||||||
|
Map<String, TextEditingController> get controllers => _controllers;
|
||||||
|
bool get isProcessing => _state == KtpValidationState.processing;
|
||||||
|
bool get hasData => _ktpData.hasData;
|
||||||
|
bool get canProcessImage => _selectedImage != null && !isProcessing;
|
||||||
|
|
||||||
|
void initialize() {
|
||||||
|
_initializeControllers();
|
||||||
|
_initializeTextRecognizer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeControllers() {
|
||||||
|
final fields = [
|
||||||
|
'nik',
|
||||||
|
'name',
|
||||||
|
'placeOfBirth',
|
||||||
|
'dateOfBirth',
|
||||||
|
'gender',
|
||||||
|
'bloodType',
|
||||||
|
'hamlet',
|
||||||
|
'village',
|
||||||
|
'neighbourhood',
|
||||||
|
'religion',
|
||||||
|
'maritalStatus',
|
||||||
|
'job',
|
||||||
|
'citizenship',
|
||||||
|
'validUntil',
|
||||||
|
'province',
|
||||||
|
'district',
|
||||||
|
'subDistrict',
|
||||||
|
'postalCode',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (String field in fields) {
|
||||||
|
_controllers[field] = TextEditingController();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeTextRecognizer() {
|
||||||
|
_textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickImageFromCamera() async {
|
||||||
|
await _pickImage(ImageSource.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickImageFromGallery() async {
|
||||||
|
await _pickImage(ImageSource.gallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
try {
|
||||||
|
final XFile? image = await _picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: _imageQuality,
|
||||||
|
maxWidth: _maxImageSize.toDouble(),
|
||||||
|
maxHeight: _maxImageSize.toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (image != null) {
|
||||||
|
_cleanupTempFiles();
|
||||||
|
_selectedImage = File(image.path);
|
||||||
|
_processedImage = null;
|
||||||
|
_ktpData.clear();
|
||||||
|
_clearControllers();
|
||||||
|
|
||||||
|
_updateState(KtpValidationState.imageSelected);
|
||||||
|
_clearMessages();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_handleError('Error saat memilih gambar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> processImage() async {
|
||||||
|
if (_selectedImage == null || _textRecognizer == null) return;
|
||||||
|
|
||||||
|
_updateState(KtpValidationState.processing);
|
||||||
|
_updateProcessingStatus(ProcessingStatus.preprocessing);
|
||||||
|
_ktpData.clear();
|
||||||
|
_clearControllers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Improved image processing
|
||||||
|
final ProcessedImageResult result = await _processImageOptimized(
|
||||||
|
_selectedImage!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw Exception(result.error ?? 'Image processing failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
_processedImage = File(result.processedPath);
|
||||||
|
_updateProcessingStatus(ProcessingStatus.extracting);
|
||||||
|
|
||||||
|
// Enhanced OCR with multiple attempts
|
||||||
|
final String extractedNIK = await _performMultipleOCRAttempts(
|
||||||
|
_processedImage!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extractedNIK.isEmpty) {
|
||||||
|
throw Exception(
|
||||||
|
'NIK tidak ditemukan. Pastikan foto KTP jelas dan tidak buram.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateProcessingStatus(ProcessingStatus.validating);
|
||||||
|
|
||||||
|
await _parseNIK(extractedNIK);
|
||||||
|
|
||||||
|
_updateProcessingStatus(ProcessingStatus.completed);
|
||||||
|
_updateControllers();
|
||||||
|
_updateState(KtpValidationState.success);
|
||||||
|
_setSuccessMessage('NIK berhasil terdeteksi: $extractedNIK');
|
||||||
|
} catch (e) {
|
||||||
|
_handleError('Error saat memproses gambar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _performMultipleOCRAttempts(File processedImage) async {
|
||||||
|
try {
|
||||||
|
// Attempt 1: Original processed image
|
||||||
|
String nik = await _performEnhancedOCR(processedImage);
|
||||||
|
if (nik.isNotEmpty) {
|
||||||
|
debugPrint('NIK found in attempt 1: $nik');
|
||||||
|
return nik;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 2: Try with different image processing
|
||||||
|
final File alternativeProcessed = await _createAlternativeProcessedImage(
|
||||||
|
processedImage,
|
||||||
|
);
|
||||||
|
nik = await _performEnhancedOCR(alternativeProcessed);
|
||||||
|
if (nik.isNotEmpty) {
|
||||||
|
debugPrint('NIK found in attempt 2: $nik');
|
||||||
|
return nik;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt 3: Try with original image (no processing)
|
||||||
|
nik = await _performEnhancedOCR(_selectedImage!);
|
||||||
|
if (nik.isNotEmpty) {
|
||||||
|
debugPrint('NIK found in attempt 3 (original): $nik');
|
||||||
|
return nik;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('NIK not found in any attempt');
|
||||||
|
return '';
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Multiple OCR attempts error: $e');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _createAlternativeProcessedImage(File originalProcessed) async {
|
||||||
|
try {
|
||||||
|
final Uint8List imageBytes = await originalProcessed.readAsBytes();
|
||||||
|
final img.Image? image = img.decodeImage(imageBytes);
|
||||||
|
|
||||||
|
if (image == null) return originalProcessed;
|
||||||
|
|
||||||
|
img.Image processed = image;
|
||||||
|
|
||||||
|
// Different processing approach
|
||||||
|
processed = img.adjustColor(processed, contrast: 1.6, brightness: 1.2);
|
||||||
|
processed = img.gaussianBlur(processed, radius: 1);
|
||||||
|
processed = img.adjustColor(processed, contrast: 1.3);
|
||||||
|
|
||||||
|
// Apply threshold for better text recognition
|
||||||
|
processed = _applyThreshold(processed, 128);
|
||||||
|
|
||||||
|
final String altProcessedPath = originalProcessed.path.replaceAll(
|
||||||
|
'_processed.jpg',
|
||||||
|
'_alt_processed.jpg',
|
||||||
|
);
|
||||||
|
final File altProcessedFile = File(altProcessedPath);
|
||||||
|
await altProcessedFile.writeAsBytes(
|
||||||
|
img.encodeJpg(processed, quality: 95),
|
||||||
|
);
|
||||||
|
|
||||||
|
return altProcessedFile;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Alternative processing error: $e');
|
||||||
|
return originalProcessed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Image _applyThreshold(img.Image image, int threshold) {
|
||||||
|
for (int y = 0; y < image.height; y++) {
|
||||||
|
for (int x = 0; x < image.width; x++) {
|
||||||
|
final pixel = image.getPixel(x, y);
|
||||||
|
final gray = img.getLuminance(pixel);
|
||||||
|
final newPixel =
|
||||||
|
gray > threshold
|
||||||
|
? img.ColorRgb8(255, 255, 255)
|
||||||
|
: img.ColorRgb8(0, 0, 0);
|
||||||
|
image.setPixel(x, y, newPixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProcessedImageResult> _processImageOptimized(File imageFile) async {
|
||||||
|
try {
|
||||||
|
final Uint8List imageBytes = await imageFile.readAsBytes();
|
||||||
|
return await compute(
|
||||||
|
_processImageInIsolate,
|
||||||
|
ImageProcessingData(imageBytes, imageFile.path),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return ProcessedImageResult(
|
||||||
|
processedPath: imageFile.path,
|
||||||
|
success: false,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _performEnhancedOCR(File imageFile) async {
|
||||||
|
try {
|
||||||
|
final InputImage inputImage = InputImage.fromFile(imageFile);
|
||||||
|
final RecognizedText recognizedText = await _textRecognizer!.processImage(
|
||||||
|
inputImage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debug: Print all detected text
|
||||||
|
debugPrint('OCR Raw Text: ${recognizedText.text}');
|
||||||
|
|
||||||
|
// Print each text block for debugging
|
||||||
|
for (TextBlock block in recognizedText.blocks) {
|
||||||
|
debugPrint('Text Block: ${block.text}');
|
||||||
|
for (TextLine line in block.lines) {
|
||||||
|
debugPrint('Text Line: ${line.text}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try multiple extraction methods
|
||||||
|
String nik = _extractNIKEnhanced(recognizedText.text);
|
||||||
|
if (nik.isNotEmpty) return nik;
|
||||||
|
|
||||||
|
nik = _extractNIKFromBlocks(recognizedText.blocks);
|
||||||
|
if (nik.isNotEmpty) return nik;
|
||||||
|
|
||||||
|
nik = _extractNIKFallback(recognizedText.text);
|
||||||
|
return nik;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('OCR Error: $e');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPROVED: Enhanced NIK extraction with better patterns
|
||||||
|
String _extractNIKEnhanced(String ocrText) {
|
||||||
|
final List<RegExp> nikPatterns = [
|
||||||
|
// Standard 16 digits
|
||||||
|
RegExp(r'\b(\d{16})\b'),
|
||||||
|
// With spaces or separators
|
||||||
|
RegExp(r'(\d{2}[\s\-_]?\d{2}[\s\-_]?\d{2}[\s\-_]?\d{6}[\s\-_]?\d{4})'),
|
||||||
|
RegExp(r'(\d{4}[\s\-_]?\d{4}[\s\-_]?\d{4}[\s\-_]?\d{4})'),
|
||||||
|
// More flexible patterns
|
||||||
|
RegExp(r'(\d{2}\s*\d{2}\s*\d{2}\s*\d{6}\s*\d{4})'),
|
||||||
|
RegExp(r'(\d{6}\s*\d{6}\s*\d{4})'),
|
||||||
|
// Looking for NIK label followed by numbers
|
||||||
|
RegExp(r'(?:NIK|No\.?\s*KTP)[\s:]*(\d{16})', caseSensitive: false),
|
||||||
|
RegExp(
|
||||||
|
r'(?:NIK|No\.?\s*KTP)[\s:]*(\d{2}[\s\-_]?\d{2}[\s\-_]?\d{2}[\s\-_]?\d{6}[\s\-_]?\d{4})',
|
||||||
|
caseSensitive: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Clean text for better matching
|
||||||
|
String cleanText =
|
||||||
|
ocrText
|
||||||
|
.replaceAll(RegExp(r'[^\d\s\-_:]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
debugPrint('Clean text for NIK extraction: $cleanText');
|
||||||
|
|
||||||
|
for (RegExp pattern in nikPatterns) {
|
||||||
|
final Match? match = pattern.firstMatch(ocrText);
|
||||||
|
if (match != null) {
|
||||||
|
String candidate = match.group(1) ?? match.group(0)!;
|
||||||
|
candidate = candidate.replaceAll(RegExp(r'[\s\-_]+'), '');
|
||||||
|
|
||||||
|
debugPrint('Pattern matched candidate: $candidate');
|
||||||
|
|
||||||
|
if (candidate.length == 16 && _isValidNIKFormat(candidate)) {
|
||||||
|
debugPrint('Valid NIK found: $candidate');
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extractNIKFromBlocks(List<TextBlock> blocks) {
|
||||||
|
for (TextBlock block in blocks) {
|
||||||
|
for (TextLine line in block.lines) {
|
||||||
|
String lineText = line.text;
|
||||||
|
debugPrint('Checking line: $lineText');
|
||||||
|
|
||||||
|
// Look for lines that might contain NIK
|
||||||
|
if (lineText.toLowerCase().contains('nik') ||
|
||||||
|
lineText.toLowerCase().contains('ktp') ||
|
||||||
|
RegExp(r'\d{12,}').hasMatch(lineText)) {
|
||||||
|
String nik = _extractNIKEnhanced(lineText);
|
||||||
|
if (nik.isNotEmpty) {
|
||||||
|
debugPrint('NIK found in line: $lineText -> $nik');
|
||||||
|
return nik;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extractNIKFallback(String ocrText) {
|
||||||
|
final RegExp numberPattern = RegExp(r'\d+');
|
||||||
|
final Iterable<Match> matches = numberPattern.allMatches(ocrText);
|
||||||
|
|
||||||
|
for (Match match in matches) {
|
||||||
|
String candidate = match.group(0)!;
|
||||||
|
|
||||||
|
if (candidate.length == 16 && _isValidNIKFormat(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.length > 16) {
|
||||||
|
for (int i = 0; i <= candidate.length - 16; i++) {
|
||||||
|
String subCandidate = candidate.substring(i, i + 16);
|
||||||
|
if (_isValidNIKFormat(subCandidate)) {
|
||||||
|
return subCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValidNIKFormat(String nik) {
|
||||||
|
if (nik.length != 16) return false;
|
||||||
|
|
||||||
|
int provinceCode = int.tryParse(nik.substring(0, 2)) ?? 0;
|
||||||
|
if (provinceCode < 11 || provinceCode > 94) return false;
|
||||||
|
|
||||||
|
int regencyCode = int.tryParse(nik.substring(2, 4)) ?? 0;
|
||||||
|
if (regencyCode < 1 || regencyCode > 99) return false;
|
||||||
|
|
||||||
|
int districtCode = int.tryParse(nik.substring(4, 6)) ?? 0;
|
||||||
|
if (districtCode < 1 || districtCode > 99) return false;
|
||||||
|
|
||||||
|
String birthDate = nik.substring(6, 12);
|
||||||
|
int day = int.tryParse(birthDate.substring(0, 2)) ?? 0;
|
||||||
|
int month = int.tryParse(birthDate.substring(2, 4)) ?? 0;
|
||||||
|
|
||||||
|
if (day > 40) day -= 40;
|
||||||
|
|
||||||
|
if (day < 1 || day > 31) return false;
|
||||||
|
if (month < 1 || month > 12) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _parseNIK(String nik) async {
|
||||||
|
try {
|
||||||
|
final NIKModel result = await NIKValidator.instance.parse(nik: nik);
|
||||||
|
|
||||||
|
if (result.valid == true) {
|
||||||
|
_ktpData.identificationNumber = result.nik ?? '';
|
||||||
|
_ktpData.gender = result.gender ?? '';
|
||||||
|
_ktpData.dateOfBirth = result.bornDate ?? '';
|
||||||
|
_ktpData.province = result.province ?? '';
|
||||||
|
_ktpData.district = result.city ?? '';
|
||||||
|
_ktpData.subDistrict = result.subdistrict ?? '';
|
||||||
|
_ktpData.postalCode = result.postalCode ?? '';
|
||||||
|
} else {
|
||||||
|
throw Exception('NIK tidak valid: $nik');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error saat memvalidasi NIK: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateControllerValue(String key, String value) {
|
||||||
|
if (_controllers.containsKey(key)) {
|
||||||
|
_controllers[key]?.text = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateControllers() {
|
||||||
|
_controllers['nik']?.text = _ktpData.identificationNumber;
|
||||||
|
_controllers['name']?.text = _ktpData.fullName;
|
||||||
|
_controllers['placeOfBirth']?.text = _ktpData.placeOfBirth;
|
||||||
|
_controllers['dateOfBirth']?.text = _ktpData.dateOfBirth;
|
||||||
|
_controllers['gender']?.text = _ktpData.gender;
|
||||||
|
_controllers['bloodType']?.text = _ktpData.bloodType;
|
||||||
|
_controllers['hamlet']?.text = _ktpData.hamlet;
|
||||||
|
_controllers['village']?.text = _ktpData.village;
|
||||||
|
_controllers['neighbourhood']?.text = _ktpData.neighbourhood;
|
||||||
|
_controllers['religion']?.text = _ktpData.religion;
|
||||||
|
_controllers['maritalStatus']?.text = _ktpData.maritalStatus;
|
||||||
|
_controllers['job']?.text = _ktpData.job;
|
||||||
|
_controllers['citizenship']?.text = _ktpData.citizenship;
|
||||||
|
_controllers['validUntil']?.text = _ktpData.validUntil;
|
||||||
|
_controllers['province']?.text = _ktpData.province;
|
||||||
|
_controllers['district']?.text = _ktpData.district;
|
||||||
|
_controllers['subDistrict']?.text = _ktpData.subDistrict;
|
||||||
|
_controllers['postalCode']?.text = _ktpData.postalCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearControllers() {
|
||||||
|
for (var controller in _controllers.values) {
|
||||||
|
controller.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validateForm() {
|
||||||
|
final nikValue = _controllers['nik']?.text ?? '';
|
||||||
|
final nameValue = _controllers['name']?.text ?? '';
|
||||||
|
|
||||||
|
if (nikValue.length != 16) {
|
||||||
|
_handleError('NIK harus 16 digit');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameValue.isEmpty) {
|
||||||
|
_handleError('Nama lengkap harus diisi');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> saveData() async {
|
||||||
|
if (!validateForm()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_ktpData.identificationNumber = _controllers['nik']?.text ?? '';
|
||||||
|
_ktpData.fullName = _controllers['name']?.text ?? '';
|
||||||
|
_ktpData.placeOfBirth = _controllers['placeOfBirth']?.text ?? '';
|
||||||
|
_ktpData.dateOfBirth = _controllers['dateOfBirth']?.text ?? '';
|
||||||
|
_ktpData.gender = _controllers['gender']?.text ?? '';
|
||||||
|
_ktpData.bloodType = _controllers['bloodType']?.text ?? '';
|
||||||
|
_ktpData.neighbourhood = _controllers['neighbourhood']?.text ?? '';
|
||||||
|
_ktpData.village = _controllers['village']?.text ?? '';
|
||||||
|
_ktpData.religion = _controllers['religion']?.text ?? '';
|
||||||
|
_ktpData.maritalStatus = _controllers['maritalStatus']?.text ?? '';
|
||||||
|
_ktpData.job = _controllers['job']?.text ?? '';
|
||||||
|
_ktpData.citizenship = _controllers['citizenship']?.text ?? '';
|
||||||
|
_ktpData.validUntil = _controllers['validUntil']?.text ?? '';
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
_setSuccessMessage('Data KTP berhasil disimpan!');
|
||||||
|
debugPrint('KTP Data saved: ${_ktpData.toJson()}');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
_handleError('Error saat menyimpan data: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateState(KtpValidationState newState) {
|
||||||
|
_state = newState;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateProcessingStatus(ProcessingStatus status) {
|
||||||
|
_processingStatus = status;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleError(String message) {
|
||||||
|
_errorMessage = message;
|
||||||
|
_successMessage = '';
|
||||||
|
_updateState(KtpValidationState.error);
|
||||||
|
debugPrint('KTP Validation Error: $message');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setSuccessMessage(String message) {
|
||||||
|
_successMessage = message;
|
||||||
|
_errorMessage = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearMessages() {
|
||||||
|
_errorMessage = '';
|
||||||
|
_successMessage = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearError() {
|
||||||
|
_errorMessage = '';
|
||||||
|
if (_state == KtpValidationState.error) {
|
||||||
|
_updateState(
|
||||||
|
_selectedImage != null
|
||||||
|
? KtpValidationState.imageSelected
|
||||||
|
: KtpValidationState.initial,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cleanupTempFiles() {
|
||||||
|
try {
|
||||||
|
_processedImage?.deleteSync();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error cleaning temp files: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_textRecognizer?.close();
|
||||||
|
for (var controller in _controllers.values) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
_cleanupTempFiles();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProcessedImageResult> _processImageInIsolate(
|
||||||
|
ImageProcessingData data,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
img.Image? image = img.decodeImage(data.imageBytes);
|
||||||
|
if (image == null) {
|
||||||
|
return ProcessedImageResult(
|
||||||
|
processedPath: data.imagePath,
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to decode image',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Image processed = image;
|
||||||
|
|
||||||
|
processed = img.grayscale(processed);
|
||||||
|
|
||||||
|
processed = img.adjustColor(processed, contrast: 1.4, brightness: 1.1);
|
||||||
|
|
||||||
|
processed = img.convolution(
|
||||||
|
processed,
|
||||||
|
filter: [0, -1, 0, -1, 5, -1, 0, -1, 0],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (processed.width < 1200) {
|
||||||
|
processed = img.copyResize(processed, width: 1200);
|
||||||
|
} else if (processed.width > 1600) {
|
||||||
|
processed = img.copyResize(processed, width: 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
processed = img.gaussianBlur(processed, radius: 1);
|
||||||
|
|
||||||
|
processed = img.adjustColor(processed, contrast: 1.2);
|
||||||
|
|
||||||
|
processed = img.normalize(processed, min: 0, max: 255);
|
||||||
|
|
||||||
|
final String processedPath = data.imagePath.replaceAll(
|
||||||
|
'.jpg',
|
||||||
|
'_processed.jpg',
|
||||||
|
);
|
||||||
|
final File processedFile = File(processedPath);
|
||||||
|
await processedFile.writeAsBytes(img.encodeJpg(processed, quality: 95));
|
||||||
|
|
||||||
|
return ProcessedImageResult(processedPath: processedPath, success: true);
|
||||||
|
} catch (e) {
|
||||||
|
return ProcessedImageResult(
|
||||||
|
processedPath: data.imagePath,
|
||||||
|
success: false,
|
||||||
|
error: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,178 +1,684 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:provider/provider.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:rijig_mobile/core/router.dart';
|
import 'package:rijig_mobile/core/router.dart';
|
||||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||||
|
import 'package:rijig_mobile/widget/formfiled.dart';
|
||||||
|
import 'package:rijig_mobile/features/auth/presentation/screen/collector/controller/ktp_validator_controller.dart';
|
||||||
|
|
||||||
class UploadKtpScreen extends StatefulWidget {
|
class IdentityValidationScreen extends StatefulWidget {
|
||||||
const UploadKtpScreen({super.key});
|
const IdentityValidationScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<UploadKtpScreen> createState() => _UploadKtpScreenState();
|
State<IdentityValidationScreen> createState() =>
|
||||||
|
_IdentityValidationScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UploadKtpScreenState extends State<UploadKtpScreen> {
|
class _IdentityValidationScreenState extends State<IdentityValidationScreen>
|
||||||
File? _ktpImage;
|
with AutomaticKeepAliveClientMixin {
|
||||||
bool isLoading = false;
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||||
|
late KtpValidatorController _controller;
|
||||||
final Map<String, TextEditingController> controllers = {
|
|
||||||
"NIK": TextEditingController(),
|
|
||||||
"Nama": TextEditingController(),
|
|
||||||
"Tempat/Tgl Lahir": TextEditingController(),
|
|
||||||
"Alamat": TextEditingController(),
|
|
||||||
"RT/RW": TextEditingController(),
|
|
||||||
"Kel/Desa": TextEditingController(),
|
|
||||||
"Kecamatan": TextEditingController(),
|
|
||||||
"Agama": TextEditingController(),
|
|
||||||
"Status Perkawinan": TextEditingController(),
|
|
||||||
"Pekerjaan": TextEditingController(),
|
|
||||||
"Kewarganegaraan": TextEditingController(),
|
|
||||||
"Berlaku Hingga": TextEditingController(),
|
|
||||||
};
|
|
||||||
|
|
||||||
final Map<String, List<String>> labelSynonyms = {
|
|
||||||
"NIK": ["nik"],
|
|
||||||
"Nama": ["nama", "nama lengkap"],
|
|
||||||
"Tempat/Tgl Lahir": ["tempat/tgl lahir", "tempat lahir", "tgl lahir"],
|
|
||||||
"Alamat": ["alamat"],
|
|
||||||
"RT/RW": ["rt/rw", "rtrw", "rt rw"],
|
|
||||||
"Kel/Desa": ["kelurahan", "desa", "kel/desa"],
|
|
||||||
"Kecamatan": ["kecamatan"],
|
|
||||||
"Agama": ["agama"],
|
|
||||||
"Status Perkawinan": ["status", "status perkawinan", "perkawinan"],
|
|
||||||
"Pekerjaan": ["pekerjaan"],
|
|
||||||
"Kewarganegaraan": ["kewarganegaraan", "warga negara"],
|
|
||||||
"Berlaku Hingga": ["berlaku", "berlaku hingga"],
|
|
||||||
};
|
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
|
||||||
final pickedFile = await ImagePicker().pickImage(
|
|
||||||
source: ImageSource.camera,
|
|
||||||
);
|
|
||||||
if (pickedFile != null) {
|
|
||||||
setState(() {
|
|
||||||
isLoading = true;
|
|
||||||
_ktpImage = File(pickedFile.path);
|
|
||||||
});
|
|
||||||
await _processImageWithEnhancements(File(pickedFile.path));
|
|
||||||
setState(() => isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _processImageWithEnhancements(File imageFile) async {
|
|
||||||
final rawBytes = await imageFile.readAsBytes();
|
|
||||||
final originalImage = img.decodeImage(rawBytes);
|
|
||||||
if (originalImage == null) return;
|
|
||||||
|
|
||||||
// Enhance image: grayscale + auto rotate + thresholding
|
|
||||||
var processedImage = img.grayscale(originalImage);
|
|
||||||
if (processedImage.width > processedImage.height) {
|
|
||||||
processedImage = img.copyRotate(processedImage, angle: -90);
|
|
||||||
}
|
|
||||||
// processedImage = img.threshold(processedImage, threshold: 128);
|
|
||||||
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
|
||||||
final enhancedPath = '${tempDir.path}/enhanced_ktp.jpg';
|
|
||||||
final enhancedFile = File(enhancedPath)
|
|
||||||
..writeAsBytesSync(img.encodeJpg(processedImage));
|
|
||||||
|
|
||||||
final inputImage = InputImage.fromFile(enhancedFile);
|
|
||||||
final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
|
||||||
final RecognizedText recognizedText = await textRecognizer.processImage(
|
|
||||||
inputImage,
|
|
||||||
);
|
|
||||||
await textRecognizer.close();
|
|
||||||
|
|
||||||
final lines = recognizedText.text.split('\n');
|
|
||||||
debugPrint("[OCR Result]\n${recognizedText.text}");
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
for (var key in controllers.keys) {
|
|
||||||
final value = _extractMultilineValue(key, lines);
|
|
||||||
controllers[key]?.text = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
String _extractMultilineValue(String key, List<String> lines) {
|
|
||||||
final List<String> keywords = labelSynonyms[key] ?? [key];
|
|
||||||
for (int i = 0; i < lines.length; i++) {
|
|
||||||
final line = lines[i].toLowerCase();
|
|
||||||
for (final kw in keywords) {
|
|
||||||
if (line.contains(kw.toLowerCase())) {
|
|
||||||
if (lines[i].contains(":")) {
|
|
||||||
return lines[i].split(":").last.trim();
|
|
||||||
} else if (i + 1 < lines.length) {
|
|
||||||
return lines[i + 1].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
bool get wantKeepAlive => true;
|
||||||
for (final controller in controllers.values) {
|
|
||||||
controller.dispose();
|
@override
|
||||||
}
|
void initState() {
|
||||||
super.dispose();
|
super.initState();
|
||||||
|
_controller = context.read<KtpValidatorController>();
|
||||||
|
_controller.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: whiteColor,
|
backgroundColor: whiteColor,
|
||||||
appBar: AppBar(
|
appBar: _buildAppBar(),
|
||||||
title: const Text("Upload KTP"),
|
body: _buildBody(),
|
||||||
backgroundColor: primaryColor,
|
);
|
||||||
foregroundColor: whiteColor,
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar() {
|
||||||
|
return AppBar(
|
||||||
|
title: Text(
|
||||||
|
'Validasi Identitas KTP',
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: bold,
|
||||||
|
color: whiteColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
backgroundColor: primaryColor,
|
||||||
child: SingleChildScrollView(
|
elevation: 0,
|
||||||
padding: const EdgeInsets.all(20),
|
centerTitle: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(Icons.arrow_back_ios, color: whiteColor),
|
||||||
|
onPressed: () => router.pop(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
return Consumer<KtpValidatorController>(
|
||||||
|
builder: (context, controller, child) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(20.w),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_ktpImage != null
|
_buildImageUploadSection(controller),
|
||||||
? Image.file(_ktpImage!, height: 200)
|
SizedBox(height: 24.h),
|
||||||
: Container(
|
if (controller.hasData) _buildFormSection(controller),
|
||||||
height: 200,
|
_buildMessages(controller),
|
||||||
color: Colors.grey.shade300,
|
|
||||||
child: const Center(child: Text("Belum ada gambar KTP")),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
isLoading
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: CardButtonOne(
|
|
||||||
textButton: "Upload Foto KTP",
|
|
||||||
fontSized: 16,
|
|
||||||
colorText: whiteColor,
|
|
||||||
color: primaryColor,
|
|
||||||
borderRadius: 10,
|
|
||||||
horizontal: double.infinity,
|
|
||||||
vertical: 50,
|
|
||||||
onTap: _pickImage,
|
|
||||||
usingRow: false,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 30),
|
|
||||||
for (var key in controllers.keys)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: TextField(
|
|
||||||
decoration: InputDecoration(labelText: key),
|
|
||||||
controller: controllers[key],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(onPressed: ()=> router.go("/berandapengepul"), child: Text("ke home collector"))
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageUploadSection(KtpValidatorController controller) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(20.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: whiteColor,
|
||||||
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildUploadHeader(),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
if (controller.selectedImage != null)
|
||||||
|
_buildImagePreview(controller.selectedImage!),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
_buildImageSourceButtons(controller),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
_buildProcessButton(controller),
|
||||||
|
if (controller.isProcessing) _buildProcessingIndicator(controller),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUploadHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(12.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.camera_alt, color: primaryColor, size: 24.w),
|
||||||
|
),
|
||||||
|
SizedBox(width: 16.w),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Upload Foto KTP',
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: bold,
|
||||||
|
color: blackNavyColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
Text(
|
||||||
|
'Pastikan foto jelas dan tidak buram',
|
||||||
|
style: GoogleFonts.dmSans(fontSize: 12.sp, color: greyColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagePreview(File image) {
|
||||||
|
return Container(
|
||||||
|
height: 180.h,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
border: Border.all(color: greyColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
child: Image.file(image, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageSourceButtons(KtpValidatorController controller) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CardButtonOne(
|
||||||
|
textButton: 'Kamera',
|
||||||
|
fontSized: 14,
|
||||||
|
colorText: primaryColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 48.h,
|
||||||
|
color: whiteColor,
|
||||||
|
borderAll: Border.all(color: primaryColor, width: 1),
|
||||||
|
onTap: controller.pickImageFromCamera,
|
||||||
|
usingRow: true,
|
||||||
|
child: Icon(Icons.camera_alt, color: primaryColor, size: 18.w),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 16.w),
|
||||||
|
Expanded(
|
||||||
|
child: CardButtonOne(
|
||||||
|
textButton: 'Galeri',
|
||||||
|
fontSized: 14,
|
||||||
|
colorText: primaryColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 48.h,
|
||||||
|
color: whiteColor,
|
||||||
|
borderAll: Border.all(color: primaryColor, width: 1),
|
||||||
|
onTap: controller.pickImageFromGallery,
|
||||||
|
usingRow: true,
|
||||||
|
child: Icon(Icons.photo_library, color: primaryColor, size: 18.w),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProcessButton(KtpValidatorController controller) {
|
||||||
|
return CardButtonOne(
|
||||||
|
textButton: controller.isProcessing ? 'Memproses...' : 'SCAN KTP',
|
||||||
|
fontSized: 16,
|
||||||
|
colorText: whiteColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 52.h,
|
||||||
|
color: controller.canProcessImage ? primaryColor : greyColor,
|
||||||
|
loadingTrue: controller.isProcessing,
|
||||||
|
onTap: controller.canProcessImage ? controller.processImage : () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProcessingIndicator(KtpValidatorController controller) {
|
||||||
|
String statusText = '';
|
||||||
|
double progress = 0.0;
|
||||||
|
|
||||||
|
switch (controller.processingStatus) {
|
||||||
|
case ProcessingStatus.preprocessing:
|
||||||
|
statusText = 'Memproses gambar...';
|
||||||
|
progress = 0.25;
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.extracting:
|
||||||
|
statusText = 'Membaca teks...';
|
||||||
|
progress = 0.5;
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.validating:
|
||||||
|
statusText = 'Validasi NIK...';
|
||||||
|
progress = 0.75;
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.completed:
|
||||||
|
statusText = 'Selesai!';
|
||||||
|
progress = 1.0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusText = 'Memulai...';
|
||||||
|
progress = 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: EdgeInsets.only(top: 16.h),
|
||||||
|
padding: EdgeInsets.all(16.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
statusText,
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: medium,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
backgroundColor: greyColor.withValues(alpha: 0.3),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFormSection(KtpValidatorController controller) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(20.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: whiteColor,
|
||||||
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildFormHeader(),
|
||||||
|
SizedBox(height: 24.h),
|
||||||
|
..._buildFormFields(controller),
|
||||||
|
SizedBox(height: 24.h),
|
||||||
|
_buildSaveButton(controller),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildFormHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.all(12.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.edit_document, color: Colors.green, size: 24.w),
|
||||||
|
),
|
||||||
|
SizedBox(width: 16.w),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Data KTP Terdeteksi',
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: bold,
|
||||||
|
color: blackNavyColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
Text(
|
||||||
|
'Silakan periksa dan edit data jika diperlukan',
|
||||||
|
style: GoogleFonts.dmSans(fontSize: 12.sp, color: greyColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildFormFields(KtpValidatorController controller) {
|
||||||
|
return [
|
||||||
|
_buildSectionTitle('Data Utama'),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'NIK',
|
||||||
|
controllers: controller.controllers['nik'],
|
||||||
|
isRequired: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 16,
|
||||||
|
onTap: () {},
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.length != 16) {
|
||||||
|
return 'NIK harus 16 digit';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'Nama Lengkap',
|
||||||
|
controllers: controller.controllers['name'],
|
||||||
|
isRequired: true,
|
||||||
|
onTap: () {},
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Nama lengkap harus diisi';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
_buildSectionTitle('Informasi Personal'),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Tempat Lahir',
|
||||||
|
controllers: controller.controllers['placeOfBirth'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Tanggal Lahir',
|
||||||
|
controllers: controller.controllers['dateOfBirth'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'DD-MM-YYYY',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Jenis Kelamin',
|
||||||
|
controllers: controller.controllers['gender'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'LAKI-LAKI / PEREMPUAN',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Golongan Darah',
|
||||||
|
controllers: controller.controllers['bloodType'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'A, B, AB, O',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
_buildSectionTitle('Alamat'),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'RT/RW',
|
||||||
|
controllers: controller.controllers['neighbourhood'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'Contoh: 001/002',
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Desa/Kelurahan',
|
||||||
|
controllers: controller.controllers['village'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Kecamatan',
|
||||||
|
controllers: controller.controllers['subDistrict'],
|
||||||
|
isRequired: false,
|
||||||
|
enabled: false,
|
||||||
|
inputColor: greyColor.withValues(alpha: 0.1),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
_buildSectionTitle('Informasi Lainnya'),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'Agama',
|
||||||
|
controllers: controller.controllers['religion'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'Status Perkawinan',
|
||||||
|
controllers: controller.controllers['maritalStatus'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'BELUM KAWIN / KAWIN / CERAI',
|
||||||
|
),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'Pekerjaan',
|
||||||
|
controllers: controller.controllers['job'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Kewarganegaraan',
|
||||||
|
controllers: controller.controllers['citizenship'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'WNI / WNA',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Berlaku Hingga',
|
||||||
|
controllers: controller.controllers['validUntil'],
|
||||||
|
isRequired: false,
|
||||||
|
onTap: () {},
|
||||||
|
placeholder: 'SEUMUR HIDUP',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
_buildSectionTitle('Data dari NIK Validator'),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
|
||||||
|
FormFieldOne(
|
||||||
|
hintText: 'Provinsi',
|
||||||
|
controllers: controller.controllers['province'],
|
||||||
|
isRequired: false,
|
||||||
|
enabled: false,
|
||||||
|
inputColor: greyColor.withValues(alpha: 0.1),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Kabupaten/Kota',
|
||||||
|
controllers: controller.controllers['district'],
|
||||||
|
isRequired: false,
|
||||||
|
enabled: false,
|
||||||
|
inputColor: greyColor.withValues(alpha: 0.1),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: FormFieldOne(
|
||||||
|
hintText: 'Kode Pos',
|
||||||
|
controllers: controller.controllers['postalCode'],
|
||||||
|
isRequired: false,
|
||||||
|
enabled: false,
|
||||||
|
inputColor: greyColor.withValues(alpha: 0.1),
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionTitle(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: bold,
|
||||||
|
color: blackNavyColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSaveButton(KtpValidatorController controller) {
|
||||||
|
return CardButtonOne(
|
||||||
|
textButton: 'SIMPAN DATA KTP',
|
||||||
|
fontSized: 16,
|
||||||
|
colorText: whiteColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 52.h,
|
||||||
|
color: primaryColor,
|
||||||
|
onTap: () => _handleSaveData(controller),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessages(KtpValidatorController controller) {
|
||||||
|
if (controller.errorMessage.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
margin: EdgeInsets.only(top: 16.h),
|
||||||
|
padding: EdgeInsets.all(16.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: redColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
border: Border.all(color: redColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: redColor, size: 20.w),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.errorMessage,
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: redColor,
|
||||||
|
fontWeight: medium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: controller.clearError,
|
||||||
|
icon: Icon(Icons.close, color: redColor, size: 20.w),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.successMessage.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
margin: EdgeInsets.only(top: 16.h),
|
||||||
|
padding: EdgeInsets.all(16.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
border: Border.all(color: Colors.green.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline, color: Colors.green, size: 20.w),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.successMessage,
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: Colors.green,
|
||||||
|
fontWeight: medium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSaveData(KtpValidatorController controller) async {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
final success = await controller.saveData();
|
||||||
|
|
||||||
|
if (success && mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: whiteColor),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Data KTP berhasil disimpan!',
|
||||||
|
style: GoogleFonts.dmSans(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: medium,
|
||||||
|
color: whiteColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadKtpScreen extends StatelessWidget {
|
||||||
|
const UploadKtpScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ChangeNotifierProvider(
|
||||||
|
create: (context) => KtpValidatorController(),
|
||||||
|
child: const IdentityValidationScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
|
import 'package:rijig_mobile/core/router.dart';
|
||||||
import 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
|
import 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
|
||||||
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
|
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
|
||||||
|
|
||||||
|
@ -868,6 +869,7 @@ class _OrderSummaryScreenState extends State<OrderSummaryScreen>
|
||||||
: hasItems
|
: hasItems
|
||||||
? () {
|
? () {
|
||||||
_showSnackbar('Lanjut ke proses selanjutnya');
|
_showSnackbar('Lanjut ke proses selanjutnya');
|
||||||
|
router.push('/pickupmethod');
|
||||||
}
|
}
|
||||||
: () => Navigator.of(context).pop(),
|
: () => Navigator.of(context).pop(),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
class ChatItem {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String profileImage;
|
||||||
|
final String lastMessage;
|
||||||
|
final DateTime lastMessageTime;
|
||||||
|
final int unreadCount;
|
||||||
|
final bool isOnline;
|
||||||
|
|
||||||
|
ChatItem({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.profileImage,
|
||||||
|
required this.lastMessage,
|
||||||
|
required this.lastMessageTime,
|
||||||
|
this.unreadCount = 0,
|
||||||
|
this.isOnline = false,
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.dart';
|
||||||
|
|
||||||
|
class Message {
|
||||||
|
final String id;
|
||||||
|
final String content;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool isFromMe;
|
||||||
|
final MessageType type;
|
||||||
|
final MessageStatus status;
|
||||||
|
final String? mediaUrl;
|
||||||
|
|
||||||
|
Message({
|
||||||
|
required this.id,
|
||||||
|
required this.content,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.isFromMe,
|
||||||
|
this.type = MessageType.text,
|
||||||
|
this.status = MessageStatus.sent,
|
||||||
|
this.mediaUrl,
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,805 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rijig_mobile/core/router.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
import 'package:rijig_mobile/features/chat/presentation/model/chatlist_model.dart';
|
||||||
|
|
||||||
|
class ChatListScreen extends StatefulWidget {
|
||||||
|
const ChatListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatListScreenState extends State<ChatListScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
List<ChatItem> _allChats = [];
|
||||||
|
List<ChatItem> _filteredChats = [];
|
||||||
|
bool _isSearching = false;
|
||||||
|
|
||||||
|
// Selection mode states
|
||||||
|
bool _isSelectionMode = false;
|
||||||
|
Set<String> _selectedChatIds = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeDummyData();
|
||||||
|
_filteredChats = _allChats;
|
||||||
|
_searchController.addListener(_onSearchChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeDummyData() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
_allChats = [
|
||||||
|
ChatItem(
|
||||||
|
id: '1',
|
||||||
|
name: 'Sarah Johnson',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1494790108755-2616b612b793?w=150',
|
||||||
|
lastMessage: 'Halo! Bagaimana dengan projek yang kemarin?',
|
||||||
|
lastMessageTime: now.subtract(const Duration(minutes: 5)),
|
||||||
|
unreadCount: 3,
|
||||||
|
isOnline: true,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '2',
|
||||||
|
name: 'Ahmad Pratama',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150',
|
||||||
|
lastMessage: 'Terima kasih untuk bantuannya!',
|
||||||
|
lastMessageTime: now.subtract(const Duration(minutes: 15)),
|
||||||
|
unreadCount: 1,
|
||||||
|
isOnline: true,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '3',
|
||||||
|
name: 'Maria Garcia',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150',
|
||||||
|
lastMessage: 'Sudah sampai rumah dengan selamat',
|
||||||
|
lastMessageTime: now.subtract(const Duration(hours: 1)),
|
||||||
|
unreadCount: 0,
|
||||||
|
isOnline: false,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '4',
|
||||||
|
name: 'David Chen',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150',
|
||||||
|
lastMessage: 'Baik, nanti saya kirim file nya',
|
||||||
|
lastMessageTime: now.subtract(const Duration(hours: 2)),
|
||||||
|
unreadCount: 2,
|
||||||
|
isOnline: true,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '5',
|
||||||
|
name: 'Jessica Lee',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=150',
|
||||||
|
lastMessage: 'Kapan kita bisa ketemu lagi?',
|
||||||
|
lastMessageTime: now.subtract(const Duration(hours: 4)),
|
||||||
|
unreadCount: 0,
|
||||||
|
isOnline: true,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '6',
|
||||||
|
name: 'Michael Rodriguez',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150',
|
||||||
|
lastMessage: 'Jangan lupa meeting besok pagi',
|
||||||
|
lastMessageTime: now.subtract(const Duration(hours: 8)),
|
||||||
|
unreadCount: 4,
|
||||||
|
isOnline: false,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '7',
|
||||||
|
name: 'Lisa Wang',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150',
|
||||||
|
lastMessage: 'Foto-fotonya bagus banget!',
|
||||||
|
lastMessageTime: now.subtract(const Duration(days: 1)),
|
||||||
|
unreadCount: 0,
|
||||||
|
isOnline: false,
|
||||||
|
),
|
||||||
|
ChatItem(
|
||||||
|
id: '8',
|
||||||
|
name: 'Ryan Thompson',
|
||||||
|
profileImage:
|
||||||
|
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?w=150',
|
||||||
|
lastMessage: 'Oke siap, sampai jumpa!',
|
||||||
|
lastMessageTime: now.subtract(const Duration(days: 2)),
|
||||||
|
unreadCount: 1,
|
||||||
|
isOnline: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged() {
|
||||||
|
final query = _searchController.text.toLowerCase();
|
||||||
|
setState(() {
|
||||||
|
_isSearching = query.isNotEmpty;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_filteredChats = _allChats;
|
||||||
|
} else {
|
||||||
|
_filteredChats =
|
||||||
|
_allChats.where((chat) {
|
||||||
|
return chat.name.toLowerCase().contains(query) ||
|
||||||
|
chat.lastMessage.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChatTap(ChatItem chat) {
|
||||||
|
if (_isSelectionMode) {
|
||||||
|
// Toggle selection in selection mode
|
||||||
|
setState(() {
|
||||||
|
if (_selectedChatIds.contains(chat.id)) {
|
||||||
|
_selectedChatIds.remove(chat.id);
|
||||||
|
} else {
|
||||||
|
_selectedChatIds.add(chat.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit selection mode if no items selected
|
||||||
|
if (_selectedChatIds.isEmpty) {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal navigation
|
||||||
|
// Mark as read (remove unread count)
|
||||||
|
setState(() {
|
||||||
|
final index = _allChats.indexWhere((c) => c.id == chat.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_allChats[index] = ChatItem(
|
||||||
|
id: chat.id,
|
||||||
|
name: chat.name,
|
||||||
|
profileImage: chat.profileImage,
|
||||||
|
lastMessage: chat.lastMessage,
|
||||||
|
lastMessageTime: chat.lastMessageTime,
|
||||||
|
unreadCount: 0, // Mark as read
|
||||||
|
isOnline: chat.isOnline,
|
||||||
|
);
|
||||||
|
_filteredChats = _allChats;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to chat room with query parameters
|
||||||
|
final encodedName = Uri.encodeComponent(chat.name);
|
||||||
|
final encodedImage = Uri.encodeComponent(chat.profileImage);
|
||||||
|
final onlineStatus = chat.isOnline.toString();
|
||||||
|
|
||||||
|
router.push(
|
||||||
|
'/chatroom/${chat.id}?name=$encodedName&image=$encodedImage&online=$onlineStatus',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChatLongPress(ChatItem chat) {
|
||||||
|
if (!_isSelectionMode) {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = true;
|
||||||
|
_selectedChatIds.add(chat.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _exitSelectionMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSelectionMode = false;
|
||||||
|
_selectedChatIds.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAllChats() {
|
||||||
|
setState(() {
|
||||||
|
_selectedChatIds = _filteredChats.map((chat) => chat.id).toSet();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markSelectedAsRead() {
|
||||||
|
final selectedCount = _selectedChatIds.length;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
for (int i = 0; i < _allChats.length; i++) {
|
||||||
|
if (_selectedChatIds.contains(_allChats[i].id)) {
|
||||||
|
_allChats[i] = ChatItem(
|
||||||
|
id: _allChats[i].id,
|
||||||
|
name: _allChats[i].name,
|
||||||
|
profileImage: _allChats[i].profileImage,
|
||||||
|
lastMessage: _allChats[i].lastMessage,
|
||||||
|
lastMessageTime: _allChats[i].lastMessageTime,
|
||||||
|
unreadCount: 0, // Mark as read
|
||||||
|
isOnline: _allChats[i].isOnline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_filteredChats = _allChats;
|
||||||
|
_exitSelectionMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$selectedCount chat ditandai sudah dibaca'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteSelectedChats() {
|
||||||
|
final selectedCount = _selectedChatIds.length;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.warning, color: Colors.red, size: 24),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Hapus Chat'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'Apakah Anda yakin ingin menghapus $selectedCount chat? Tindakan ini tidak dapat dibatalkan.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: Text(
|
||||||
|
'Batal',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_allChats.removeWhere(
|
||||||
|
(chat) => _selectedChatIds.contains(chat.id),
|
||||||
|
);
|
||||||
|
_filteredChats = _allChats;
|
||||||
|
_exitSelectionMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
context.pop();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$selectedCount chat berhasil dihapus'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Hapus', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markAllAsRead() {
|
||||||
|
setState(() {
|
||||||
|
for (int i = 0; i < _allChats.length; i++) {
|
||||||
|
_allChats[i] = ChatItem(
|
||||||
|
id: _allChats[i].id,
|
||||||
|
name: _allChats[i].name,
|
||||||
|
profileImage: _allChats[i].profileImage,
|
||||||
|
lastMessage: _allChats[i].lastMessage,
|
||||||
|
lastMessageTime: _allChats[i].lastMessageTime,
|
||||||
|
unreadCount: 0, // Mark as read
|
||||||
|
isOnline: _allChats[i].isOnline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_filteredChats = _allChats;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Semua chat ditandai sudah dibaca'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteAllChats() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(context) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.warning, color: Colors.red, size: 24),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Hapus Semua Chat'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Apakah Anda yakin ingin menghapus semua chat? Tindakan ini tidak dapat dibatalkan.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: Text(
|
||||||
|
'Batal',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_allChats.clear();
|
||||||
|
_filteredChats.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
context.pop();
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Semua chat berhasil dihapus'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'Hapus Semua',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(time);
|
||||||
|
|
||||||
|
if (difference.inMinutes < 1) {
|
||||||
|
return 'Baru saja';
|
||||||
|
} else if (difference.inMinutes < 60) {
|
||||||
|
return '${difference.inMinutes}m';
|
||||||
|
} else if (difference.inHours < 24) {
|
||||||
|
return '${difference.inHours}j';
|
||||||
|
} else if (difference.inDays == 1) {
|
||||||
|
return 'Kemarin';
|
||||||
|
} else if (difference.inDays < 7) {
|
||||||
|
return '${difference.inDays} hari';
|
||||||
|
} else {
|
||||||
|
return '${time.day}/${time.month}/${time.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final totalUnread = _allChats.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, chat) => sum + chat.unreadCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
elevation: 0,
|
||||||
|
leading:
|
||||||
|
_isSelectionMode
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: _exitSelectionMode,
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
title:
|
||||||
|
_isSelectionMode
|
||||||
|
? Text(
|
||||||
|
'${_selectedChatIds.length} dipilih',
|
||||||
|
style: Tulisan.subheading(),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Text('Chat', style: Tulisan.subheading()),
|
||||||
|
if (totalUnread > 0)
|
||||||
|
Text(
|
||||||
|
'$totalUnread pesan belum dibaca',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
actions:
|
||||||
|
_isSelectionMode
|
||||||
|
? [
|
||||||
|
// Select All Button
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_selectedChatIds.length == _filteredChats.length
|
||||||
|
? Icons.deselect
|
||||||
|
: Icons.select_all,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (_selectedChatIds.length == _filteredChats.length) {
|
||||||
|
_exitSelectionMode();
|
||||||
|
} else {
|
||||||
|
_selectAllChats();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip:
|
||||||
|
_selectedChatIds.length == _filteredChats.length
|
||||||
|
? 'Batal pilih semua'
|
||||||
|
: 'Pilih semua',
|
||||||
|
),
|
||||||
|
// Mark as Read Button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.mark_email_read),
|
||||||
|
onPressed:
|
||||||
|
_selectedChatIds.isNotEmpty
|
||||||
|
? _markSelectedAsRead
|
||||||
|
: null,
|
||||||
|
tooltip: 'Tandai sudah dibaca',
|
||||||
|
),
|
||||||
|
// Delete Button
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed:
|
||||||
|
_selectedChatIds.isNotEmpty
|
||||||
|
? _deleteSelectedChats
|
||||||
|
: null,
|
||||||
|
tooltip: 'Hapus',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
onSelected: (value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'mark_all_read':
|
||||||
|
_markAllAsRead();
|
||||||
|
break;
|
||||||
|
case 'delete_all':
|
||||||
|
_deleteAllChats();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder:
|
||||||
|
(BuildContext context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'mark_all_read',
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(
|
||||||
|
Icons.mark_email_read,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Baca Semua'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete_all',
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(
|
||||||
|
Icons.delete_sweep,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Hapus Semua Chat',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Search Bar
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: whiteColor,
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Cari chat atau pesan...',
|
||||||
|
prefixIcon: Icon(Icons.search, color: Colors.grey.shade500),
|
||||||
|
suffixIcon:
|
||||||
|
_isSearching
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchController.clear();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Chat List
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
_filteredChats.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: _filteredChats.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildChatItem(_filteredChats[index]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
size: 80,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_isSearching ? 'Tidak ada chat ditemukan' : 'Belum ada chat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_isSearching ? 'Coba kata kunci lain' : 'Belum ada percakapan',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChatItem(ChatItem chat) {
|
||||||
|
final isSelected = _selectedChatIds.contains(chat.id);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? primaryColor.withValues(alpha: 0.1) : whiteColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: isSelected ? Border.all(color: primaryColor, width: 2) : null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _onChatTap(chat),
|
||||||
|
onLongPress: () => _onChatLongPress(chat),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Selection Checkbox (only show in selection mode)
|
||||||
|
if (_isSelectionMode) ...[
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? primaryColor : Colors.grey.shade400,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
color: isSelected ? primaryColor : Colors.transparent,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
isSelected
|
||||||
|
? const Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Profile Picture with Online Status
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: Colors.grey.shade200,
|
||||||
|
backgroundImage: NetworkImage(chat.profileImage),
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
child: Text(
|
||||||
|
chat.name.substring(0, 1).toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (chat.isOnline)
|
||||||
|
Positioned(
|
||||||
|
bottom: 2,
|
||||||
|
right: 2,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: whiteColor, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Chat Content
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Name and Time
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
chat.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight:
|
||||||
|
chat.unreadCount > 0
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatTime(chat.lastMessageTime),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
chat.unreadCount > 0
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color:
|
||||||
|
chat.unreadCount > 0
|
||||||
|
? primaryColor
|
||||||
|
: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
|
// Last Message and Unread Count
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
chat.lastMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight:
|
||||||
|
chat.unreadCount > 0
|
||||||
|
? FontWeight.w500
|
||||||
|
: FontWeight.normal,
|
||||||
|
color:
|
||||||
|
chat.unreadCount > 0
|
||||||
|
? Colors.black87
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (chat.unreadCount > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(minWidth: 20),
|
||||||
|
child: Text(
|
||||||
|
chat.unreadCount > 99
|
||||||
|
? '99+'
|
||||||
|
: '${chat.unreadCount}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,692 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
import 'package:rijig_mobile/features/chat/presentation/model/chatroom_model.dart';
|
||||||
|
import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
|
||||||
|
|
||||||
|
// Model untuk Message
|
||||||
|
/* class Message {
|
||||||
|
final String id;
|
||||||
|
final String content;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final bool isFromMe;
|
||||||
|
final MessageType type;
|
||||||
|
final MessageStatus status;
|
||||||
|
final String? mediaUrl;
|
||||||
|
|
||||||
|
Message({
|
||||||
|
required this.id,
|
||||||
|
required this.content,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.isFromMe,
|
||||||
|
this.type = MessageType.text,
|
||||||
|
this.status = MessageStatus.sent,
|
||||||
|
this.mediaUrl,
|
||||||
|
});
|
||||||
|
} */
|
||||||
|
|
||||||
|
enum MessageType { text, image, video }
|
||||||
|
|
||||||
|
enum MessageStatus { sending, sent, delivered, read }
|
||||||
|
|
||||||
|
class ChatRoomScreen extends StatefulWidget {
|
||||||
|
final String contactName;
|
||||||
|
final String contactImage;
|
||||||
|
final bool isOnline;
|
||||||
|
|
||||||
|
const ChatRoomScreen({
|
||||||
|
super.key,
|
||||||
|
required this.contactName,
|
||||||
|
required this.contactImage,
|
||||||
|
this.isOnline = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||||
|
final TextEditingController _messageController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final FocusNode _messageFocus = FocusNode();
|
||||||
|
|
||||||
|
List<Message> _messages = [];
|
||||||
|
bool _isTyping = false;
|
||||||
|
bool _isSending = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeDummyMessages();
|
||||||
|
_messageController.addListener(_onTypingChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_messageFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeDummyMessages() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
_messages = [
|
||||||
|
Message(
|
||||||
|
id: '1',
|
||||||
|
content: 'Halo! Bagaimana kabarnya?',
|
||||||
|
timestamp: now.subtract(const Duration(hours: 2)),
|
||||||
|
isFromMe: false,
|
||||||
|
status: MessageStatus.read,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '2',
|
||||||
|
content: 'Halo juga! Baik kok, sedang sibuk proyekan',
|
||||||
|
timestamp: now.subtract(const Duration(hours: 2, minutes: -5)),
|
||||||
|
isFromMe: true,
|
||||||
|
status: MessageStatus.read,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '3',
|
||||||
|
content: 'Wah asyik, projek apa emangnya?',
|
||||||
|
timestamp: now.subtract(const Duration(hours: 1, minutes: 50)),
|
||||||
|
isFromMe: false,
|
||||||
|
status: MessageStatus.read,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '4',
|
||||||
|
content: 'Projek mobile app untuk client, lumayan challenging sih',
|
||||||
|
timestamp: now.subtract(const Duration(hours: 1, minutes: 45)),
|
||||||
|
isFromMe: true,
|
||||||
|
status: MessageStatus.read,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '5',
|
||||||
|
content:
|
||||||
|
'https://images.unsplash.com/photo-1551650975-87deedd944c3?w=400',
|
||||||
|
timestamp: now.subtract(const Duration(hours: 1, minutes: 30)),
|
||||||
|
isFromMe: false,
|
||||||
|
type: MessageType.image,
|
||||||
|
status: MessageStatus.read,
|
||||||
|
mediaUrl:
|
||||||
|
'https://images.unsplash.com/photo-1551650975-87deedd944c3?w=400',
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '6',
|
||||||
|
content: 'Keren banget! UI nya bagus',
|
||||||
|
timestamp: now.subtract(const Duration(hours: 1, minutes: 25)),
|
||||||
|
isFromMe: true,
|
||||||
|
status: MessageStatus.read,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '7',
|
||||||
|
content: 'Terima kasih! Btw kapan kita bisa ketemu lagi?',
|
||||||
|
timestamp: now.subtract(const Duration(minutes: 30)),
|
||||||
|
isFromMe: false,
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '8',
|
||||||
|
content: 'Gimana weekend ini? Kita bisa lunch bareng',
|
||||||
|
timestamp: now.subtract(const Duration(minutes: 25)),
|
||||||
|
isFromMe: true,
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: '9',
|
||||||
|
content: 'Boleh banget! Jam berapa dan dimana?',
|
||||||
|
timestamp: now.subtract(const Duration(minutes: 5)),
|
||||||
|
isFromMe: false,
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTypingChanged() {
|
||||||
|
final isCurrentlyTyping = _messageController.text.isNotEmpty;
|
||||||
|
if (isCurrentlyTyping != _isTyping) {
|
||||||
|
setState(() {
|
||||||
|
_isTyping = isCurrentlyTyping;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage() {
|
||||||
|
final content = _messageController.text.trim();
|
||||||
|
if (content.isEmpty || _isSending) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSending = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final newMessage = Message(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
content: content,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
isFromMe: true,
|
||||||
|
status: MessageStatus.sending,
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_messages.add(newMessage);
|
||||||
|
_messageController.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
_scrollToBottom();
|
||||||
|
|
||||||
|
// Simulate sending
|
||||||
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
|
setState(() {
|
||||||
|
_isSending = false;
|
||||||
|
// Update message status to sent
|
||||||
|
final index = _messages.indexWhere((m) => m.id == newMessage.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_messages[index] = Message(
|
||||||
|
id: newMessage.id,
|
||||||
|
content: newMessage.content,
|
||||||
|
timestamp: newMessage.timestamp,
|
||||||
|
isFromMe: newMessage.isFromMe,
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate delivery after 2 seconds
|
||||||
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
setState(() {
|
||||||
|
final index = _messages.indexWhere((m) => m.id == newMessage.id);
|
||||||
|
if (index != -1) {
|
||||||
|
_messages[index] = Message(
|
||||||
|
id: newMessage.id,
|
||||||
|
content: newMessage.content,
|
||||||
|
timestamp: newMessage.timestamp,
|
||||||
|
isFromMe: newMessage.isFromMe,
|
||||||
|
status: MessageStatus.delivered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAttachmentOptions() {
|
||||||
|
CustomBottomSheet.show(
|
||||||
|
context: context,
|
||||||
|
title: 'Kirim Media',
|
||||||
|
content: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildAttachmentOption(
|
||||||
|
icon: Icons.camera_alt,
|
||||||
|
label: 'Kamera',
|
||||||
|
color: Colors.blue,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_takePhoto();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildAttachmentOption(
|
||||||
|
icon: Icons.photo_library,
|
||||||
|
label: 'Galeri',
|
||||||
|
color: Colors.green,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_pickFromGallery();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildAttachmentOption(
|
||||||
|
icon: Icons.videocam,
|
||||||
|
label: 'Video',
|
||||||
|
color: Colors.red,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_pickVideo();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
button1: Container(), // Empty since we have custom buttons
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAttachmentOption({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 30),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _takePhoto() {
|
||||||
|
debugPrint('Take photo from camera');
|
||||||
|
// TODO: Implement camera functionality
|
||||||
|
// Example: ImagePicker.pickImage(source: ImageSource.camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickFromGallery() {
|
||||||
|
debugPrint('Pick image from gallery');
|
||||||
|
// TODO: Implement gallery picker
|
||||||
|
// Example: ImagePicker.pickImage(source: ImageSource.gallery)
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickVideo() {
|
||||||
|
debugPrint('Pick video from gallery');
|
||||||
|
// TODO: Implement video picker
|
||||||
|
// Example: ImagePicker.pickVideo(source: ImageSource.gallery)
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatMessageTime(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final messageDate = DateTime(time.year, time.month, time.day);
|
||||||
|
|
||||||
|
if (messageDate == today) {
|
||||||
|
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
||||||
|
return 'Kemarin ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
} else {
|
||||||
|
return '${time.day}/${time.month} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageStatusIcon(MessageStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case MessageStatus.sending:
|
||||||
|
return const SizedBox(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.grey),
|
||||||
|
);
|
||||||
|
case MessageStatus.sent:
|
||||||
|
return Icon(Icons.check, size: 14, color: Colors.grey.shade500);
|
||||||
|
case MessageStatus.delivered:
|
||||||
|
return Icon(Icons.done_all, size: 14, color: Colors.grey.shade500);
|
||||||
|
case MessageStatus.read:
|
||||||
|
return Icon(Icons.done_all, size: 14, color: primaryColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.grey.shade200,
|
||||||
|
backgroundImage: NetworkImage(widget.contactImage),
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
child: Text(
|
||||||
|
widget.contactName.substring(0, 1).toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.isOnline)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: whiteColor, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.contactName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.isOnline ? 'Online' : 'Terakhir dilihat baru saja',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
onPressed: () {
|
||||||
|
debugPrint('Show chat options');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Messages List
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = _messages[index];
|
||||||
|
final showTimestamp =
|
||||||
|
index == 0 ||
|
||||||
|
_messages[index - 1].timestamp
|
||||||
|
.difference(message.timestamp)
|
||||||
|
.inMinutes
|
||||||
|
.abs() >
|
||||||
|
5;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (showTimestamp)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Text(
|
||||||
|
_formatMessageTime(message.timestamp),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildMessageBubble(message),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Message Input Area
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: whiteColor,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Attachment Button
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showAttachmentOptions,
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.attach_file,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Text Input
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
focusNode: _messageFocus,
|
||||||
|
maxLines: null,
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Ketik pesan...',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
// Send Button
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _isTyping && !_isSending ? _sendMessage : null,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isTyping ? primaryColor : Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isSending
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.send,
|
||||||
|
color:
|
||||||
|
_isTyping
|
||||||
|
? Colors.white
|
||||||
|
: Colors.grey.shade500,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(Message message) {
|
||||||
|
final isMe = message.isFromMe;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (!isMe) ...[
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundColor: Colors.grey.shade200,
|
||||||
|
backgroundImage: NetworkImage(widget.contactImage),
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
child: Text(
|
||||||
|
widget.contactName.substring(0, 1).toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMe ? primaryColor : whiteColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(18),
|
||||||
|
topRight: const Radius.circular(18),
|
||||||
|
bottomLeft: Radius.circular(isMe ? 18 : 4),
|
||||||
|
bottomRight: Radius.circular(isMe ? 4 : 18),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (message.type == MessageType.image &&
|
||||||
|
message.mediaUrl != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.network(
|
||||||
|
message.mediaUrl!,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 200,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder:
|
||||||
|
(_, __, ___) => Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 200,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.broken_image),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (message.content.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
message.content,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: isMe ? Colors.white : Colors.black87,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isMe) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatMessageTime(message.timestamp),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildMessageStatusIcon(message.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatMessageTime(message.timestamp),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isMe) const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,592 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
|
||||||
|
class NotificationScreen extends StatefulWidget {
|
||||||
|
const NotificationScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NotificationScreen> createState() => _NotificationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationScreenState extends State<NotificationScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
List<NotificationItem> unreadNotifications = [
|
||||||
|
NotificationItem(
|
||||||
|
id: '1',
|
||||||
|
title: 'Pesanan Dikonfirmasi',
|
||||||
|
message: 'Pesanan #12345 telah dikonfirmasi dan sedang diproses',
|
||||||
|
time: DateTime.now().subtract(const Duration(minutes: 15)),
|
||||||
|
type: NotificationType.order,
|
||||||
|
isRead: false,
|
||||||
|
),
|
||||||
|
NotificationItem(
|
||||||
|
id: '2',
|
||||||
|
title: 'Promo Spesial!',
|
||||||
|
message: 'Dapatkan diskon 50% untuk pembelian minimal Rp 100.000',
|
||||||
|
time: DateTime.now().subtract(const Duration(hours: 2)),
|
||||||
|
type: NotificationType.promotion,
|
||||||
|
isRead: false,
|
||||||
|
),
|
||||||
|
NotificationItem(
|
||||||
|
id: '3',
|
||||||
|
title: 'Pengiriman Dalam Perjalanan',
|
||||||
|
message: 'Pesanan #12344 sedang dalam perjalanan menuju alamat Anda',
|
||||||
|
time: DateTime.now().subtract(const Duration(hours: 4)),
|
||||||
|
type: NotificationType.delivery,
|
||||||
|
isRead: false,
|
||||||
|
),
|
||||||
|
NotificationItem(
|
||||||
|
id: '4',
|
||||||
|
title: 'Update Sistem',
|
||||||
|
message: 'Aplikasi telah diperbarui dengan fitur-fitur terbaru',
|
||||||
|
time: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
type: NotificationType.system,
|
||||||
|
isRead: false,
|
||||||
|
),
|
||||||
|
NotificationItem(
|
||||||
|
id: '5',
|
||||||
|
title: 'Pembayaran Berhasil',
|
||||||
|
message: 'Pembayaran untuk pesanan #12343 telah berhasil diproses',
|
||||||
|
time: DateTime.now().subtract(const Duration(days: 1, hours: 3)),
|
||||||
|
type: NotificationType.payment,
|
||||||
|
isRead: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<NotificationItem> readNotifications = [
|
||||||
|
NotificationItem(
|
||||||
|
id: '6',
|
||||||
|
title: 'Pesanan Selesai',
|
||||||
|
message:
|
||||||
|
'Pesanan #12342 telah selesai. Berikan rating untuk pelayanan kami!',
|
||||||
|
time: DateTime.now().subtract(const Duration(days: 2)),
|
||||||
|
type: NotificationType.order,
|
||||||
|
isRead: true,
|
||||||
|
),
|
||||||
|
NotificationItem(
|
||||||
|
id: '7',
|
||||||
|
title: 'Cashback Berhasil',
|
||||||
|
message: 'Cashback sebesar Rp 10.000 telah masuk ke saldo Anda',
|
||||||
|
time: DateTime.now().subtract(const Duration(days: 3)),
|
||||||
|
type: NotificationType.payment,
|
||||||
|
isRead: true,
|
||||||
|
),
|
||||||
|
NotificationItem(
|
||||||
|
id: '8',
|
||||||
|
title: 'Selamat Datang!',
|
||||||
|
message: 'Terima kasih telah bergabung dengan aplikasi kami',
|
||||||
|
time: DateTime.now().subtract(const Duration(days: 7)),
|
||||||
|
type: NotificationType.system,
|
||||||
|
isRead: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markAllAsRead() {
|
||||||
|
setState(() {
|
||||||
|
for (var notification in unreadNotifications) {
|
||||||
|
notification.isRead = true;
|
||||||
|
readNotifications.insert(0, notification);
|
||||||
|
}
|
||||||
|
unreadNotifications.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Semua notifikasi telah ditandai sebagai dibaca'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markAsRead(NotificationItem notification) {
|
||||||
|
setState(() {
|
||||||
|
notification.isRead = true;
|
||||||
|
unreadNotifications.remove(notification);
|
||||||
|
readNotifications.insert(0, notification);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteNotification(NotificationItem notification, bool isFromUnread) {
|
||||||
|
setState(() {
|
||||||
|
if (isFromUnread) {
|
||||||
|
unreadNotifications.remove(notification);
|
||||||
|
} else {
|
||||||
|
readNotifications.remove(notification);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Notifikasi telah dihapus'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Notifikasi', style: Tulisan.subheading()),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
if (unreadNotifications.isNotEmpty)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _markAllAsRead,
|
||||||
|
child: Text(
|
||||||
|
'Baca Semua',
|
||||||
|
style: TextStyle(
|
||||||
|
color: primaryColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(40),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||||
|
child: Container(
|
||||||
|
height: 40,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
dividerColor: Colors.transparent,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||||
|
),
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: Colors.black54,
|
||||||
|
tabs: [
|
||||||
|
Tab(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('Belum Dibaca'),
|
||||||
|
if (unreadNotifications.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${unreadNotifications.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('Dibaca'),
|
||||||
|
if (readNotifications.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${readNotifications.length}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [_buildUnreadTab(), _buildReadTab()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUnreadTab() {
|
||||||
|
if (unreadNotifications.isEmpty) {
|
||||||
|
return _buildEmptyState(
|
||||||
|
icon: Icons.notifications_off,
|
||||||
|
title: 'Tidak Ada Notifikasi Baru',
|
||||||
|
subtitle: 'Semua notifikasi sudah dibaca',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: unreadNotifications.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final notification = unreadNotifications[index];
|
||||||
|
return _buildNotificationCard(notification, true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReadTab() {
|
||||||
|
if (readNotifications.isEmpty) {
|
||||||
|
return _buildEmptyState(
|
||||||
|
icon: Icons.mark_email_read,
|
||||||
|
title: 'Belum Ada Notifikasi Dibaca',
|
||||||
|
subtitle: 'Notifikasi yang sudah dibaca akan muncul di sini',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: readNotifications.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final notification = readNotifications[index];
|
||||||
|
return _buildNotificationCard(notification, false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationCard(NotificationItem notification, bool isUnread) {
|
||||||
|
final notificationIcon = _getNotificationIcon(notification.type);
|
||||||
|
final notificationColor = _getNotificationColor(notification.type);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUnread ? Colors.white : Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border:
|
||||||
|
isUnread
|
||||||
|
? Border.all(
|
||||||
|
color: primaryColor.withValues(alpha: 0.2),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
|
: Border.all(color: Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Dismissible(
|
||||||
|
key: Key(notification.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.delete, color: Colors.white, size: 24),
|
||||||
|
),
|
||||||
|
onDismissed: (direction) {
|
||||||
|
_deleteNotification(notification, isUnread);
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: isUnread ? () => _markAsRead(notification) : null,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: notificationColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
notificationIcon,
|
||||||
|
color: notificationColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
notification.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight:
|
||||||
|
isUnread
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.w600,
|
||||||
|
color:
|
||||||
|
isUnread
|
||||||
|
? Colors.black
|
||||||
|
: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isUnread)
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
notification.message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color:
|
||||||
|
isUnread
|
||||||
|
? Colors.grey.shade700
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatTime(notification.time),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildNotificationTypeChip(notification.type),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNotificationTypeChip(NotificationType type) {
|
||||||
|
String label;
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.order:
|
||||||
|
label = 'Pesanan';
|
||||||
|
color = Colors.blue;
|
||||||
|
break;
|
||||||
|
case NotificationType.delivery:
|
||||||
|
label = 'Pengiriman';
|
||||||
|
color = Colors.green;
|
||||||
|
break;
|
||||||
|
case NotificationType.payment:
|
||||||
|
label = 'Pembayaran';
|
||||||
|
color = Colors.orange;
|
||||||
|
break;
|
||||||
|
case NotificationType.promotion:
|
||||||
|
label = 'Promo';
|
||||||
|
color = Colors.purple;
|
||||||
|
break;
|
||||||
|
case NotificationType.system:
|
||||||
|
label = 'Sistem';
|
||||||
|
color = Colors.grey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
}) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 40, color: Colors.grey.shade400),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getNotificationIcon(NotificationType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.order:
|
||||||
|
return Icons.shopping_bag;
|
||||||
|
case NotificationType.delivery:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case NotificationType.payment:
|
||||||
|
return Icons.payment;
|
||||||
|
case NotificationType.promotion:
|
||||||
|
return Icons.local_offer;
|
||||||
|
case NotificationType.system:
|
||||||
|
return Icons.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getNotificationColor(NotificationType type) {
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.order:
|
||||||
|
return Colors.blue;
|
||||||
|
case NotificationType.delivery:
|
||||||
|
return Colors.green;
|
||||||
|
case NotificationType.payment:
|
||||||
|
return Colors.orange;
|
||||||
|
case NotificationType.promotion:
|
||||||
|
return Colors.purple;
|
||||||
|
case NotificationType.system:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final difference = now.difference(time);
|
||||||
|
|
||||||
|
if (difference.inMinutes < 60) {
|
||||||
|
return '${difference.inMinutes} menit lalu';
|
||||||
|
} else if (difference.inHours < 24) {
|
||||||
|
return '${difference.inHours} jam lalu';
|
||||||
|
} else if (difference.inDays < 7) {
|
||||||
|
return '${difference.inDays} hari lalu';
|
||||||
|
} else {
|
||||||
|
return '${time.day}/${time.month}/${time.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final DateTime time;
|
||||||
|
final NotificationType type;
|
||||||
|
bool isRead;
|
||||||
|
|
||||||
|
NotificationItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
required this.time,
|
||||||
|
required this.type,
|
||||||
|
required this.isRead,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationType { order, delivery, payment, promotion, system }
|
|
@ -65,7 +65,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => router.push('/trashview'),
|
// onPressed: () => router.push('/trashview'),
|
||||||
|
onPressed: () => router.push('/notifikasi'),
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Iconsax.notification_copy,
|
Iconsax.notification_copy,
|
||||||
color: primaryColor,
|
color: primaryColor,
|
||||||
|
@ -74,6 +75,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
debugPrint('message icon tapped');
|
debugPrint('message icon tapped');
|
||||||
|
// router.push('/cmapview');
|
||||||
|
router.push('/chatlist');
|
||||||
},
|
},
|
||||||
icon: Icon(Iconsax.message_copy, color: primaryColor),
|
icon: Icon(Iconsax.message_copy, color: primaryColor),
|
||||||
),
|
),
|
||||||
|
|
|
@ -0,0 +1,739 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||||
|
import 'package:rijig_mobile/widget/formfiled.dart';
|
||||||
|
import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class AccountScreen extends StatefulWidget {
|
||||||
|
const AccountScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountScreen> createState() => _AccountScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountScreenState extends State<AccountScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _phoneController = TextEditingController();
|
||||||
|
|
||||||
|
String _selectedGender = '';
|
||||||
|
DateTime? _selectedDate;
|
||||||
|
|
||||||
|
// Expanded states for each card
|
||||||
|
bool _isNameExpanded = false;
|
||||||
|
bool _isPhoneExpanded = false;
|
||||||
|
bool _isGenderExpanded = false;
|
||||||
|
bool _isBirthDateExpanded = false;
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
bool _isNameLoading = false;
|
||||||
|
bool _isPhoneLoading = false;
|
||||||
|
bool _isGenderLoading = false;
|
||||||
|
bool _isBirthDateLoading = false;
|
||||||
|
|
||||||
|
// Change detection states
|
||||||
|
bool _hasNameChanged = false;
|
||||||
|
bool _hasPhoneChanged = false;
|
||||||
|
bool _hasGenderChanged = false;
|
||||||
|
bool _hasBirthDateChanged = false;
|
||||||
|
|
||||||
|
// Original values for comparison
|
||||||
|
String _originalName = 'John Doe';
|
||||||
|
String _originalPhone = '+62 812 3456 7890';
|
||||||
|
String _originalGender = 'Laki-laki';
|
||||||
|
DateTime? _originalBirthDate = DateTime(1990, 1, 15);
|
||||||
|
|
||||||
|
// Current display values
|
||||||
|
String _userName = 'John Doe';
|
||||||
|
String _userPhone = '+62 812 3456 7890';
|
||||||
|
String _userGender = 'Laki-laki';
|
||||||
|
String _userBirthDate = '15 Januari 1990';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeData();
|
||||||
|
_addChangeListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeData() {
|
||||||
|
_nameController.text = _originalName;
|
||||||
|
_phoneController.text = _originalPhone;
|
||||||
|
_selectedGender = _originalGender;
|
||||||
|
_selectedDate = _originalBirthDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addChangeListeners() {
|
||||||
|
_nameController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_hasNameChanged = _nameController.text != _originalName;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_phoneController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_hasPhoneChanged = _phoneController.text != _originalPhone;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_phoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectProfilePhoto() {
|
||||||
|
CustomBottomSheet.show(
|
||||||
|
context: context,
|
||||||
|
title: 'Pilih Foto Profil',
|
||||||
|
content: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildPhotoOption(
|
||||||
|
icon: Icons.camera_alt,
|
||||||
|
label: 'Kamera',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
debugPrint('Open Camera');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildPhotoOption(
|
||||||
|
icon: Icons.photo_library,
|
||||||
|
label: 'Galeri',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
debugPrint('Open Gallery');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
button1:
|
||||||
|
Container(), // Empty container since we have custom buttons above
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhotoOption({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: primaryColor, size: 30),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveField(String field) {
|
||||||
|
setState(() {
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
_isNameLoading = true;
|
||||||
|
break;
|
||||||
|
case 'gender':
|
||||||
|
_isGenderLoading = true;
|
||||||
|
break;
|
||||||
|
case 'birthDate':
|
||||||
|
_isBirthDateLoading = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
|
setState(() {
|
||||||
|
// Update the display values and reset loading/change states
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
_userName = _nameController.text;
|
||||||
|
_originalName = _nameController.text;
|
||||||
|
_hasNameChanged = false;
|
||||||
|
_isNameExpanded = false;
|
||||||
|
_isNameLoading = false;
|
||||||
|
break;
|
||||||
|
case 'gender':
|
||||||
|
_userGender = _selectedGender;
|
||||||
|
_originalGender = _selectedGender;
|
||||||
|
_hasGenderChanged = false;
|
||||||
|
_isGenderExpanded = false;
|
||||||
|
_isGenderLoading = false;
|
||||||
|
break;
|
||||||
|
case 'birthDate':
|
||||||
|
if (_selectedDate != null) {
|
||||||
|
_userBirthDate =
|
||||||
|
'${_selectedDate!.day} ${_getMonthName(_selectedDate!.month)} ${_selectedDate!.year}';
|
||||||
|
_originalBirthDate = _selectedDate;
|
||||||
|
}
|
||||||
|
_hasBirthDateChanged = false;
|
||||||
|
_isBirthDateExpanded = false;
|
||||||
|
_isBirthDateLoading = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Data berhasil disimpan'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendOTP() {
|
||||||
|
setState(() {
|
||||||
|
_isPhoneLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate OTP sending
|
||||||
|
Future.delayed(const Duration(seconds: 2), () {
|
||||||
|
setState(() {
|
||||||
|
_isPhoneLoading = false;
|
||||||
|
// Update phone after successful OTP verification
|
||||||
|
_userPhone = _phoneController.text;
|
||||||
|
_originalPhone = _phoneController.text;
|
||||||
|
_hasPhoneChanged = false;
|
||||||
|
_isPhoneExpanded = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Kode OTP telah dikirim ke nomor Anda'),
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('Send OTP to: ${_phoneController.text}');
|
||||||
|
// TODO: Navigate to OTP verification screen
|
||||||
|
// Navigator.pushNamed(context, '/otp-verification');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getMonthName(int month) {
|
||||||
|
const months = [
|
||||||
|
'',
|
||||||
|
'Januari',
|
||||||
|
'Februari',
|
||||||
|
'Maret',
|
||||||
|
'April',
|
||||||
|
'Mei',
|
||||||
|
'Juni',
|
||||||
|
'Juli',
|
||||||
|
'Agustus',
|
||||||
|
'September',
|
||||||
|
'Oktober',
|
||||||
|
'November',
|
||||||
|
'Desember',
|
||||||
|
];
|
||||||
|
return months[month];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDate() async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedDate ?? DateTime(1990),
|
||||||
|
firstDate: DateTime(1950),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: ColorScheme.light(
|
||||||
|
primary: primaryColor,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
surface: Colors.white,
|
||||||
|
onSurface: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null && picked != _selectedDate) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDate = picked;
|
||||||
|
_hasBirthDateChanged = _selectedDate != _originalBirthDate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Akun Saya', style: Tulisan.subheading()),
|
||||||
|
centerTitle: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Profile Photo Section
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 50,
|
||||||
|
backgroundColor: Colors.grey.shade200,
|
||||||
|
backgroundImage: const NetworkImage(
|
||||||
|
'https://via.placeholder.com/150',
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person,
|
||||||
|
size: 50,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _selectProfilePhoto,
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.edit,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_userName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_userPhone,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Account Information Cards
|
||||||
|
_buildExpandableCard(
|
||||||
|
title: 'Nama Lengkap',
|
||||||
|
value: _userName,
|
||||||
|
icon: Icons.person,
|
||||||
|
isExpanded: _isNameExpanded,
|
||||||
|
hasChanges: _hasNameChanged,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isNameExpanded = !_isNameExpanded;
|
||||||
|
_isPhoneExpanded = false;
|
||||||
|
_isGenderExpanded = false;
|
||||||
|
_isBirthDateExpanded = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: _buildNameForm(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
_buildExpandableCard(
|
||||||
|
title: 'Nomor Telepon',
|
||||||
|
value: _userPhone,
|
||||||
|
icon: Icons.phone,
|
||||||
|
isExpanded: _isPhoneExpanded,
|
||||||
|
hasChanges: _hasPhoneChanged,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isPhoneExpanded = !_isPhoneExpanded;
|
||||||
|
_isNameExpanded = false;
|
||||||
|
_isGenderExpanded = false;
|
||||||
|
_isBirthDateExpanded = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: _buildPhoneForm(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
_buildExpandableCard(
|
||||||
|
title: 'Jenis Kelamin',
|
||||||
|
value: _userGender,
|
||||||
|
icon: Icons.wc,
|
||||||
|
isExpanded: _isGenderExpanded,
|
||||||
|
hasChanges: _hasGenderChanged,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isGenderExpanded = !_isGenderExpanded;
|
||||||
|
_isNameExpanded = false;
|
||||||
|
_isPhoneExpanded = false;
|
||||||
|
_isBirthDateExpanded = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: _buildGenderForm(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
_buildExpandableCard(
|
||||||
|
title: 'Tanggal Lahir',
|
||||||
|
value: _userBirthDate,
|
||||||
|
icon: Icons.cake,
|
||||||
|
isExpanded: _isBirthDateExpanded,
|
||||||
|
hasChanges: _hasBirthDateChanged,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isBirthDateExpanded = !_isBirthDateExpanded;
|
||||||
|
_isNameExpanded = false;
|
||||||
|
_isPhoneExpanded = false;
|
||||||
|
_isGenderExpanded = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: _buildBirthDateForm(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExpandableCard({
|
||||||
|
required String title,
|
||||||
|
required String value,
|
||||||
|
required IconData icon,
|
||||||
|
required bool isExpanded,
|
||||||
|
required bool hasChanges,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: primaryColor, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasChanges) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Berubah',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: isExpanded ? 0.5 : 0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: Icon(
|
||||||
|
Icons.keyboard_arrow_down,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedCrossFade(
|
||||||
|
firstChild: const SizedBox.shrink(),
|
||||||
|
secondChild: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
crossFadeState:
|
||||||
|
isExpanded
|
||||||
|
? CrossFadeState.showSecond
|
||||||
|
: CrossFadeState.showFirst,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNameForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FormFieldOne(
|
||||||
|
controllers: _nameController,
|
||||||
|
hintText: 'Nama Lengkap',
|
||||||
|
placeholder: 'Masukkan nama lengkap',
|
||||||
|
isRequired: true,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CardButtonOne(
|
||||||
|
textButton: 'Simpan',
|
||||||
|
fontSized: 16,
|
||||||
|
colorText: Colors.white,
|
||||||
|
borderRadius: 8,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 45,
|
||||||
|
color: _hasNameChanged ? primaryColor : Colors.grey.shade400,
|
||||||
|
loadingTrue: _isNameLoading,
|
||||||
|
onTap: _hasNameChanged ? () => _saveField('name') : () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhoneForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FormFieldOne(
|
||||||
|
controllers: _phoneController,
|
||||||
|
hintText: 'Nomor Telepon',
|
||||||
|
placeholder: 'Masukkan nomor telepon',
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
isRequired: true,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CardButtonOne(
|
||||||
|
textButton: 'Kirim OTP',
|
||||||
|
fontSized: 16,
|
||||||
|
colorText: Colors.white,
|
||||||
|
borderRadius: 8,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 45,
|
||||||
|
color: _hasPhoneChanged ? Colors.orange : Colors.grey.shade400,
|
||||||
|
loadingTrue: _isPhoneLoading,
|
||||||
|
onTap: _hasPhoneChanged ? _sendOTP : () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGenderForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<String>(
|
||||||
|
title: const Text('Laki-laki'),
|
||||||
|
value: 'Laki-laki',
|
||||||
|
groupValue: _selectedGender,
|
||||||
|
activeColor: primaryColor,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedGender = value!;
|
||||||
|
_hasGenderChanged = _selectedGender != _originalGender;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile<String>(
|
||||||
|
title: const Text('Perempuan'),
|
||||||
|
value: 'Perempuan',
|
||||||
|
groupValue: _selectedGender,
|
||||||
|
activeColor: primaryColor,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedGender = value!;
|
||||||
|
_hasGenderChanged = _selectedGender != _originalGender;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CardButtonOne(
|
||||||
|
textButton: 'Simpan',
|
||||||
|
fontSized: 16,
|
||||||
|
colorText: Colors.white,
|
||||||
|
borderRadius: 8,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 45,
|
||||||
|
color: _hasGenderChanged ? primaryColor : Colors.grey.shade400,
|
||||||
|
loadingTrue: _isGenderLoading,
|
||||||
|
onTap: _hasGenderChanged ? () => _saveField('gender') : () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBirthDateForm() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _selectDate,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
_selectedDate != null
|
||||||
|
? '${_selectedDate!.day} ${_getMonthName(_selectedDate!.month)} ${_selectedDate!.year}'
|
||||||
|
: 'Pilih tanggal lahir',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color:
|
||||||
|
_selectedDate != null
|
||||||
|
? Colors.black
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CardButtonOne(
|
||||||
|
textButton: 'Simpan',
|
||||||
|
fontSized: 16,
|
||||||
|
colorText: Colors.white,
|
||||||
|
borderRadius: 8,
|
||||||
|
horizontal: double.infinity,
|
||||||
|
vertical: 45,
|
||||||
|
color: _hasBirthDateChanged ? primaryColor : Colors.grey.shade400,
|
||||||
|
loadingTrue: _isBirthDateLoading,
|
||||||
|
onTap: _hasBirthDateChanged ? () => _saveField('birthDate') : () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
class AddressItem {
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String recipientName;
|
||||||
|
final String phoneNumber;
|
||||||
|
final String fullAddress;
|
||||||
|
final bool isDefault;
|
||||||
|
|
||||||
|
AddressItem({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.recipientName,
|
||||||
|
required this.phoneNumber,
|
||||||
|
required this.fullAddress,
|
||||||
|
required this.isDefault,
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,676 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:rijig_mobile/core/router.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
|
||||||
|
class AddAddressScreen extends StatefulWidget {
|
||||||
|
const AddAddressScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddAddressScreen> createState() => _AddAddressScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddAddressScreenState extends State<AddAddressScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
final _labelController = TextEditingController();
|
||||||
|
final _recipientNameController = TextEditingController();
|
||||||
|
final _phoneController = TextEditingController();
|
||||||
|
final _addressController = TextEditingController();
|
||||||
|
final _detailController = TextEditingController();
|
||||||
|
final _noteController = TextEditingController();
|
||||||
|
|
||||||
|
final _labelFocus = FocusNode();
|
||||||
|
final _recipientNameFocus = FocusNode();
|
||||||
|
final _phoneFocus = FocusNode();
|
||||||
|
final _addressFocus = FocusNode();
|
||||||
|
final _detailFocus = FocusNode();
|
||||||
|
final _noteFocus = FocusNode();
|
||||||
|
|
||||||
|
bool _isDefault = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _selectedProvince;
|
||||||
|
String? _selectedCity;
|
||||||
|
String? _selectedDistrict;
|
||||||
|
|
||||||
|
final List<String> _provinces = [
|
||||||
|
'DKI Jakarta',
|
||||||
|
'Jawa Barat',
|
||||||
|
'Jawa Tengah',
|
||||||
|
'Jawa Timur',
|
||||||
|
'Banten',
|
||||||
|
];
|
||||||
|
|
||||||
|
final Map<String, List<String>> _cities = {
|
||||||
|
'DKI Jakarta': [
|
||||||
|
'Jakarta Pusat',
|
||||||
|
'Jakarta Utara',
|
||||||
|
'Jakarta Selatan',
|
||||||
|
'Jakarta Timur',
|
||||||
|
'Jakarta Barat',
|
||||||
|
],
|
||||||
|
'Jawa Barat': ['Bandung', 'Bekasi', 'Bogor', 'Depok', 'Cimahi'],
|
||||||
|
'Jawa Tengah': ['Semarang', 'Solo', 'Yogyakarta', 'Magelang', 'Salatiga'],
|
||||||
|
'Jawa Timur': ['Surabaya', 'Malang', 'Kediri', 'Blitar', 'Madiun'],
|
||||||
|
'Banten': ['Tangerang', 'Tangerang Selatan', 'Serang', 'Cilegon', 'Lebak'],
|
||||||
|
};
|
||||||
|
|
||||||
|
final Map<String, List<String>> _districts = {
|
||||||
|
'Jakarta Pusat': [
|
||||||
|
'Menteng',
|
||||||
|
'Gambir',
|
||||||
|
'Tanah Abang',
|
||||||
|
'Senen',
|
||||||
|
'Cempaka Putih',
|
||||||
|
],
|
||||||
|
'Jakarta Selatan': [
|
||||||
|
'Kebayoran Baru',
|
||||||
|
'Kemang',
|
||||||
|
'Kuningan',
|
||||||
|
'Senayan',
|
||||||
|
'Pondok Indah',
|
||||||
|
],
|
||||||
|
'Bandung': ['Coblong', 'Sukasari', 'Cidadap', 'Dago', 'Antapani'],
|
||||||
|
'Surabaya': ['Gubeng', 'Wonokromo', 'Tegalsari', 'Genteng', 'Bubutan'],
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_labelController.dispose();
|
||||||
|
_recipientNameController.dispose();
|
||||||
|
_phoneController.dispose();
|
||||||
|
_addressController.dispose();
|
||||||
|
_detailController.dispose();
|
||||||
|
_noteController.dispose();
|
||||||
|
_labelFocus.dispose();
|
||||||
|
_recipientNameFocus.dispose();
|
||||||
|
_phoneFocus.dispose();
|
||||||
|
_addressFocus.dispose();
|
||||||
|
_detailFocus.dispose();
|
||||||
|
_noteFocus.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onProvinceChanged(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedProvince = value;
|
||||||
|
_selectedCity = null;
|
||||||
|
_selectedDistrict = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCityChanged(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCity = value;
|
||||||
|
_selectedDistrict = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDistrictChanged(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDistrict = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAddress() async {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Alamat berhasil disimpan'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
router.pop(_buildFullAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildFullAddress() {
|
||||||
|
final parts = [
|
||||||
|
_addressController.text,
|
||||||
|
_detailController.text,
|
||||||
|
_selectedDistrict,
|
||||||
|
_selectedCity,
|
||||||
|
_selectedProvince,
|
||||||
|
];
|
||||||
|
return parts.where((part) => part != null && part.isNotEmpty).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _useCurrentLocation() {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Menggunakan lokasi saat ini...'),
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Tambah Alamat', style: Tulisan.subheading()),
|
||||||
|
centerTitle: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
|
||||||
|
onPressed: () => router.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
color: primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Lokasi Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _useCurrentLocation,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.my_location,
|
||||||
|
size: 18,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Gunakan Lokasi Saat Ini',
|
||||||
|
style: TextStyle(color: primaryColor),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: primaryColor),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Informasi Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _labelController,
|
||||||
|
focusNode: _labelFocus,
|
||||||
|
label: 'Label Alamat',
|
||||||
|
hint: 'Contoh: Rumah, Kantor, Kost',
|
||||||
|
icon: Icons.label,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Label alamat tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(
|
||||||
|
context,
|
||||||
|
).requestFocus(_recipientNameFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _recipientNameController,
|
||||||
|
focusNode: _recipientNameFocus,
|
||||||
|
label: 'Nama Penerima',
|
||||||
|
hint: 'Masukkan nama lengkap penerima',
|
||||||
|
icon: Icons.person,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Nama penerima tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(context).requestFocus(_phoneFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
focusNode: _phoneFocus,
|
||||||
|
label: 'Nomor Telepon',
|
||||||
|
hint: 'Contoh: 08123456789',
|
||||||
|
icon: Icons.phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Nomor telepon tidak boleh kosong';
|
||||||
|
}
|
||||||
|
if (value.length < 10) {
|
||||||
|
return 'Nomor telepon minimal 10 digit';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(
|
||||||
|
context,
|
||||||
|
).requestFocus(_addressFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildDropdown(
|
||||||
|
label: 'Provinsi',
|
||||||
|
value: _selectedProvince,
|
||||||
|
items: _provinces,
|
||||||
|
onChanged: _onProvinceChanged,
|
||||||
|
hint: 'Pilih Provinsi',
|
||||||
|
icon: Icons.location_city,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildDropdown(
|
||||||
|
label: 'Kota/Kabupaten',
|
||||||
|
value: _selectedCity,
|
||||||
|
items:
|
||||||
|
_selectedProvince != null
|
||||||
|
? _cities[_selectedProvince!] ?? []
|
||||||
|
: [],
|
||||||
|
onChanged: _onCityChanged,
|
||||||
|
hint: 'Pilih Kota/Kabupaten',
|
||||||
|
icon: Icons.business,
|
||||||
|
enabled: _selectedProvince != null,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildDropdown(
|
||||||
|
label: 'Kecamatan',
|
||||||
|
value: _selectedDistrict,
|
||||||
|
items:
|
||||||
|
_selectedCity != null
|
||||||
|
? _districts[_selectedCity!] ?? []
|
||||||
|
: [],
|
||||||
|
onChanged: _onDistrictChanged,
|
||||||
|
hint: 'Pilih Kecamatan',
|
||||||
|
icon: Icons.map,
|
||||||
|
enabled: _selectedCity != null,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _addressController,
|
||||||
|
focusNode: _addressFocus,
|
||||||
|
label: 'Alamat Lengkap',
|
||||||
|
hint: 'Jalan, RT/RW, Kelurahan',
|
||||||
|
icon: Icons.home,
|
||||||
|
maxLines: 3,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Alamat lengkap tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(context).requestFocus(_detailFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _detailController,
|
||||||
|
focusNode: _detailFocus,
|
||||||
|
label: 'Detail Alamat (Opsional)',
|
||||||
|
hint: 'Patokan, warna rumah, nomor rumah, dll',
|
||||||
|
icon: Icons.info,
|
||||||
|
maxLines: 2,
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(context).requestFocus(_noteFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _noteController,
|
||||||
|
focusNode: _noteFocus,
|
||||||
|
label: 'Catatan untuk Kurir (Opsional)',
|
||||||
|
hint: 'Instruksi khusus untuk kurir',
|
||||||
|
icon: Icons.note,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _isDefault,
|
||||||
|
onChanged: (bool? value) {
|
||||||
|
setState(() {
|
||||||
|
_isDefault = value ?? false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: primaryColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Jadikan sebagai alamat utama',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _saveAddress,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Menyimpan...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Simpan Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required FocusNode focusNode,
|
||||||
|
required String label,
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
int maxLines = 1,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
|
void Function(String)? onFieldSubmitted,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
maxLines: maxLines,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
validator: validator,
|
||||||
|
onFieldSubmitted: onFieldSubmitted,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
prefixIcon: Icon(icon, color: Colors.grey.shade500),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: primaryColor, width: 2),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Colors.red),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdown({
|
||||||
|
required String label,
|
||||||
|
required String? value,
|
||||||
|
required List<String> items,
|
||||||
|
required void Function(String?) onChanged,
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
bool enabled = true,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: value,
|
||||||
|
items:
|
||||||
|
items.map((String item) {
|
||||||
|
return DropdownMenuItem<String>(value: item, child: Text(item));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: enabled ? onChanged : null,
|
||||||
|
validator: (value) {
|
||||||
|
if (enabled && (value == null || value.isEmpty)) {
|
||||||
|
return '$label tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
icon,
|
||||||
|
color: enabled ? Colors.grey.shade500 : Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: primaryColor, width: 2),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Colors.red),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: enabled ? Colors.grey.shade50 : Colors.grey.shade100,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,562 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
|
import 'package:rijig_mobile/core/router.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
import 'package:rijig_mobile/features/profil/address/model/address_model.dart';
|
||||||
|
|
||||||
|
class AddressScreen extends StatefulWidget {
|
||||||
|
const AddressScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddressScreen> createState() => _AddressScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressScreenState extends State<AddressScreen> {
|
||||||
|
int? selectedAddressIndex;
|
||||||
|
|
||||||
|
final List<AddressItem> addresses = [
|
||||||
|
AddressItem(
|
||||||
|
id: '1',
|
||||||
|
label: 'Rumah',
|
||||||
|
recipientName: 'John Doe',
|
||||||
|
phoneNumber: '+62 812-3456-7890',
|
||||||
|
fullAddress:
|
||||||
|
'Jl. Merdeka No. 123, RT 05/RW 02, Kelurahan Suka Maju, Kecamatan Menteng, Jakarta Pusat, DKI Jakarta 10310',
|
||||||
|
isDefault: true,
|
||||||
|
),
|
||||||
|
AddressItem(
|
||||||
|
id: '2',
|
||||||
|
label: 'Kantor',
|
||||||
|
recipientName: 'John Doe',
|
||||||
|
phoneNumber: '+62 812-3456-7890',
|
||||||
|
fullAddress:
|
||||||
|
'Gedung Cyber 2 Tower, Lantai 15, Jl. HR Rasuna Said, Kuningan, Jakarta Selatan, DKI Jakarta 12950',
|
||||||
|
isDefault: false,
|
||||||
|
),
|
||||||
|
AddressItem(
|
||||||
|
id: '3',
|
||||||
|
label: 'Rumah Orang Tua',
|
||||||
|
recipientName: 'Jane Doe',
|
||||||
|
phoneNumber: '+62 821-9876-5432',
|
||||||
|
fullAddress:
|
||||||
|
'Komplek Permata Hijau Blok A No. 45, Jl. Kemang Raya, Kemang, Jakarta Selatan, DKI Jakarta 12560',
|
||||||
|
isDefault: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void _selectAddress(int index) {
|
||||||
|
setState(() {
|
||||||
|
if (selectedAddressIndex == index) {
|
||||||
|
selectedAddressIndex = null;
|
||||||
|
} else {
|
||||||
|
selectedAddressIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmAddressSelection() {
|
||||||
|
if (selectedAddressIndex != null) {
|
||||||
|
final selectedAddress = addresses[selectedAddressIndex!];
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: Colors.green, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Alamat Dipilih'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Alamat pengiriman berhasil dipilih:',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.green.shade200),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
selectedAddress.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
selectedAddress.recipientName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
selectedAddress.fullAddress,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => router.pop(),
|
||||||
|
|
||||||
|
child: Text('OK', style: TextStyle(color: primaryColor)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addNewAddress() {
|
||||||
|
router.push('/addaddress');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editAddress(AddressItem address) {
|
||||||
|
router.push('/editaddress', extra: address);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Edit alamat: ${address.label}'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteAddress(AddressItem address, int index) {
|
||||||
|
setState(() {
|
||||||
|
addresses.removeAt(index);
|
||||||
|
if (selectedAddressIndex == index) {
|
||||||
|
selectedAddressIndex = null;
|
||||||
|
} else if (selectedAddressIndex != null &&
|
||||||
|
selectedAddressIndex! > index) {
|
||||||
|
selectedAddressIndex = selectedAddressIndex! - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Alamat "${address.label}" telah dihapus'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Pilih Alamat', style: Tulisan.subheading()),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _addNewAddress,
|
||||||
|
child: Text(
|
||||||
|
'Tambah Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
color: primaryColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.location_on, color: primaryColor, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Alamat Pengiriman',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Pilih alamat untuk pengiriman pesanan Anda',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
addresses.isEmpty
|
||||||
|
? _buildEmptyState()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: addresses.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final address = addresses[index];
|
||||||
|
final isSelected = selectedAddressIndex == index;
|
||||||
|
|
||||||
|
return Slidable(
|
||||||
|
key: Key(address.id),
|
||||||
|
endActionPane: ActionPane(
|
||||||
|
motion: const ScrollMotion(),
|
||||||
|
children: [
|
||||||
|
SlidableAction(
|
||||||
|
onPressed:
|
||||||
|
(context) => _deleteAddress(address, index),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'Hapus',
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: _buildAddressCard(address, index, isSelected),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (addresses.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
selectedAddressIndex != null
|
||||||
|
? _confirmAddressSelection
|
||||||
|
: null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
selectedAddressIndex != null
|
||||||
|
? primaryColor
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
selectedAddressIndex != null
|
||||||
|
? 'Pilih Alamat'
|
||||||
|
: 'Pilih salah satu alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color:
|
||||||
|
selectedAddressIndex != null
|
||||||
|
? Colors.white
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddressCard(AddressItem address, int index, bool isSelected) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border:
|
||||||
|
isSelected
|
||||||
|
? Border.all(color: primaryColor, width: 2)
|
||||||
|
: Border.all(color: Colors.grey.shade200),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _selectAddress(index),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getLabelColor(
|
||||||
|
address.label,
|
||||||
|
).withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
address.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _getLabelColor(address.label),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (address.isDefault) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Utama',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
if (isSelected)
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person, size: 16, color: Colors.grey.shade600),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
address.recipientName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(Icons.phone, size: 16, color: Colors.grey.shade600),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
address.phoneNumber,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.location_on,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
address.fullAddress,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
if (isSelected) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _editAddress(address),
|
||||||
|
icon: Icon(Icons.edit, size: 16, color: primaryColor),
|
||||||
|
label: Text(
|
||||||
|
'Edit Alamat',
|
||||||
|
style: TextStyle(color: primaryColor),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: primaryColor),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.location_off,
|
||||||
|
size: 40,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Belum Ada Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Tambahkan alamat untuk memudahkan pengiriman',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _addNewAddress,
|
||||||
|
icon: const Icon(Icons.add, color: Colors.white),
|
||||||
|
label: const Text(
|
||||||
|
'Tambah Alamat',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getLabelColor(String label) {
|
||||||
|
switch (label.toLowerCase()) {
|
||||||
|
case 'rumah':
|
||||||
|
return Colors.blue;
|
||||||
|
case 'kantor':
|
||||||
|
return Colors.orange;
|
||||||
|
case 'rumah orang tua':
|
||||||
|
return Colors.purple;
|
||||||
|
default:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,889 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:rijig_mobile/core/router.dart';
|
||||||
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:rijig_mobile/features/profil/address/model/address_model.dart';
|
||||||
|
|
||||||
|
class EditAddressScreen extends StatefulWidget {
|
||||||
|
final AddressItem address;
|
||||||
|
|
||||||
|
const EditAddressScreen({super.key, required this.address});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EditAddressScreen> createState() => _EditAddressScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditAddressScreenState extends State<EditAddressScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
|
final _labelController = TextEditingController();
|
||||||
|
final _recipientNameController = TextEditingController();
|
||||||
|
final _phoneController = TextEditingController();
|
||||||
|
final _addressController = TextEditingController();
|
||||||
|
final _detailController = TextEditingController();
|
||||||
|
final _noteController = TextEditingController();
|
||||||
|
|
||||||
|
final _labelFocus = FocusNode();
|
||||||
|
final _recipientNameFocus = FocusNode();
|
||||||
|
final _phoneFocus = FocusNode();
|
||||||
|
final _addressFocus = FocusNode();
|
||||||
|
final _detailFocus = FocusNode();
|
||||||
|
final _noteFocus = FocusNode();
|
||||||
|
|
||||||
|
bool _isDefault = false;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _hasChanges = false;
|
||||||
|
String? _selectedProvince;
|
||||||
|
String? _selectedCity;
|
||||||
|
String? _selectedDistrict;
|
||||||
|
|
||||||
|
final List<String> _provinces = [
|
||||||
|
'DKI Jakarta',
|
||||||
|
'Jawa Barat',
|
||||||
|
'Jawa Tengah',
|
||||||
|
'Jawa Timur',
|
||||||
|
'Banten',
|
||||||
|
];
|
||||||
|
|
||||||
|
final Map<String, List<String>> _cities = {
|
||||||
|
'DKI Jakarta': [
|
||||||
|
'Jakarta Pusat',
|
||||||
|
'Jakarta Utara',
|
||||||
|
'Jakarta Selatan',
|
||||||
|
'Jakarta Timur',
|
||||||
|
'Jakarta Barat',
|
||||||
|
],
|
||||||
|
'Jawa Barat': ['Bandung', 'Bekasi', 'Bogor', 'Depok', 'Cimahi'],
|
||||||
|
'Jawa Tengah': ['Semarang', 'Solo', 'Yogyakarta', 'Magelang', 'Salatiga'],
|
||||||
|
'Jawa Timur': ['Surabaya', 'Malang', 'Kediri', 'Blitar', 'Madiun'],
|
||||||
|
'Banten': ['Tangerang', 'Tangerang Selatan', 'Serang', 'Cilegon', 'Lebak'],
|
||||||
|
};
|
||||||
|
|
||||||
|
final Map<String, List<String>> _districts = {
|
||||||
|
'Jakarta Pusat': [
|
||||||
|
'Menteng',
|
||||||
|
'Gambir',
|
||||||
|
'Tanah Abang',
|
||||||
|
'Senen',
|
||||||
|
'Cempaka Putih',
|
||||||
|
],
|
||||||
|
'Jakarta Selatan': [
|
||||||
|
'Kebayoran Baru',
|
||||||
|
'Kemang',
|
||||||
|
'Kuningan',
|
||||||
|
'Senayan',
|
||||||
|
'Pondok Indah',
|
||||||
|
],
|
||||||
|
'Bandung': ['Coblong', 'Sukasari', 'Cidadap', 'Dago', 'Antapani'],
|
||||||
|
'Surabaya': ['Gubeng', 'Wonokromo', 'Tegalsari', 'Genteng', 'Bubutan'],
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initializeForm();
|
||||||
|
_addChangeListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeForm() {
|
||||||
|
_labelController.text = widget.address.label;
|
||||||
|
_recipientNameController.text = widget.address.recipientName;
|
||||||
|
_phoneController.text = widget.address.phoneNumber;
|
||||||
|
_isDefault = widget.address.isDefault;
|
||||||
|
|
||||||
|
_parseFullAddress(widget.address.fullAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseFullAddress(String fullAddress) {
|
||||||
|
final parts = fullAddress.split(', ');
|
||||||
|
|
||||||
|
if (parts.isNotEmpty) {
|
||||||
|
_addressController.text = parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullAddress.contains('Jakarta')) {
|
||||||
|
_selectedProvince = 'DKI Jakarta';
|
||||||
|
if (fullAddress.contains('Jakarta Pusat')) {
|
||||||
|
_selectedCity = 'Jakarta Pusat';
|
||||||
|
_selectedDistrict = 'Menteng';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 3) {
|
||||||
|
_detailController.text = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addChangeListeners() {
|
||||||
|
_labelController.addListener(_onFormChanged);
|
||||||
|
_recipientNameController.addListener(_onFormChanged);
|
||||||
|
_phoneController.addListener(_onFormChanged);
|
||||||
|
_addressController.addListener(_onFormChanged);
|
||||||
|
_detailController.addListener(_onFormChanged);
|
||||||
|
_noteController.addListener(_onFormChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFormChanged() {
|
||||||
|
if (!_hasChanges) {
|
||||||
|
setState(() {
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_labelController.dispose();
|
||||||
|
_recipientNameController.dispose();
|
||||||
|
_phoneController.dispose();
|
||||||
|
_addressController.dispose();
|
||||||
|
_detailController.dispose();
|
||||||
|
_noteController.dispose();
|
||||||
|
_labelFocus.dispose();
|
||||||
|
_recipientNameFocus.dispose();
|
||||||
|
_phoneFocus.dispose();
|
||||||
|
_addressFocus.dispose();
|
||||||
|
_detailFocus.dispose();
|
||||||
|
_noteFocus.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onProvinceChanged(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedProvince = value;
|
||||||
|
_selectedCity = null;
|
||||||
|
_selectedDistrict = null;
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCityChanged(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCity = value;
|
||||||
|
_selectedDistrict = null;
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDistrictChanged(String? value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDistrict = value;
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _showUnsavedChangesDialog() async {
|
||||||
|
if (!_hasChanges) return true;
|
||||||
|
|
||||||
|
return await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning, color: Colors.orange, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Perubahan Belum Disimpan'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: const Text(
|
||||||
|
'Anda memiliki perubahan yang belum disimpan. Apakah Anda yakin ingin keluar?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(false),
|
||||||
|
child: Text(
|
||||||
|
'Batal',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(true),
|
||||||
|
child: const Text(
|
||||||
|
'Keluar',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateAddress() async {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Alamat berhasil diperbarui'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
context.pop({
|
||||||
|
'id': widget.address.id,
|
||||||
|
'label': _labelController.text,
|
||||||
|
'recipientName': _recipientNameController.text,
|
||||||
|
'phone': _phoneController.text,
|
||||||
|
'fullAddress': _buildFullAddress(),
|
||||||
|
'isDefault': _isDefault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildFullAddress() {
|
||||||
|
final parts = [
|
||||||
|
_addressController.text,
|
||||||
|
_detailController.text,
|
||||||
|
_selectedDistrict,
|
||||||
|
_selectedCity,
|
||||||
|
_selectedProvince,
|
||||||
|
];
|
||||||
|
return parts.where((part) => part != null && part.isNotEmpty).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _useCurrentLocation() {
|
||||||
|
setState(() {
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: const Text('Menggunakan lokasi saat ini...'),
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteAddress() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warning, color: Colors.red, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Hapus Alamat'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'Apakah Anda yakin ingin menghapus alamat "${widget.address.label}"?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: Text(
|
||||||
|
'Batal',
|
||||||
|
style: TextStyle(color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.pop();
|
||||||
|
context.pop({'action': 'delete', 'id': widget.address.id});
|
||||||
|
},
|
||||||
|
child: const Text('Hapus', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: whiteColor,
|
||||||
|
elevation: 0,
|
||||||
|
title: Text('Edit Alamat', style: Tulisan.subheading()),
|
||||||
|
centerTitle: true,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () async {
|
||||||
|
if (await _showUnsavedChangesDialog()) {
|
||||||
|
router.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: Icon(Icons.more_vert, color: Colors.grey.shade700),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'delete') {
|
||||||
|
_deleteAddress();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder:
|
||||||
|
(BuildContext context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.delete, size: 18, color: Colors.red),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'Hapus Alamat',
|
||||||
|
style: TextStyle(color: Colors.red),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.edit_location,
|
||||||
|
color: primaryColor,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Edit Lokasi Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _useCurrentLocation,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.my_location,
|
||||||
|
size: 18,
|
||||||
|
color: primaryColor,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
'Gunakan Lokasi Saat Ini',
|
||||||
|
style: TextStyle(color: primaryColor),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: primaryColor),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Informasi Alamat',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (_hasChanges)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Ada Perubahan',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _labelController,
|
||||||
|
focusNode: _labelFocus,
|
||||||
|
label: 'Label Alamat',
|
||||||
|
hint: 'Contoh: Rumah, Kantor, Kost',
|
||||||
|
icon: Icons.label,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Label alamat tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(
|
||||||
|
context,
|
||||||
|
).requestFocus(_recipientNameFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _recipientNameController,
|
||||||
|
focusNode: _recipientNameFocus,
|
||||||
|
label: 'Nama Penerima',
|
||||||
|
hint: 'Masukkan nama lengkap penerima',
|
||||||
|
icon: Icons.person,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Nama penerima tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(context).requestFocus(_phoneFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
focusNode: _phoneFocus,
|
||||||
|
label: 'Nomor Telepon',
|
||||||
|
hint: 'Contoh: 08123456789',
|
||||||
|
icon: Icons.phone,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Nomor telepon tidak boleh kosong';
|
||||||
|
}
|
||||||
|
if (value.length < 10) {
|
||||||
|
return 'Nomor telepon minimal 10 digit';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(
|
||||||
|
context,
|
||||||
|
).requestFocus(_addressFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildDropdown(
|
||||||
|
label: 'Provinsi',
|
||||||
|
value: _selectedProvince,
|
||||||
|
items: _provinces,
|
||||||
|
onChanged: _onProvinceChanged,
|
||||||
|
hint: 'Pilih Provinsi',
|
||||||
|
icon: Icons.location_city,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildDropdown(
|
||||||
|
label: 'Kota/Kabupaten',
|
||||||
|
value: _selectedCity,
|
||||||
|
items:
|
||||||
|
_selectedProvince != null
|
||||||
|
? _cities[_selectedProvince!] ?? []
|
||||||
|
: [],
|
||||||
|
onChanged: _onCityChanged,
|
||||||
|
hint: 'Pilih Kota/Kabupaten',
|
||||||
|
icon: Icons.business,
|
||||||
|
enabled: _selectedProvince != null,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildDropdown(
|
||||||
|
label: 'Kecamatan',
|
||||||
|
value: _selectedDistrict,
|
||||||
|
items:
|
||||||
|
_selectedCity != null
|
||||||
|
? _districts[_selectedCity!] ?? []
|
||||||
|
: [],
|
||||||
|
onChanged: _onDistrictChanged,
|
||||||
|
hint: 'Pilih Kecamatan',
|
||||||
|
icon: Icons.map,
|
||||||
|
enabled: _selectedCity != null,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _addressController,
|
||||||
|
focusNode: _addressFocus,
|
||||||
|
label: 'Alamat Lengkap',
|
||||||
|
hint: 'Jalan, RT/RW, Kelurahan',
|
||||||
|
icon: Icons.home,
|
||||||
|
maxLines: 3,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Alamat lengkap tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(context).requestFocus(_detailFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _detailController,
|
||||||
|
focusNode: _detailFocus,
|
||||||
|
label: 'Detail Alamat (Opsional)',
|
||||||
|
hint: 'Patokan, warna rumah, nomor rumah, dll',
|
||||||
|
icon: Icons.info,
|
||||||
|
maxLines: 2,
|
||||||
|
onFieldSubmitted: (value) {
|
||||||
|
FocusScope.of(context).requestFocus(_noteFocus);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
_buildTextField(
|
||||||
|
controller: _noteController,
|
||||||
|
focusNode: _noteFocus,
|
||||||
|
label: 'Catatan untuk Kurir (Opsional)',
|
||||||
|
hint: 'Instruksi khusus untuk kurir',
|
||||||
|
icon: Icons.note,
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: _isDefault,
|
||||||
|
onChanged: (bool? value) {
|
||||||
|
setState(() {
|
||||||
|
_isDefault = value ?? false;
|
||||||
|
_hasChanges = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
activeColor: primaryColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Jadikan sebagai alamat utama',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _updateAddress,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
_hasChanges ? primaryColor : Colors.grey.shade400,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
_isLoading
|
||||||
|
? Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Memperbarui...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
_hasChanges
|
||||||
|
? 'Simpan Perubahan'
|
||||||
|
: 'Simpan Alamat',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required FocusNode focusNode,
|
||||||
|
required String label,
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
int maxLines = 1,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
String? Function(String?)? validator,
|
||||||
|
void Function(String)? onFieldSubmitted,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
maxLines: maxLines,
|
||||||
|
keyboardType: keyboardType,
|
||||||
|
validator: validator,
|
||||||
|
onFieldSubmitted: onFieldSubmitted,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
prefixIcon: Icon(icon, color: Colors.grey.shade500),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: primaryColor, width: 2),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Colors.red),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdown({
|
||||||
|
required String label,
|
||||||
|
required String? value,
|
||||||
|
required List<String> items,
|
||||||
|
required void Function(String?) onChanged,
|
||||||
|
required String hint,
|
||||||
|
required IconData icon,
|
||||||
|
bool enabled = true,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: value,
|
||||||
|
items:
|
||||||
|
items.map((String item) {
|
||||||
|
return DropdownMenuItem<String>(value: item, child: Text(item));
|
||||||
|
}).toList(),
|
||||||
|
onChanged: enabled ? onChanged : null,
|
||||||
|
validator: (value) {
|
||||||
|
if (enabled && (value == null || value.isEmpty)) {
|
||||||
|
return '$label tidak boleh kosong';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
icon,
|
||||||
|
color: enabled ? Colors.grey.shade500 : Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: primaryColor, width: 2),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Colors.red),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: enabled ? Colors.grey.shade50 : Colors.grey.shade100,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,98 +1,100 @@
|
||||||
import 'package:flutter/material.dart';
|
// import 'package:flutter/material.dart';
|
||||||
import 'package:iconsax_flutter/iconsax_flutter.dart';
|
// import 'package:iconsax_flutter/iconsax_flutter.dart';
|
||||||
import 'package:rijig_mobile/core/router.dart';
|
// import 'package:rijig_mobile/core/router.dart';
|
||||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
// import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
import 'package:rijig_mobile/features/profil/components/profile_list_tile.dart';
|
// import 'package:rijig_mobile/features/profil/components/profile_list_tile.dart';
|
||||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
// import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||||
import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
|
// import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
|
||||||
|
|
||||||
class ProfileMenuOptions extends StatelessWidget {
|
// class ProfileMenuOptions extends StatelessWidget {
|
||||||
const ProfileMenuOptions({super.key});
|
// const ProfileMenuOptions({super.key});
|
||||||
|
|
||||||
@override
|
// @override
|
||||||
Widget build(BuildContext context) {
|
// Widget build(BuildContext context) {
|
||||||
return Container(
|
// return Container(
|
||||||
padding: PaddingCustom().paddingAll(10),
|
// padding: PaddingCustom().paddingAll(10),
|
||||||
decoration: BoxDecoration(
|
// decoration: BoxDecoration(
|
||||||
color: whiteColor,
|
// color: whiteColor,
|
||||||
border: Border.all(color: greyColor),
|
// border: Border.all(color: greyColor),
|
||||||
borderRadius: BorderRadius.circular(10),
|
// borderRadius: BorderRadius.circular(10),
|
||||||
),
|
// ),
|
||||||
child: Column(
|
// child: Column(
|
||||||
children: [
|
// children: [
|
||||||
ProfileListTile(
|
// ProfileListTile(
|
||||||
title: 'Ubah Pin',
|
// title: 'Pin',
|
||||||
iconColor: primaryColor,
|
// iconColor: primaryColor,
|
||||||
icon: Iconsax.wallet,
|
// icon: Iconsax.wallet,
|
||||||
onTap: () {
|
// onTap: () {
|
||||||
router.push('/pinsecureinput');
|
// router.push('/pinsecureinput');
|
||||||
},
|
// },
|
||||||
),
|
// ),
|
||||||
Divider(thickness: 0.7, color: greyColor),
|
// Divider(thickness: 0.7, color: greyColor),
|
||||||
ProfileListTile(
|
// ProfileListTile(
|
||||||
title: 'Alamat',
|
// title: 'Alamat',
|
||||||
iconColor: primaryColor,
|
// iconColor: primaryColor,
|
||||||
icon: Iconsax.wallet,
|
// icon: Iconsax.wallet,
|
||||||
onTap: () {},
|
// onTap: () {
|
||||||
),
|
// router.push('/address');
|
||||||
Divider(thickness: 0.7, color: greyColor),
|
// },
|
||||||
ProfileListTile(
|
// ),
|
||||||
title: 'Bantuan',
|
// Divider(thickness: 0.7, color: greyColor),
|
||||||
icon: Iconsax.wallet,
|
// ProfileListTile(
|
||||||
iconColor: primaryColor,
|
// title: 'Bantuan',
|
||||||
onTap: () {},
|
// icon: Iconsax.wallet,
|
||||||
),
|
// iconColor: primaryColor,
|
||||||
Divider(thickness: 0.7, color: greyColor),
|
// onTap: () {},
|
||||||
ProfileListTile(
|
// ),
|
||||||
title: 'Ulasan',
|
// Divider(thickness: 0.7, color: greyColor),
|
||||||
icon: Iconsax.wallet,
|
// ProfileListTile(
|
||||||
iconColor: primaryColor,
|
// title: 'Ulasan',
|
||||||
onTap: () {},
|
// icon: Iconsax.wallet,
|
||||||
),
|
// iconColor: primaryColor,
|
||||||
Divider(thickness: 0.7, color: greyColor),
|
// onTap: () {},
|
||||||
ProfileListTile(
|
// ),
|
||||||
title: 'Keluar',
|
// Divider(thickness: 0.7, color: greyColor),
|
||||||
icon: Iconsax.logout,
|
// ProfileListTile(
|
||||||
iconColor: redColor,
|
// title: 'Keluar',
|
||||||
onTap:
|
// icon: Iconsax.logout,
|
||||||
() => CustomBottomSheet.show(
|
// iconColor: redColor,
|
||||||
context: context,
|
// onTap:
|
||||||
title: "Logout Sekarang?",
|
// () => CustomBottomSheet.show(
|
||||||
content: Column(
|
// context: context,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// title: "Logout Sekarang?",
|
||||||
children: [
|
// content: Column(
|
||||||
Text("Yakin ingin logout dari akun ini?"),
|
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
// tambahan konten
|
// children: [
|
||||||
],
|
// Text("Yakin ingin logout dari akun ini?"),
|
||||||
),
|
// // tambahan konten
|
||||||
button1: CardButtonOne(
|
// ],
|
||||||
textButton: "Logout",
|
// ),
|
||||||
onTap: () {},
|
// button1: CardButtonOne(
|
||||||
fontSized: 14,
|
// textButton: "Logout",
|
||||||
colorText: Colors.white,
|
// onTap: () {},
|
||||||
color: Colors.red,
|
// fontSized: 14,
|
||||||
borderRadius: 10,
|
// colorText: Colors.white,
|
||||||
horizontal: double.infinity,
|
// color: Colors.red,
|
||||||
vertical: 50,
|
// borderRadius: 10,
|
||||||
loadingTrue: false,
|
// horizontal: double.infinity,
|
||||||
usingRow: false,
|
// vertical: 50,
|
||||||
),
|
// loadingTrue: false,
|
||||||
button2: CardButtonOne(
|
// usingRow: false,
|
||||||
textButton: "Batal",
|
// ),
|
||||||
onTap: () => router.pop(),
|
// button2: CardButtonOne(
|
||||||
fontSized: 14,
|
// textButton: "Batal",
|
||||||
colorText: Colors.red,
|
// onTap: () => router.pop(),
|
||||||
color: Colors.white,
|
// fontSized: 14,
|
||||||
borderRadius: 10,
|
// colorText: Colors.red,
|
||||||
horizontal: double.infinity,
|
// color: Colors.white,
|
||||||
vertical: 50,
|
// borderRadius: 10,
|
||||||
loadingTrue: false,
|
// horizontal: double.infinity,
|
||||||
usingRow: false,
|
// vertical: 50,
|
||||||
),
|
// loadingTrue: false,
|
||||||
),
|
// usingRow: false,
|
||||||
),
|
// ),
|
||||||
],
|
// ),
|
||||||
),
|
// ),
|
||||||
);
|
// ],
|
||||||
}
|
// ),
|
||||||
}
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
|
@ -230,6 +230,7 @@ class _ProfilScreenState extends State<ProfilScreen> {
|
||||||
switch (menuTitle) {
|
switch (menuTitle) {
|
||||||
case 'Profil':
|
case 'Profil':
|
||||||
debugPrint('Profil');
|
debugPrint('Profil');
|
||||||
|
router.push('/akunprofil');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Ubah Pin':
|
case 'Ubah Pin':
|
||||||
|
@ -239,6 +240,7 @@ class _ProfilScreenState extends State<ProfilScreen> {
|
||||||
|
|
||||||
case 'Alamat':
|
case 'Alamat':
|
||||||
debugPrint('Alamat');
|
debugPrint('Alamat');
|
||||||
|
router.push('/address');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'Bantuan':
|
case 'Bantuan':
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
|
|
||||||
class CardButtonOne extends StatelessWidget {
|
class CardButtonOne extends StatelessWidget {
|
||||||
|
@ -53,25 +52,21 @@ class CardButtonOne extends StatelessWidget {
|
||||||
? usingRow == false
|
? usingRow == false
|
||||||
? Text(
|
? Text(
|
||||||
textButton,
|
textButton,
|
||||||
style: GoogleFonts.roboto(
|
style: Tulisan.customText(
|
||||||
textStyle: TextStyle(
|
color: colorText,
|
||||||
fontWeight: bold,
|
fontWeight: extraBold,
|
||||||
fontSize: fontSized.sp,
|
fontsize: 16,
|
||||||
color: colorText,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Row(
|
: Row(
|
||||||
mainAxisSize: mainAxisSize,
|
mainAxisSize: mainAxisSize,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
textButton,
|
textButton,
|
||||||
style: GoogleFonts.roboto(
|
style: Tulisan.customText(
|
||||||
textStyle: TextStyle(
|
color: colorText,
|
||||||
fontWeight: medium,
|
fontsize: 14,
|
||||||
fontSize: fontSized.sp,
|
|
||||||
color: colorText,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GapCustom().gapValue(10, false),
|
GapCustom().gapValue(10, false),
|
||||||
|
|
24
pubspec.lock
24
pubspec.lock
|
@ -400,6 +400,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
fuzzywuzzy:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fuzzywuzzy
|
||||||
|
sha256: "3004379ffd6e7f476a0c2091f38f16588dc45f67de7adf7c41aa85dec06b432c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
gap:
|
gap:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -744,6 +752,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
nik_validator:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: nik_validator
|
||||||
|
sha256: "7c24d2a396468177a689561fafd8bb59d3366e6795514a6db124441c0ad68107"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
nm:
|
nm:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -1069,6 +1085,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.4"
|
||||||
|
timelines_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: timelines_plus
|
||||||
|
sha256: "335cd832e0aed7035458a5f336bc2c882dc70dcfa4d500d3bcadd764f1ac6553"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.7"
|
||||||
toastification:
|
toastification:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -28,6 +28,7 @@ dependencies:
|
||||||
flutter_secure_storage: ^9.2.4
|
flutter_secure_storage: ^9.2.4
|
||||||
flutter_slidable: ^4.0.0
|
flutter_slidable: ^4.0.0
|
||||||
flutter_svg: ^2.1.0
|
flutter_svg: ^2.1.0
|
||||||
|
fuzzywuzzy: ^1.2.0
|
||||||
gap: ^3.0.1
|
gap: ^3.0.1
|
||||||
geolocator: ^14.0.0
|
geolocator: ^14.0.0
|
||||||
get_it: ^8.0.3
|
get_it: ^8.0.3
|
||||||
|
@ -43,6 +44,7 @@ dependencies:
|
||||||
jwt_decoder: ^2.0.1
|
jwt_decoder: ^2.0.1
|
||||||
latlong2: ^0.9.1
|
latlong2: ^0.9.1
|
||||||
localstorage: ^6.0.0
|
localstorage: ^6.0.0
|
||||||
|
nik_validator: ^1.1.2
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
permission_handler: ^12.0.0+1
|
permission_handler: ^12.0.0+1
|
||||||
pin_code_fields: ^8.0.1
|
pin_code_fields: ^8.0.1
|
||||||
|
@ -50,6 +52,7 @@ dependencies:
|
||||||
shared_preferences: ^2.3.3
|
shared_preferences: ^2.3.3
|
||||||
shimmer: ^3.0.0
|
shimmer: ^3.0.0
|
||||||
smooth_page_indicator: ^1.2.1
|
smooth_page_indicator: ^1.2.1
|
||||||
|
timelines_plus: ^1.0.7
|
||||||
toastification: ^3.0.2
|
toastification: ^3.0.2
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
|
|
Loading…
Reference in New Issue