feat: UI impelemenatation

This commit is contained in:
pahmiudahgede 2025-06-15 09:09:33 +07:00
parent 918e3c0ced
commit 5eed472971
24 changed files with 7524 additions and 294 deletions

View File

@ -18,21 +18,6 @@ migration:
- platform: android
create_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

View File

@ -1,11 +1,25 @@
import 'package:rijig_mobile/core/utils/exportimportview.dart';
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.dart';
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => SplashScreen()),
GoRoute(path: '/trashview', builder: (context, state) => TestRequestPickScreen()),
GoRoute(path: '/ordersumary', builder: (context, state) => OrderSummaryScreen()),
GoRoute(path: '/pinsecureinput', builder: (context, state) => SecurityCodeScreen()),
// GoRoute(
// path: '/',
// 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(
path: '/cmapview',
builder: (context, state) => CollectorRouteMapScreen(),
@ -66,12 +80,42 @@ final router = GoRouter(
// Rute untuk halaman-halaman utama
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(
path: '/dataperforma',
builder: (context, state) => DatavisualizedScreen(),
),
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
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(
path: '/aboutdetail',

View File

@ -3,8 +3,12 @@ export 'package:go_router/go_router.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/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/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/login_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/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/chat/presentation/screen/chatlist_screen.dart';
export 'package:rijig_mobile/features/profil/account/presentation/screen/account_screen.dart';

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
import 'package:rijig_mobile/widget/tabbar_custom.dart';
import 'package:rijig_mobile/widget/unhope_handler.dart';
import 'package:timelines_plus/timelines_plus.dart';
class ActivityScreen extends StatefulWidget {
const ActivityScreen({super.key});
@ -11,6 +12,98 @@ class ActivityScreen extends StatefulWidget {
}
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
Widget build(BuildContext context) {
return DefaultTabController(
@ -42,7 +135,7 @@ class _ActivityScreenState extends State<ActivityScreen> {
unselectedLabelColor: Colors.black54,
tabs: [
TabItem(title: 'Proses', count: 6),
TabItem(title: 'Gak Eroh', count: 3),
TabItem(title: 'Selesai', count: 3),
TabItem(title: 'Dibatalkan', count: 1),
],
),
@ -52,12 +145,845 @@ class _ActivityScreenState extends State<ActivityScreen> {
),
body: TabBarView(
children: [
Center(child: InfoStateWidget(type: InfoStateType.emptyData)),
Center(child: InfoStateWidget(type: InfoStateType.emptyData)),
Center(child: InfoStateWidget(type: InfoStateType.emptyData)),
_buildProcessTab(),
_buildCompletedTab(),
_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,
}

View File

@ -85,8 +85,12 @@ class CloginScreenState extends State<CloginScreen> {
horizontal: double.infinity,
vertical: 50,
onTap: () {
// if (cPhoneController.text.isNotEmpty) {
// debugPrint("send otp dipencet");
if (cPhoneController.text.isNotEmpty) {
debugPrint("send otp dipencet");
router.go(
"/cverif-otp",
extra: cPhoneController.text,
);
// await viewModel.loginOrRegister(
// cPhoneController.text,
// );
@ -96,7 +100,7 @@ class CloginScreenState extends State<CloginScreen> {
// extra: cPhoneController.text,
// );
// }
// }
}
},
// loadingTrue: viewModel.isLoading,
usingRow: false,

View File

@ -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(),
);
}
}

View File

@ -1,178 +1,684 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:rijig_mobile/core/router.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/features/auth/presentation/screen/collector/controller/ktp_validator_controller.dart';
class UploadKtpScreen extends StatefulWidget {
const UploadKtpScreen({super.key});
class IdentityValidationScreen extends StatefulWidget {
const IdentityValidationScreen({super.key});
@override
State<UploadKtpScreen> createState() => _UploadKtpScreenState();
State<IdentityValidationScreen> createState() =>
_IdentityValidationScreenState();
}
class _UploadKtpScreenState extends State<UploadKtpScreen> {
File? _ktpImage;
bool isLoading = false;
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 '';
}
class _IdentityValidationScreenState extends State<IdentityValidationScreen>
with AutomaticKeepAliveClientMixin {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late KtpValidatorController _controller;
@override
void dispose() {
for (final controller in controllers.values) {
controller.dispose();
}
super.dispose();
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_controller = context.read<KtpValidatorController>();
_controller.initialize();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: whiteColor,
appBar: AppBar(
title: const Text("Upload KTP"),
appBar: _buildAppBar(),
body: _buildBody(),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
title: Text(
'Validasi Identitas KTP',
style: GoogleFonts.dmSans(
fontSize: 18.sp,
fontWeight: bold,
color: whiteColor,
),
),
backgroundColor: primaryColor,
foregroundColor: whiteColor,
elevation: 0,
centerTitle: true,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios, color: whiteColor),
onPressed: () => router.pop(),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
);
}
Widget _buildBody() {
return Consumer<KtpValidatorController>(
builder: (context, controller, child) {
return SingleChildScrollView(
padding: EdgeInsets.all(20.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ktpImage != null
? Image.file(_ktpImage!, height: 200)
: Container(
height: 200,
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"))
_buildImageUploadSection(controller),
SizedBox(height: 24.h),
if (controller.hasData) _buildFormSection(controller),
_buildMessages(controller),
],
),
);
},
);
}
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(),
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:gap/gap.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/model/trashcart_model.dart';
@ -868,6 +869,7 @@ class _OrderSummaryScreenState extends State<OrderSummaryScreen>
: hasItems
? () {
_showSnackbar('Lanjut ke proses selanjutnya');
router.push('/pickupmethod');
}
: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -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,
),
),
],
],
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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 }

View File

@ -65,7 +65,8 @@ class _HomeScreenState extends State<HomeScreen> {
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
onPressed: () => router.push('/trashview'),
// onPressed: () => router.push('/trashview'),
onPressed: () => router.push('/notifikasi'),
icon: Icon(
Iconsax.notification_copy,
color: primaryColor,
@ -74,6 +75,8 @@ class _HomeScreenState extends State<HomeScreen> {
IconButton(
onPressed: () {
debugPrint('message icon tapped');
// router.push('/cmapview');
router.push('/chatlist');
},
icon: Icon(Iconsax.message_copy, color: primaryColor),
),

View File

@ -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') : () {},
),
],
);
}
}

View File

@ -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,
});
}

View File

@ -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,
),
),
),
],
);
}
}

View File

@ -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;
}
}
}

View File

@ -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,
),
),
),
],
);
}
}

View File

@ -1,98 +1,100 @@
import 'package:flutter/material.dart';
import 'package:iconsax_flutter/iconsax_flutter.dart';
import 'package:rijig_mobile/core/router.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
import 'package:rijig_mobile/features/profil/components/profile_list_tile.dart';
import 'package:rijig_mobile/widget/buttoncard.dart';
import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
// import 'package:flutter/material.dart';
// import 'package:iconsax_flutter/iconsax_flutter.dart';
// import 'package:rijig_mobile/core/router.dart';
// import 'package:rijig_mobile/core/utils/guide.dart';
// import 'package:rijig_mobile/features/profil/components/profile_list_tile.dart';
// import 'package:rijig_mobile/widget/buttoncard.dart';
// import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
class ProfileMenuOptions extends StatelessWidget {
const ProfileMenuOptions({super.key});
// class ProfileMenuOptions extends StatelessWidget {
// const ProfileMenuOptions({super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: PaddingCustom().paddingAll(10),
decoration: BoxDecoration(
color: whiteColor,
border: Border.all(color: greyColor),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
ProfileListTile(
title: 'Ubah Pin',
iconColor: primaryColor,
icon: Iconsax.wallet,
onTap: () {
router.push('/pinsecureinput');
},
),
Divider(thickness: 0.7, color: greyColor),
ProfileListTile(
title: 'Alamat',
iconColor: primaryColor,
icon: Iconsax.wallet,
onTap: () {},
),
Divider(thickness: 0.7, color: greyColor),
ProfileListTile(
title: 'Bantuan',
icon: Iconsax.wallet,
iconColor: primaryColor,
onTap: () {},
),
Divider(thickness: 0.7, color: greyColor),
ProfileListTile(
title: 'Ulasan',
icon: Iconsax.wallet,
iconColor: primaryColor,
onTap: () {},
),
Divider(thickness: 0.7, color: greyColor),
ProfileListTile(
title: 'Keluar',
icon: Iconsax.logout,
iconColor: redColor,
onTap:
() => CustomBottomSheet.show(
context: context,
title: "Logout Sekarang?",
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Yakin ingin logout dari akun ini?"),
// tambahan konten
],
),
button1: CardButtonOne(
textButton: "Logout",
onTap: () {},
fontSized: 14,
colorText: Colors.white,
color: Colors.red,
borderRadius: 10,
horizontal: double.infinity,
vertical: 50,
loadingTrue: false,
usingRow: false,
),
button2: CardButtonOne(
textButton: "Batal",
onTap: () => router.pop(),
fontSized: 14,
colorText: Colors.red,
color: Colors.white,
borderRadius: 10,
horizontal: double.infinity,
vertical: 50,
loadingTrue: false,
usingRow: false,
),
),
),
],
),
);
}
}
// @override
// Widget build(BuildContext context) {
// return Container(
// padding: PaddingCustom().paddingAll(10),
// decoration: BoxDecoration(
// color: whiteColor,
// border: Border.all(color: greyColor),
// borderRadius: BorderRadius.circular(10),
// ),
// child: Column(
// children: [
// ProfileListTile(
// title: 'Pin',
// iconColor: primaryColor,
// icon: Iconsax.wallet,
// onTap: () {
// router.push('/pinsecureinput');
// },
// ),
// Divider(thickness: 0.7, color: greyColor),
// ProfileListTile(
// title: 'Alamat',
// iconColor: primaryColor,
// icon: Iconsax.wallet,
// onTap: () {
// router.push('/address');
// },
// ),
// Divider(thickness: 0.7, color: greyColor),
// ProfileListTile(
// title: 'Bantuan',
// icon: Iconsax.wallet,
// iconColor: primaryColor,
// onTap: () {},
// ),
// Divider(thickness: 0.7, color: greyColor),
// ProfileListTile(
// title: 'Ulasan',
// icon: Iconsax.wallet,
// iconColor: primaryColor,
// onTap: () {},
// ),
// Divider(thickness: 0.7, color: greyColor),
// ProfileListTile(
// title: 'Keluar',
// icon: Iconsax.logout,
// iconColor: redColor,
// onTap:
// () => CustomBottomSheet.show(
// context: context,
// title: "Logout Sekarang?",
// content: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text("Yakin ingin logout dari akun ini?"),
// // tambahan konten
// ],
// ),
// button1: CardButtonOne(
// textButton: "Logout",
// onTap: () {},
// fontSized: 14,
// colorText: Colors.white,
// color: Colors.red,
// borderRadius: 10,
// horizontal: double.infinity,
// vertical: 50,
// loadingTrue: false,
// usingRow: false,
// ),
// button2: CardButtonOne(
// textButton: "Batal",
// onTap: () => router.pop(),
// fontSized: 14,
// colorText: Colors.red,
// color: Colors.white,
// borderRadius: 10,
// horizontal: double.infinity,
// vertical: 50,
// loadingTrue: false,
// usingRow: false,
// ),
// ),
// ),
// ],
// ),
// );
// }
// }

View File

@ -230,6 +230,7 @@ class _ProfilScreenState extends State<ProfilScreen> {
switch (menuTitle) {
case 'Profil':
debugPrint('Profil');
router.push('/akunprofil');
break;
case 'Ubah Pin':
@ -239,6 +240,7 @@ class _ProfilScreenState extends State<ProfilScreen> {
case 'Alamat':
debugPrint('Alamat');
router.push('/address');
break;
case 'Bantuan':

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
class CardButtonOne extends StatelessWidget {
@ -53,25 +52,21 @@ class CardButtonOne extends StatelessWidget {
? usingRow == false
? Text(
textButton,
style: GoogleFonts.roboto(
textStyle: TextStyle(
fontWeight: bold,
fontSize: fontSized.sp,
style: Tulisan.customText(
color: colorText,
),
fontWeight: extraBold,
fontsize: 16,
),
)
: Row(
mainAxisSize: mainAxisSize,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
textButton,
style: GoogleFonts.roboto(
textStyle: TextStyle(
fontWeight: medium,
fontSize: fontSized.sp,
style: Tulisan.customText(
color: colorText,
),
fontsize: 14,
),
),
GapCustom().gapValue(10, false),

View File

@ -400,6 +400,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
fuzzywuzzy:
dependency: "direct main"
description:
name: fuzzywuzzy
sha256: "3004379ffd6e7f476a0c2091f38f16588dc45f67de7adf7c41aa85dec06b432c"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
gap:
dependency: "direct main"
description:
@ -744,6 +752,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -1069,6 +1085,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:

View File

@ -28,6 +28,7 @@ dependencies:
flutter_secure_storage: ^9.2.4
flutter_slidable: ^4.0.0
flutter_svg: ^2.1.0
fuzzywuzzy: ^1.2.0
gap: ^3.0.1
geolocator: ^14.0.0
get_it: ^8.0.3
@ -43,6 +44,7 @@ dependencies:
jwt_decoder: ^2.0.1
latlong2: ^0.9.1
localstorage: ^6.0.0
nik_validator: ^1.1.2
path_provider: ^2.1.5
permission_handler: ^12.0.0+1
pin_code_fields: ^8.0.1
@ -50,6 +52,7 @@ dependencies:
shared_preferences: ^2.3.3
shimmer: ^3.0.0
smooth_page_indicator: ^1.2.1
timelines_plus: ^1.0.7
toastification: ^3.0.2
url_launcher: ^6.3.1
uuid: ^4.5.1