feat: feature cart and integrate with trash data us u can see

This commit is contained in:
pahmiudahgede 2025-05-29 05:28:09 +07:00
parent 4db92f3d22
commit 918e3c0ced
29 changed files with 3792 additions and 2402 deletions

View File

@ -17,5 +17,9 @@ export 'package:rijig_mobile/globaldata/about/about_service.dart';
export 'package:rijig_mobile/globaldata/article/article_repository.dart'; export 'package:rijig_mobile/globaldata/article/article_repository.dart';
export 'package:rijig_mobile/globaldata/article/article_service.dart'; export 'package:rijig_mobile/globaldata/article/article_service.dart';
export 'package:rijig_mobile/globaldata/article/article_vmod.dart'; export 'package:rijig_mobile/globaldata/article/article_vmod.dart';
export 'package:rijig_mobile/features/cart/presentation/viewmodel/cartitem_vmod.dart';
export 'package:rijig_mobile/features/cart/repositories/cartitem_repo.dart'; export 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
export 'package:rijig_mobile/features/cart/repositories/trashcart_repo.dart';
export 'package:rijig_mobile/features/cart/service/trashcart_service.dart';
// export 'package:rijig_mobile/features/cart/presentation/viewmodel/cartitem_vmod.dart';
// export 'package:rijig_mobile/features/cart/repositories/cartitem_repo.dart';

View File

@ -1,4 +1,7 @@
import 'package:rijig_mobile/core/container/export_vmod.dart'; import 'package:rijig_mobile/core/container/export_vmod.dart';
// import 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
// import 'package:rijig_mobile/features/cart/repositories/trashcart_repo.dart';
// import 'package:rijig_mobile/features/cart/service/trashcart_service.dart';
final sl = GetIt.instance; final sl = GetIt.instance;
@ -7,11 +10,35 @@ void init() {
sl.registerFactory(() => OtpViewModel(OtpService(OtpRepository()))); sl.registerFactory(() => OtpViewModel(OtpService(OtpRepository())));
sl.registerFactory(() => LogoutViewModel(LogoutService(LogoutRepository()))); sl.registerFactory(() => LogoutViewModel(LogoutService(LogoutRepository())));
sl.registerFactory( sl.registerLazySingleton<ITrashCategoryRepository>(
() => TrashViewModel(TrashCategoryService(TrashCategoryRepository())), () => TrashCategoryRepository(),
); );
sl.registerLazySingleton<ITrashCategoryService>(
() => TrashCategoryService(sl<ITrashCategoryRepository>()),
);
sl.registerFactory<TrashViewModel>(
() => TrashViewModel(sl<ITrashCategoryService>()),
);
sl.registerFactory(() => AboutViewModel(AboutService(AboutRepository()))); sl.registerFactory(() => AboutViewModel(AboutService(AboutRepository())));
sl.registerFactory(() => AboutDetailViewModel(AboutService(AboutRepository()))); sl.registerFactory(
sl.registerFactory(() => ArticleViewModel(ArticleService(ArticleRepository()))); () => AboutDetailViewModel(AboutService(AboutRepository())),
sl.registerFactory(() => CartViewModel(CartRepository())); );
sl.registerFactory(
() => ArticleViewModel(ArticleService(ArticleRepository())),
);
// sl.registerFactory(() => CartViewModel(CartRepository()));
sl.registerLazySingleton<CartRepository>(
() => CartRepositoryImpl(),
);
sl.registerLazySingleton<CartService>(
() => CartServiceImpl(repository: sl<CartRepository>()),
);
sl.registerFactory<CartViewModel>(
() => CartViewModel(cartService: sl<CartService>()),
);
} }

View File

@ -177,10 +177,10 @@ class NetworkDialogManager {
required VoidCallback onRetry, required VoidCallback onRetry,
required VoidCallback onExit, required VoidCallback onExit,
}) { }) {
CustomModalDialog.show( CustomModalDialog.showImage(
context: context, context: context,
showCloseIcon: false, showCloseIcon: false,
variant: ModalVariant.imageVersion, // variant: ModalVariant.imageVersion,
title: 'Tidak Ada Koneksi Internet', title: 'Tidak Ada Koneksi Internet',
content: content:
'Sepertinya koneksi internet Anda bermasalah. Periksa koneksi WiFi atau data seluler Anda, lalu coba lagi.', 'Sepertinya koneksi internet Anda bermasalah. Periksa koneksi WiFi atau data seluler Anda, lalu coba lagi.',
@ -209,10 +209,10 @@ class NetworkDialogManager {
required BuildContext context, required BuildContext context,
required VoidCallback onRetry, required VoidCallback onRetry,
}) { }) {
CustomModalDialog.show( CustomModalDialog.showImage(
context: context, context: context,
showCloseIcon: false, showCloseIcon: false,
variant: ModalVariant.imageVersion, // variant: ModalVariant.imageVersion,
title: 'Koneksi Timeout', title: 'Koneksi Timeout',
content: content:
'Permintaan memakan waktu terlalu lama. Periksa koneksi internet Anda dan coba lagi.', 'Permintaan memakan waktu terlalu lama. Periksa koneksi internet Anda dan coba lagi.',
@ -241,10 +241,10 @@ class NetworkDialogManager {
required BuildContext context, required BuildContext context,
required VoidCallback onRetry, required VoidCallback onRetry,
}) { }) {
CustomModalDialog.show( CustomModalDialog.showImage(
context: context, context: context,
showCloseIcon: false, showCloseIcon: false,
variant: ModalVariant.imageVersion, // variant: ModalVariant.imageVersion,
title: 'Koneksi Gagal', title: 'Koneksi Gagal',
content: content:
'Tidak dapat terhubung ke server. Pastikan koneksi internet Anda stabil.', 'Tidak dapat terhubung ke server. Pastikan koneksi internet Anda stabil.',
@ -274,15 +274,14 @@ class NetworkDialogManager {
required BuildContext context, required BuildContext context,
required VoidCallback onContinue, required VoidCallback onContinue,
}) { }) {
CustomModalDialog.show( CustomModalDialog.showImage(
context: context, context: context,
showCloseIcon: false, showCloseIcon: false,
variant: ModalVariant.imageVersion, // variant: ModalVariant.imageVersion,
title: 'Koneksi Lambat', title: 'Koneksi Lambat',
content: content:
'Koneksi internet Anda lambat. Beberapa fitur mungkin tidak berfungsi optimal.', 'Koneksi internet Anda lambat. Beberapa fitur mungkin tidak berfungsi optimal.',
imageAsset: imageAsset: 'assets/images/poor_connection.png',
'assets/images/poor_connection.png',
buttonCount: 2, buttonCount: 2,
button1: ElevatedButton( button1: ElevatedButton(
onPressed: onContinue, onPressed: onContinue,

View File

@ -1,5 +1,4 @@
import 'package:rijig_mobile/core/utils/exportimportview.dart'; import 'package:rijig_mobile/core/utils/exportimportview.dart';
import 'package:rijig_mobile/features/profil/components/secure_pin_input.dart';
final router = GoRouter( final router = GoRouter(
routes: [ routes: [
@ -72,11 +71,6 @@ final router = GoRouter(
builder: (context, state) => DatavisualizedScreen(), builder: (context, state) => DatavisualizedScreen(),
), ),
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()), GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
GoRoute(
path: '/requestpickup',
builder: (context, state) => RequestPickScreen(),
),
GoRoute(path: '/cart', builder: (context, state) => CartScreen()),
GoRoute(path: '/profil', builder: (context, state) => ProfilScreen()), GoRoute(path: '/profil', builder: (context, state) => ProfilScreen()),
GoRoute( GoRoute(

View File

@ -2,11 +2,9 @@ export 'package:flutter/material.dart';
export 'package:go_router/go_router.dart'; export 'package:go_router/go_router.dart';
export 'package:rijig_mobile/core/utils/navigation.dart'; export 'package:rijig_mobile/core/utils/navigation.dart';
export 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart'; export 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart';
export 'package:rijig_mobile/features/cart/presentation/screens/cart_screen.dart';
export 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart'; export 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart';
export 'package:rijig_mobile/features/home/datavisualized/presentation/screen/datavisualized_screen.dart'; export 'package:rijig_mobile/features/home/datavisualized/presentation/screen/datavisualized_screen.dart';
export 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart'; export 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart';
export 'package:rijig_mobile/features/requestpick/presentation/screen/requestpickup_screen.dart';
export 'package:rijig_mobile/features/auth/presentation/screen/inputpin_screen.dart'; export 'package:rijig_mobile/features/auth/presentation/screen/inputpin_screen.dart';
export 'package:rijig_mobile/features/auth/presentation/screen/login_screen.dart'; export 'package:rijig_mobile/features/auth/presentation/screen/login_screen.dart';
export 'package:rijig_mobile/features/auth/presentation/screen/otp_screen.dart'; export 'package:rijig_mobile/features/auth/presentation/screen/otp_screen.dart';
@ -28,5 +26,6 @@ export 'package:rijig_mobile/features/pickup/presentation/screen/pickup_map_scre
// remmovable // remmovable
export 'package:rijig_mobile/features/home/presentation/components/cart_test_screen.dart'; export 'package:rijig_mobile/features/cart/presentation/screens/cart_test_screen.dart';
export 'package:rijig_mobile/features/home/presentation/components/trash_testview.dart'; export 'package:rijig_mobile/features/profil/components/secure_pin_input.dart';
export 'package:rijig_mobile/features/requestpick/presentation/screen/trash_testview.dart';

View File

@ -3,7 +3,8 @@ import 'package:iconsax_flutter/iconsax_flutter.dart';
import 'package:rijig_mobile/core/utils/guide.dart'; import 'package:rijig_mobile/core/utils/guide.dart';
import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/router.dart';
import 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart'; import 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart';
import 'package:rijig_mobile/features/cart/presentation/screens/cart_screen.dart'; // import 'package:rijig_mobile/features/cart/presentation/screens/cart_screen.dart';
import 'package:rijig_mobile/features/cart/presentation/screens/cart_test_screen.dart';
import 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart'; import 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart';
import 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart'; import 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -82,7 +83,8 @@ class _NavigationPageState extends State<NavigationPage>
HomeScreen(), HomeScreen(),
ActivityScreen(), ActivityScreen(),
Text(""), Text(""),
CartScreen(), // CartScreen(),
OrderSummaryScreen(),
ProfilScreen(), ProfilScreen(),
], ],
), ),
@ -163,7 +165,8 @@ class _NavigationPageState extends State<NavigationPage>
disabledElevation: 0, disabledElevation: 0,
autofocus: false, autofocus: false,
focusElevation: 0, focusElevation: 0,
onPressed: () => router.push("/requestpickup"), onPressed: () => router.push("/trashview"),
// onPressed: () => router.push("/requestpickup"),
shape: const CircleBorder(), shape: const CircleBorder(),
elevation: 0, elevation: 0,
child: Column( child: Column(

View File

@ -1,89 +0,0 @@
import 'dart:convert';
class CartItem {
final String trashId;
final double amount;
CartItem({required this.trashId, required this.amount});
Map<String, dynamic> toJson() => {'trashid': trashId, 'amount': amount};
factory CartItem.fromJson(Map<String, dynamic> json) {
return CartItem(
trashId: json['trashid'],
amount: (json['amount'] as num).toDouble(),
);
}
static String encodeList(List<CartItem> items) =>
jsonEncode(items.map((e) => e.toJson()).toList());
static List<CartItem> decodeList(String source) =>
(jsonDecode(source) as List<dynamic>)
.map((e) => CartItem.fromJson(e))
.toList();
}
class CartItemResponse {
final String trashId;
final String trashIcon;
final String trashName;
final double amount;
final double estimatedSubTotalPrice;
CartItemResponse({
required this.trashId,
required this.trashIcon,
required this.trashName,
required this.amount,
required this.estimatedSubTotalPrice,
});
factory CartItemResponse.fromJson(Map<String, dynamic> json) {
return CartItemResponse(
trashId: json['trashid'],
trashIcon: json['trashicon'] ?? '',
trashName: json['trashname'] ?? '',
amount: (json['amount'] as num).toDouble(),
estimatedSubTotalPrice:
(json['estimated_subtotalprice'] as num).toDouble(),
);
}
}
class CartResponse {
final String id;
final String userId;
final double totalAmount;
final double estimatedTotalPrice;
final String createdAt;
final String updatedAt;
final List<CartItemResponse> cartItems;
CartResponse({
required this.id,
required this.userId,
required this.totalAmount,
required this.estimatedTotalPrice,
required this.createdAt,
required this.updatedAt,
required this.cartItems,
});
factory CartResponse.fromJson(Map<String, dynamic> json) {
var items =
(json['cartitems'] as List<dynamic>)
.map((e) => CartItemResponse.fromJson(e))
.toList();
return CartResponse(
id: json['id'],
userId: json['userid'],
totalAmount: (json['totalamount'] as num).toDouble(),
estimatedTotalPrice: (json['estimated_totalprice'] as num).toDouble(),
createdAt: json['createdAt'],
updatedAt: json['updatedAt'],
cartItems: items,
);
}
}

View File

@ -0,0 +1,183 @@
class CartItem {
final String id;
final String trashId;
final String trashName;
final String trashIcon;
final double trashPrice;
final int amount;
final double subtotalEstimatedPrice;
CartItem({
required this.id,
required this.trashId,
required this.trashName,
required this.trashIcon,
required this.trashPrice,
required this.amount,
required this.subtotalEstimatedPrice,
});
factory CartItem.fromJson(Map<String, dynamic> json) {
return CartItem(
id: json['id'] ?? '',
trashId: json['trash_id'] ?? '',
trashName: json['trash_name'] ?? '',
trashIcon: json['trash_icon'] ?? '',
trashPrice: (json['trash_price'] ?? 0).toDouble(),
amount: json['amount'] ?? 0,
subtotalEstimatedPrice:
(json['subtotal_estimated_price'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'trash_id': trashId,
'trash_name': trashName,
'trash_icon': trashIcon,
'trash_price': trashPrice,
'amount': amount,
'subtotal_estimated_price': subtotalEstimatedPrice,
};
}
CartItem copyWith({
String? id,
String? trashId,
String? trashName,
String? trashIcon,
double? trashPrice,
int? amount,
double? subtotalEstimatedPrice,
}) {
return CartItem(
id: id ?? this.id,
trashId: trashId ?? this.trashId,
trashName: trashName ?? this.trashName,
trashIcon: trashIcon ?? this.trashIcon,
trashPrice: trashPrice ?? this.trashPrice,
amount: amount ?? this.amount,
subtotalEstimatedPrice:
subtotalEstimatedPrice ?? this.subtotalEstimatedPrice,
);
}
@override
String toString() {
return 'CartItem(id: $id, trashId: $trashId, trashName: $trashName, amount: $amount, subtotalEstimatedPrice: $subtotalEstimatedPrice)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CartItem && other.trashId == trashId;
}
@override
int get hashCode => trashId.hashCode;
}
class Cart {
final String id;
final String userId;
final int totalAmount;
final double estimatedTotalPrice;
final List<CartItem> cartItems;
Cart({
required this.id,
required this.userId,
required this.totalAmount,
required this.estimatedTotalPrice,
required this.cartItems,
});
factory Cart.fromJson(Map<String, dynamic> json) {
final data = json['data'] ?? {};
return Cart(
id: data['id'] ?? '',
userId: data['user_id'] ?? '',
totalAmount: data['total_amount'] ?? 0,
estimatedTotalPrice: (data['estimated_total_price'] ?? 0).toDouble(),
cartItems:
(data['cart_items'] as List<dynamic>?)
?.map((item) => CartItem.fromJson(item))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'data': {
'id': id,
'user_id': userId,
'total_amount': totalAmount,
'estimated_total_price': estimatedTotalPrice,
'cart_items': cartItems.map((item) => item.toJson()).toList(),
},
};
}
Cart copyWith({
String? id,
String? userId,
int? totalAmount,
double? estimatedTotalPrice,
List<CartItem>? cartItems,
}) {
return Cart(
id: id ?? this.id,
userId: userId ?? this.userId,
totalAmount: totalAmount ?? this.totalAmount,
estimatedTotalPrice: estimatedTotalPrice ?? this.estimatedTotalPrice,
cartItems: cartItems ?? this.cartItems,
);
}
bool get isEmpty => cartItems.isEmpty;
bool get isNotEmpty => cartItems.isNotEmpty;
@override
String toString() {
return 'Cart(id: $id, userId: $userId, totalAmount: $totalAmount, estimatedTotalPrice: $estimatedTotalPrice, cartItems: ${cartItems.length})';
}
}
class AddOrUpdateCartRequest {
final String trashId;
final int amount;
AddOrUpdateCartRequest({required this.trashId, required this.amount});
Map<String, dynamic> toJson() {
return {'trash_id': trashId, 'amount': amount};
}
@override
String toString() {
return 'AddOrUpdateCartRequest(trashId: $trashId, amount: $amount)';
}
}
class CartApiResponse<T> {
final int status;
final String message;
final T? data;
CartApiResponse({required this.status, required this.message, this.data});
factory CartApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>)? fromJsonT,
) {
return CartApiResponse<T>(
status: json['meta']?['status'] ?? 0,
message: json['meta']?['message'] ?? '',
data: json['data'] != null && fromJsonT != null ? fromJsonT(json) : null,
);
}
bool get isSuccess => status >= 200 && status < 300;
}

View File

@ -1,466 +0,0 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart';
import 'package:iconsax_flutter/iconsax_flutter.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/features/cart/model/cartitem_model.dart';
import 'package:rijig_mobile/features/cart/presentation/viewmodel/cartitem_vmod.dart';
import 'package:rijig_mobile/widget/buttoncard.dart';
import 'package:rijig_mobile/widget/skeletonize.dart';
class CartScreen extends StatefulWidget {
const CartScreen({super.key});
@override
State<CartScreen> createState() => _CartScreenState();
}
class _CartScreenState extends State<CartScreen> with WidgetsBindingObserver {
CartResponse? cart;
bool isLoading = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
fetchCart();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
debugPrint("App resumed, flushing cart...");
final vmod = Provider.of<CartViewModel>(context, listen: false);
vmod.flushCartToServer();
}
}
Future<void> fetchCart() async {
final vmod = Provider.of<CartViewModel>(context, listen: false);
final result = await vmod.fetchCartFromServer();
setState(() {
cart = result;
isLoading = false;
});
}
double get totalAmount => cart?.totalAmount ?? 0;
double get totalPrice => cart?.estimatedTotalPrice ?? 0;
String formatAmount(double value) {
String formattedValue = value.toStringAsFixed(2);
return formattedValue.endsWith('.00')
? formattedValue.split('.').first
: formattedValue;
}
void _showEditDialog(String trashId, double currentAmount, int index) {
double editedAmount = currentAmount;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Edit Jumlah'),
content: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
setState(() {
if (editedAmount > 0.25) editedAmount -= 0.25;
});
},
),
Text('${formatAmount(editedAmount)} kg'),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
editedAmount += 0.25;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
TextButton(
onPressed: () {
final vmod = Provider.of<CartViewModel>(context, listen: false);
vmod.addOrUpdateItem(
CartItem(trashId: trashId, amount: editedAmount),
);
final item = cart!.cartItems[index];
setState(() {
cart!.cartItems[index] = CartItemResponse(
trashIcon: item.trashIcon,
trashName: item.trashName,
amount: editedAmount,
estimatedSubTotalPrice:
editedAmount *
(item.estimatedSubTotalPrice / item.amount),
trashId: item.trashId,
);
});
Navigator.of(context).pop();
},
child: const Text('Simpan'),
),
],
);
},
);
}
void recalculateCartSummary() {
double totalWeight = 0;
double totalPrice = 0;
for (final item in cart!.cartItems) {
totalWeight += item.amount;
totalPrice += item.estimatedSubTotalPrice;
}
setState(() {
cart = CartResponse(
id: cart!.id,
userId: cart!.userId,
totalAmount: totalWeight,
estimatedTotalPrice: totalPrice,
createdAt: cart!.createdAt,
updatedAt: cart!.updatedAt,
cartItems: cart!.cartItems,
);
});
}
@override
Widget build(BuildContext context) {
final String? baseUrl = dotenv.env["BASE_URL"];
return Scaffold(
backgroundColor: whiteColor,
appBar: AppBar(
title: Text("Keranjang Item", style: Tulisan.subheading()),
backgroundColor: whiteColor,
centerTitle: true,
),
body: SafeArea(
child:
isLoading
? ListView.builder(
shrinkWrap: true,
itemCount: 3,
itemBuilder: (context, index) {
return SkeletonCard();
},
)
: Padding(
padding: PaddingCustom().paddingOnly(
left: 10,
right: 10,
bottom: 40,
top: 10,
),
child: Column(
children: [
const Gap(20),
Expanded(
child: ListView.builder(
itemCount: cart?.cartItems.length ?? 0,
itemBuilder: (context, index) {
final item = cart!.cartItems[index];
final perKgPrice =
item.estimatedSubTotalPrice / item.amount;
final currentAmount = item.amount;
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: PaddingCustom().paddingAll(20),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: greyColor.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 8,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Image.network(
"$baseUrl${item.trashIcon}",
width: 50,
height: 50,
),
const Gap(10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
item.trashName,
style: Tulisan.customText(),
),
Text(
"Rp${perKgPrice.toStringAsFixed(0)} / kg",
style: Tulisan.body(fontsize: 12),
),
],
),
),
IconButton(
onPressed: () {
final vmod =
Provider.of<CartViewModel>(
context,
listen: false,
);
vmod.removeItem(item.trashId);
setState(() {
cart!.cartItems.removeAt(index);
});
recalculateCartSummary();
},
icon: Icon(
Iconsax.trash,
color: redColor,
),
),
],
),
const Gap(10),
Row(
children: [
Text(
"berat",
style: Tulisan.body(fontsize: 12),
),
const Gap(12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
border: Border.all(
color: greyAbsolutColor,
),
borderRadius: BorderRadius.circular(
6,
),
),
child: Row(
children: [
GestureDetector(
onTap: () {
final newAmount =
(currentAmount - 0.25)
.clamp(
0.25,
double.infinity,
);
Provider.of<CartViewModel>(
context,
listen: false,
).addOrUpdateItem(
CartItem(
trashId: item.trashId,
amount: newAmount,
),
);
setState(() {
cart!.cartItems[index] =
CartItemResponse(
trashIcon:
item.trashIcon,
trashName:
item.trashName,
amount: newAmount,
estimatedSubTotalPrice:
newAmount *
(item.estimatedSubTotalPrice /
item.amount),
trashId: item.trashId,
);
});
recalculateCartSummary();
},
child: const Icon(
Icons.remove,
size: 20,
),
),
const Gap(8),
GestureDetector(
onTap:
() => _showEditDialog(
item.trashId,
currentAmount,
index,
),
child: Text(
formatAmount(currentAmount),
),
),
const Gap(8),
GestureDetector(
onTap: () {
final newAmount =
currentAmount + 0.25;
Provider.of<CartViewModel>(
context,
listen: false,
).addOrUpdateItem(
CartItem(
trashId: item.trashId,
amount: newAmount,
),
);
setState(() {
cart!.cartItems[index] =
CartItemResponse(
trashIcon:
item.trashIcon,
trashName:
item.trashName,
amount: newAmount,
estimatedSubTotalPrice:
newAmount *
(item.estimatedSubTotalPrice /
item.amount),
trashId: item.trashId,
);
});
recalculateCartSummary();
},
child: const Icon(
Icons.add,
size: 20,
),
),
],
),
),
],
),
],
),
);
},
),
),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: greyAbsolutColor.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 8,
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Cart Total",
style: Tulisan.subheading(fontsize: 14),
),
Text(
"${formatAmount(totalAmount)} kg",
style: Tulisan.body(fontsize: 14),
),
],
),
const Gap(10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Harga Total",
style: Tulisan.subheading(fontsize: 14),
),
Text(
"Rp${totalPrice.toStringAsFixed(0)}",
style: Tulisan.body(fontsize: 14),
),
],
),
],
),
),
const Gap(20),
SizedBox(
width: double.infinity,
child: CardButtonOne(
textButton: "Request Pickup",
fontSized: 16.sp,
color: primaryColor,
colorText: whiteColor,
borderRadius: 9,
horizontal: double.infinity,
vertical: 50,
// onTap: () async {
// final vmod = Provider.of<CartViewModel>(
// context,
// listen: false,
// );
// await vmod.flushCartToServer();
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text(
// "Keranjang berhasil dikirim ke server!",
// ),
// ),
// );
// },
onTap: () {
router.push("/pickupmethod", extra: '');
},
),
),
const Gap(20),
],
),
),
),
);
}
}

View File

@ -0,0 +1,911 @@
import 'dart:math' as math;
import 'package:custom_refresh_indicator/custom_refresh_indicator.dart';
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/features/cart/presentation/viewmodel/trashcart_vmod.dart';
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
class OrderSummaryScreen extends StatefulWidget {
const OrderSummaryScreen({super.key});
@override
State<OrderSummaryScreen> createState() => _OrderSummaryScreenState();
}
class _OrderSummaryScreenState extends State<OrderSummaryScreen>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late CartViewModel _cartViewModel;
bool _isInitialized = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeCart();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed && mounted) {
_refreshCartData();
}
}
void _refreshCartData() {
if (mounted) {
final cartViewModel = context.read<CartViewModel>();
cartViewModel.refresh();
}
}
void _initializeCart() async {
if (_isInitialized) return;
_cartViewModel = context.read<CartViewModel>();
await _cartViewModel.loadCartItems(showLoading: false);
if (mounted) {
setState(() {
_isInitialized = true;
});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_isInitialized) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_refreshCartData();
});
}
}
Future<void> _removeItem(String trashId, String itemName) async {
final success = await _cartViewModel.deleteItem(trashId);
if (success) {
_showSnackbar('$itemName berhasil dihapus');
} else {
_showSnackbar(_cartViewModel.errorMessage);
}
}
Future<void> _clearAllItems() async {
if (_cartViewModel.isEmpty) return;
final confirmed = await _showConfirmationDialog(
title: 'Hapus Semua Item',
content: 'Apakah Anda yakin ingin menghapus semua item dari keranjang?',
confirmText: 'Hapus Semua',
);
if (confirmed == true) {
final success = await _cartViewModel.clearCart();
if (success) {
_showSnackbar('Semua item berhasil dihapus');
} else {
_showSnackbar(_cartViewModel.errorMessage);
}
}
}
Future<void> _incrementQuantity(String trashId) async {
await _cartViewModel.incrementItemAmount(trashId);
if (_cartViewModel.state == CartState.error) {
_showSnackbar(_cartViewModel.errorMessage);
}
}
Future<void> _decrementQuantity(String trashId) async {
await _cartViewModel.decrementItemAmount(trashId);
if (_cartViewModel.state == CartState.error) {
_showSnackbar(_cartViewModel.errorMessage);
}
}
Future<void> _showQuantityDialog(CartItem item) async {
final TextEditingController controller = TextEditingController(
text: item.amount.toString(),
);
final result = await showDialog<int>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Input Jumlah ${item.trashName}'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Jumlah',
border: OutlineInputBorder(),
suffixText: 'kg',
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Batal'),
),
ElevatedButton(
onPressed: () {
final newAmount = int.tryParse(controller.text);
if (newAmount != null && newAmount > 0) {
Navigator.of(context).pop(newAmount);
} else {
_showSnackbar('Masukkan angka yang valid (lebih dari 0)');
}
},
child: Text('Simpan'),
),
],
);
},
);
if (result != null) {
final success = await _cartViewModel.addOrUpdateItem(
item.trashId,
result,
);
if (!success) {
_showSnackbar(_cartViewModel.errorMessage);
}
}
}
Future<bool?> _showConfirmationDialog({
required String title,
required String content,
required String confirmText,
}) {
return showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: Text(confirmText),
),
],
);
},
);
}
void _showSnackbar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: Duration(seconds: 2)),
);
}
Map<String, dynamic> _getTrashTypeConfig(String trashName) {
final name = trashName.toLowerCase();
if (name.contains('plastik')) {
return {
'icon': Icons.local_drink,
'backgroundColor': Colors.blue.shade100,
'iconColor': Colors.blue,
};
} else if (name.contains('kertas')) {
return {
'icon': Icons.description,
'backgroundColor': Colors.orange.shade100,
'iconColor': Colors.orange,
};
} else if (name.contains('logam') || name.contains('metal')) {
return {
'icon': Icons.build,
'backgroundColor': Colors.grey.shade100,
'iconColor': Colors.grey.shade700,
};
} else if (name.contains('kaca')) {
return {
'icon': Icons.wine_bar,
'backgroundColor': Colors.green.shade100,
'iconColor': Colors.green,
};
} else {
return {
'icon': Icons.delete_outline,
'backgroundColor': Colors.grey.shade100,
'iconColor': Colors.grey.shade600,
};
}
}
double get totalWeight => _cartViewModel.totalItems.toDouble();
int get estimatedEarnings => _cartViewModel.totalPrice.round();
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
'Detail Pesanan',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
centerTitle: true,
actions: [
Consumer<CartViewModel>(
builder: (context, cartVM, child) {
if (cartVM.isNotEmpty) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.refresh, color: Colors.black),
onPressed: () => _refreshCartData(),
tooltip: 'Refresh Data',
),
PopupMenuButton<String>(
icon: Icon(Icons.more_vert, color: Colors.black),
onSelected: (value) {
if (value == 'clear_all') {
_clearAllItems();
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem<String>(
value: 'clear_all',
child: Row(
children: [
Icon(
Icons.clear_all,
color: Colors.red,
size: 20,
),
Gap(8),
Text(
'Hapus Semua',
style: TextStyle(color: Colors.red),
),
],
),
),
],
),
],
);
}
return SizedBox.shrink();
},
),
],
),
body: Consumer<CartViewModel>(
builder: (context, cartVM, child) {
if (cartVM.state == CartState.loading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
Gap(16),
Text('Memuat keranjang...'),
],
),
);
}
if (cartVM.state == CartState.error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red),
Gap(16),
Text(
'Terjadi kesalahan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
Gap(8),
Text(
cartVM.errorMessage,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
Gap(24),
ElevatedButton(
onPressed: () => _refreshCartData(),
child: Text('Coba Lagi'),
),
],
),
);
}
if (cartVM.isEmpty) {
return _buildEmptyState();
}
return CustomMaterialIndicator(
onRefresh: () async {
await _cartViewModel.loadCartItems(showLoading: false);
},
backgroundColor: Colors.white,
indicatorBuilder: (context, controller) {
return Padding(
padding: const EdgeInsets.all(6.0),
child: CircularProgressIndicator(
color: Colors.blue,
value: controller.state.isLoading
? null
: math.min(controller.value, 1.0),
),
);
},
child: SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildItemsSection(cartVM),
Gap(20),
_buildEarningsSection(cartVM),
Gap(20),
_buildBottomButton(cartVM),
],
),
),
);
},
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.shopping_cart_outlined,
size: 48,
color: Colors.grey.shade400,
),
),
Gap(16),
Text(
'Keranjang kosong',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
Gap(8),
Text(
'Tambahkan item sampah untuk melanjutkan',
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
),
Gap(24),
ElevatedButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.add),
label: Text('Tambah Item'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
}
Widget _buildItemsSection(CartViewModel cartVM) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
children: [
_buildSectionHeader(),
Gap(16),
...cartVM.cartItems.map((item) => _buildItemCard(item)),
Gap(16),
_buildTotalWeight(cartVM),
],
),
);
}
Widget _buildSectionHeader() {
return Row(
children: [
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.delete_outline, color: Colors.orange, size: 20),
),
Gap(12),
Text(
'Jenis Sampah',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.blue, size: 16),
Gap(4),
Text(
'Tambah',
style: TextStyle(
color: Colors.blue,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
Widget _buildItemCard(CartItem item) {
final config = _getTrashTypeConfig(item.trashName);
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: Consumer<CartViewModel>(
builder: (context, cartVM, child) {
return Slidable(
key: ValueKey('${item.trashId}_${item.id}'),
endActionPane: ActionPane(
motion: ScrollMotion(),
children: [
SlidableAction(
onPressed: (context) => _removeItem(item.trashId, item.trashName),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
icon: Icons.delete,
label: 'Hapus',
borderRadius: BorderRadius.circular(12),
),
],
),
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
_buildItemIcon(config),
Gap(12),
_buildItemInfo(item),
_buildQuantityControls(item, cartVM),
],
),
),
);
},
),
);
}
Widget _buildItemIcon(Map<String, dynamic> config) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: config['backgroundColor'],
borderRadius: BorderRadius.circular(20),
),
child: Icon(config['icon'], color: config['iconColor'], size: 20),
);
}
Widget _buildItemInfo(CartItem item) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trashName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
Gap(4),
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${_formatCurrency(item.trashPrice.round())}/kg',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildQuantityControls(CartItem item, CartViewModel cartVM) {
return Row(
children: [
_buildQuantityButton(
icon: Icons.remove,
onTap: cartVM.isOperationInProgress
? null
: () => _decrementQuantity(item.trashId),
backgroundColor: Colors.white,
iconColor: Colors.grey.shade600,
),
Gap(8),
GestureDetector(
onTap: cartVM.isOperationInProgress
? null
: () => _showQuantityDialog(item),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
'${item.amount} kg',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
),
Gap(8),
_buildQuantityButton(
icon: Icons.add,
onTap: cartVM.isOperationInProgress
? null
: () => _incrementQuantity(item.trashId),
backgroundColor: Colors.blue,
iconColor: Colors.white,
),
],
);
}
Widget _buildQuantityButton({
required IconData icon,
required VoidCallback? onTap,
required Color backgroundColor,
required Color iconColor,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: onTap == null ? Colors.grey.shade300 : backgroundColor,
borderRadius: BorderRadius.circular(6),
border: backgroundColor == Colors.white
? Border.all(color: Colors.grey.shade300)
: null,
),
child: Icon(
icon,
color: onTap == null ? Colors.grey.shade500 : iconColor,
size: 16,
),
),
);
}
Widget _buildTotalWeight(CartViewModel cartVM) {
return Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
child: Icon(Icons.scale, color: Colors.grey.shade700, size: 16),
),
Gap(12),
Text(
'Berat total',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
Spacer(),
Text(
'${cartVM.totalItems} kg',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
);
}
Widget _buildEarningsSection(CartViewModel cartVM) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.folder, color: Colors.orange, size: 20),
),
Gap(12),
Text(
'Rincian Perhitungan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
Gap(16),
// Detail per item
...cartVM.cartItems.map((item) => _buildItemCalculation(item)),
Gap(12),
// Divider
Divider(color: Colors.grey.shade300),
Gap(8),
// Total
_buildTotalCalculation(cartVM),
],
),
);
}
Widget _buildItemCalculation(CartItem item) {
final subtotal = (item.amount * item.trashPrice).round();
return Padding(
padding: EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.receipt_outlined, color: Colors.blue, size: 12),
),
Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.trashName,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
Gap(2),
Text(
'${item.amount} kg × Rp ${_formatCurrency(item.trashPrice.round())}/kg',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
),
Text(
'Rp ${_formatCurrency(subtotal)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
],
),
);
}
Widget _buildTotalCalculation(CartViewModel cartVM) {
return Row(
children: [
Container(
padding: EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.account_balance_wallet, color: Colors.green, size: 16),
),
Gap(12),
Expanded(
child: Text(
'Total Estimasi Pendapatan',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
Text(
'Rp ${_formatCurrency(estimatedEarnings)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
),
),
],
);
}
Widget _buildBottomButton(CartViewModel cartVM) {
return SizedBox(
width: double.infinity,
child: Consumer<CartViewModel>(
builder: (context, cartVM, child) {
final isLoading = cartVM.isOperationInProgress;
final hasItems = cartVM.isNotEmpty;
return ElevatedButton(
onPressed: isLoading
? null
: hasItems
? () {
_showSnackbar('Lanjut ke proses selanjutnya');
}
: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: hasItems ? Colors.blue : Colors.grey.shade400,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
hasItems ? 'Lanjut' : 'Tambah Item',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
);
},
),
);
}
String _formatCurrency(int amount) {
return amount.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
}

View File

@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:rijig_mobile/features/cart/model/cartitem_model.dart';
import 'package:rijig_mobile/features/cart/repositories/cartitem_repo.dart';
class CartViewModel extends ChangeNotifier {
final CartRepository _repository;
CartViewModel(this._repository);
List<CartItem> _cartItems = [];
List<CartItem> get cartItems => _cartItems;
bool _isLoading = false;
bool get isLoading => _isLoading;
Future<void> loadLocalCart() async {
_isLoading = true;
notifyListeners();
_cartItems = await _repository.getLocalCart();
_isLoading = false;
notifyListeners();
}
void addOrUpdateItem(CartItem item) {
final index = _cartItems.indexWhere((e) => e.trashId == item.trashId);
if (index != -1) {
_cartItems[index] = item;
} else {
_cartItems.add(item);
}
_repository.saveLocalCart(_cartItems);
notifyListeners();
}
void removeItem(String trashId) {
_cartItems.removeWhere((e) => e.trashId == trashId);
_repository.saveLocalCart(_cartItems);
notifyListeners();
}
Future<void> clearLocalCart() async {
_cartItems.clear();
await _repository.clearLocalCart();
notifyListeners();
}
Future<void> flushCartToServer() async {
if (_cartItems.isEmpty) return;
_isLoading = true;
notifyListeners();
await _repository.flushCartToServer();
await clearLocalCart();
_isLoading = false;
notifyListeners();
}
Future<CartResponse?> fetchCartFromServer() async {
try {
return await _repository.getCartFromServer();
} catch (e) {
debugPrint("Error fetching cart: $e");
return null;
}
}
Future<void> commitCart() async {
await _repository.commitCart();
}
Future<void> refreshTTL() async {
await _repository.refreshCartTTL();
}
Future<void> deleteItemFromServer(String trashId) async {
await _repository.deleteCartItemFromServer(trashId);
}
Future<void> clearCartFromServer() async {
await _repository.clearServerCart();
}
}

View File

@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
import 'package:rijig_mobile/features/cart/service/trashcart_service.dart';
enum CartState { initial, loading, loaded, error, empty, updating }
class CartViewModel extends ChangeNotifier {
final CartService _cartService;
CartViewModel({CartService? cartService})
: _cartService = cartService ?? CartServiceProvider.instance;
CartState _state = CartState.initial;
Cart? _cart;
String _errorMessage = '';
bool _isOperationInProgress = false;
CartState get state => _state;
Cart? get cart => _cart;
String get errorMessage => _errorMessage;
bool get isOperationInProgress => _isOperationInProgress;
List<CartItem> get cartItems => _cart?.cartItems ?? [];
int get totalItems => _cart?.totalAmount ?? 0;
double get totalPrice => _cart?.estimatedTotalPrice ?? 0.0;
bool get isEmpty => cartItems.isEmpty;
bool get isNotEmpty => cartItems.isNotEmpty;
void _setState(CartState newState) {
if (_state != newState) {
_state = newState;
notifyListeners();
}
}
void _setError(String message) {
_errorMessage = message;
_setState(CartState.error);
}
void _setOperationInProgress(bool inProgress) {
_isOperationInProgress = inProgress;
notifyListeners();
}
Future<void> loadCartItems({bool showLoading = true}) async {
if (showLoading) {
_setState(CartState.loading);
}
try {
final response = await _cartService.getCartItems();
if (response.isSuccess && response.data != null) {
_cart = response.data as Cart;
_setState(_cart!.isEmpty ? CartState.empty : CartState.loaded);
} else if (response.isUnauthorized) {
_setError('Sesi Anda telah berakhir, silakan login kembali');
} else {
_setError(response.message);
}
} catch (e) {
debugPrint('CartViewModel - loadCartItems error: $e');
_setError('Terjadi kesalahan tidak terduga');
}
}
Future<bool> addOrUpdateItem(
String trashId,
int amount, {
bool showUpdating = true,
}) async {
if (showUpdating) {
_setOperationInProgress(true);
}
try {
final response = await _cartService.addOrUpdateItem(trashId, amount);
if (response.isSuccess) {
await loadCartItems(showLoading: false);
return true;
} else {
if (response.isUnauthorized) {
_setError('Sesi Anda telah berakhir, silakan login kembali');
} else {
_setError(response.message);
}
return false;
}
} catch (e) {
debugPrint('CartViewModel - addOrUpdateItem error: $e');
_setError('Terjadi kesalahan tidak terduga');
return false;
} finally {
if (showUpdating) {
_setOperationInProgress(false);
}
}
}
Future<bool> deleteItem(String trashId, {bool showUpdating = true}) async {
if (showUpdating) {
_setOperationInProgress(true);
}
try {
final response = await _cartService.deleteItem(trashId);
if (response.isSuccess) {
await loadCartItems(showLoading: false);
return true;
} else {
if (response.isUnauthorized) {
_setError('Sesi Anda telah berakhir, silakan login kembali');
} else {
_setError(response.message);
}
return false;
}
} catch (e) {
debugPrint('CartViewModel - deleteItem error: $e');
_setError('Terjadi kesalahan tidak terduga');
return false;
} finally {
if (showUpdating) {
_setOperationInProgress(false);
}
}
}
Future<bool> clearCart({bool showUpdating = true}) async {
if (showUpdating) {
_setOperationInProgress(true);
}
try {
final response = await _cartService.clearCart();
if (response.isSuccess) {
await loadCartItems(showLoading: false);
return true;
} else {
if (response.isUnauthorized) {
_setError('Sesi Anda telah berakhir, silakan login kembali');
} else {
_setError(response.message);
}
return false;
}
} catch (e) {
debugPrint('CartViewModel - clearCart error: $e');
_setError('Terjadi kesalahan tidak terduga');
return false;
} finally {
if (showUpdating) {
_setOperationInProgress(false);
}
}
}
Future<bool> incrementItemAmount(String trashId) async {
_setOperationInProgress(true);
try {
final response = await _cartService.incrementItemAmount(trashId);
if (response.isSuccess) {
await loadCartItems(showLoading: false);
return true;
} else {
if (response.isUnauthorized) {
_setError('Sesi Anda telah berakhir, silakan login kembali');
} else {
_setError(response.message);
}
return false;
}
} catch (e) {
debugPrint('CartViewModel - incrementItemAmount error: $e');
_setError('Terjadi kesalahan tidak terduga');
return false;
} finally {
_setOperationInProgress(false);
}
}
Future<bool> decrementItemAmount(String trashId) async {
_setOperationInProgress(true);
try {
final response = await _cartService.decrementItemAmount(trashId);
if (response.isSuccess) {
await loadCartItems(showLoading: false);
return true;
} else {
if (response.isUnauthorized) {
_setError('Sesi Anda telah berakhir, silakan login kembali');
} else {
_setError(response.message);
}
return false;
}
} catch (e) {
debugPrint('CartViewModel - decrementItemAmount error: $e');
_setError('Terjadi kesalahan tidak terduga');
return false;
} finally {
_setOperationInProgress(false);
}
}
CartItem? getItemByTrashId(String trashId) {
try {
return cartItems.firstWhere((item) => item.trashId == trashId);
} catch (e) {
return null;
}
}
bool isItemInCart(String trashId) {
return getItemByTrashId(trashId) != null;
}
int getItemAmount(String trashId) {
final item = getItemByTrashId(trashId);
return item?.amount ?? 0;
}
double getItemSubtotal(String trashId) {
final item = getItemByTrashId(trashId);
return item?.subtotalEstimatedPrice ?? 0.0;
}
void clearError() {
if (_state == CartState.error) {
_errorMessage = '';
_setState(
_cart == null || _cart!.isEmpty ? CartState.empty : CartState.loaded,
);
}
}
Future<void> refresh() async {
await loadCartItems(showLoading: true);
}
// Future<void> refresh() async {
// await loadCartItems(showLoading: false);
// notifyListeners();
// }
// @override
// void dispose() {
// super.dispose();
// }
}
extension CartViewModelExtension on CartViewModel {
String get formattedTotalPrice {
return 'Rp ${totalPrice.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
String getFormattedItemPrice(String trashId) {
final item = getItemByTrashId(trashId);
if (item == null) return 'Rp 0';
return 'Rp ${item.trashPrice.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
String getFormattedItemSubtotal(String trashId) {
final item = getItemByTrashId(trashId);
if (item == null) return 'Rp 0';
return 'Rp ${item.subtotalEstimatedPrice.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
}

View File

@ -1,55 +0,0 @@
import 'package:rijig_mobile/features/cart/model/cartitem_model.dart';
import 'package:rijig_mobile/features/cart/service/cartitem_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
class CartRepository {
final CartService _cartService = CartService();
final String _localCartKey = 'local_cart';
Future<List<CartItem>> getLocalCart() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_localCartKey);
if (raw == null || raw.isEmpty) return [];
return CartItem.decodeList(raw);
}
Future<void> saveLocalCart(List<CartItem> items) async {
final prefs = await SharedPreferences.getInstance();
final encoded = CartItem.encodeList(items);
await prefs.setString(_localCartKey, encoded);
}
Future<void> clearLocalCart() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_localCartKey);
}
Future<void> flushCartToServer() async {
final items = await getLocalCart();
if (items.isEmpty) return;
await _cartService.postCart(items);
await clearLocalCart();
}
Future<CartResponse> getCartFromServer() async {
return await _cartService.getCart();
}
Future<void> commitCart() async {
await _cartService.commitCart();
}
Future<void> clearServerCart() async {
await _cartService.clearCart();
}
Future<void> deleteCartItemFromServer(String trashId) async {
await _cartService.deleteCartItem(trashId);
}
Future<void> refreshCartTTL() async {
await _cartService.refreshCartTTL();
}
}

View File

@ -0,0 +1,189 @@
import 'package:rijig_mobile/core/api/api_exception.dart';
import 'package:rijig_mobile/core/api/api_services.dart';
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
abstract class CartRepository {
Future<CartApiResponse<dynamic>> addOrUpdateCartItem(
AddOrUpdateCartRequest request,
);
Future<Cart> getCartItems();
Future<CartApiResponse<dynamic>> deleteCartItem(String trashId);
Future<CartApiResponse<dynamic>> clearCart();
}
class CartRepositoryImpl implements CartRepository {
final Https _https = Https();
static const String _cartEndpoint = '/cart';
static const String _cartItemEndpoint = '/cart/item';
static const String _cartClearEndpoint = '/cart/clear';
@override
Future<CartApiResponse<dynamic>> addOrUpdateCartItem(
AddOrUpdateCartRequest request,
) async {
try {
final response = await _https.post(
_cartItemEndpoint,
body: request.toJson(),
);
return CartApiResponse<dynamic>.fromJson(response, null);
} on ApiException catch (e) {
throw ApiException(e.message, e.statusCode);
} catch (e) {
throw ApiException(
'Unexpected error occurred while adding/updating cart item',
500,
);
}
}
@override
Future<Cart> getCartItems() async {
try {
final response = await _https.get(_cartEndpoint);
final cartResponse = CartApiResponse<Cart>.fromJson(
response,
(json) => Cart.fromJson(json),
);
if (!cartResponse.isSuccess) {
throw ApiException(cartResponse.message, cartResponse.status);
}
return cartResponse.data ??
Cart(
id: '',
userId: '',
totalAmount: 0,
estimatedTotalPrice: 0.0,
cartItems: [],
);
} on ApiException catch (e) {
throw ApiException(e.message, e.statusCode);
} catch (e) {
throw ApiException(
'Unexpected error occurred while fetching cart items',
500,
);
}
}
@override
Future<CartApiResponse<dynamic>> deleteCartItem(String trashId) async {
try {
final response = await _https.delete('$_cartItemEndpoint/$trashId');
return CartApiResponse<dynamic>.fromJson(response, null);
} on ApiException catch (e) {
throw ApiException(e.message, e.statusCode);
} catch (e) {
throw ApiException(
'Unexpected error occurred while deleting cart item',
500,
);
}
}
@override
Future<CartApiResponse<dynamic>> clearCart() async {
try {
final response = await _https.delete(_cartClearEndpoint);
return CartApiResponse<dynamic>.fromJson(response, null);
} on ApiException catch (e) {
throw ApiException(e.message, e.statusCode);
} catch (e) {
throw ApiException('Unexpected error occurred while clearing cart', 500);
}
}
}
class MockCartRepository implements CartRepository {
static final List<CartItem> _mockCartItems = [];
@override
Future<CartApiResponse<dynamic>> addOrUpdateCartItem(
AddOrUpdateCartRequest request,
) async {
await Future.delayed(const Duration(milliseconds: 500));
final existingIndex = _mockCartItems.indexWhere(
(item) => item.trashId == request.trashId,
);
if (existingIndex != -1) {
final existingItem = _mockCartItems[existingIndex];
_mockCartItems[existingIndex] = existingItem.copyWith(
amount: request.amount,
subtotalEstimatedPrice: existingItem.trashPrice * request.amount,
);
} else {
_mockCartItems.add(
CartItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
trashId: request.trashId,
trashName: 'Mock Trash Item',
trashIcon: '/mock/icon.png',
trashPrice: 1200.0,
amount: request.amount,
subtotalEstimatedPrice: 1200.0 * request.amount,
),
);
}
return CartApiResponse<dynamic>(
status: 200,
message: 'Berhasil menambah/mengubah item keranjang',
);
}
@override
Future<Cart> getCartItems() async {
await Future.delayed(const Duration(milliseconds: 300));
final totalAmount = _mockCartItems.fold<int>(
0,
(sum, item) => sum + item.amount,
);
final estimatedTotalPrice = _mockCartItems.fold<double>(
0.0,
(sum, item) => sum + item.subtotalEstimatedPrice,
);
return Cart(
id: 'mock_cart_id',
userId: 'mock_user_id',
totalAmount: totalAmount,
estimatedTotalPrice: estimatedTotalPrice,
cartItems: List.from(_mockCartItems),
);
}
@override
Future<CartApiResponse<dynamic>> deleteCartItem(String trashId) async {
await Future.delayed(const Duration(milliseconds: 300));
_mockCartItems.removeWhere((item) => item.trashId == trashId);
return CartApiResponse<dynamic>(
status: 200,
message: 'Berhasil menghapus item dari keranjang',
);
}
@override
Future<CartApiResponse<dynamic>> clearCart() async {
await Future.delayed(const Duration(milliseconds: 300));
_mockCartItems.clear();
return CartApiResponse<dynamic>(
status: 200,
message: 'Berhasil mengosongkan keranjang',
);
}
}

View File

@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:rijig_mobile/core/api/api_services.dart';
import 'package:rijig_mobile/features/cart/model/cartitem_model.dart';
class CartService {
final Https _https = Https();
Future<void> postCart(List<CartItem> items) async {
final body = {"items": items.map((e) => e.toJson()).toList()};
await _https.post("/cart", body: body);
}
Future<CartResponse> getCart() async {
final response = await _https.get("/cart");
debugPrint(response);
return CartResponse.fromJson(response['data']);
}
Future<void> deleteCartItem(String trashId) async {
await _https.delete("/cart/$trashId");
}
Future<void> clearCart() async {
await _https.delete("/cart");
}
Future<void> refreshCartTTL() async {
await _https.put("/cart/refresh");
}
Future<void> commitCart() async {
await _https.post("/cart/commit");
}
}

View File

@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:rijig_mobile/core/api/api_exception.dart';
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
import 'package:rijig_mobile/features/cart/repositories/trashcart_repo.dart';
enum CartOperationResult { success, failed, networkError, unauthorized }
class CartOperationResponse {
final CartOperationResult result;
final String message;
final dynamic data;
CartOperationResponse({
required this.result,
required this.message,
this.data,
});
bool get isSuccess => result == CartOperationResult.success;
bool get isNetworkError => result == CartOperationResult.networkError;
bool get isUnauthorized => result == CartOperationResult.unauthorized;
}
abstract class CartService {
Future<CartOperationResponse> addOrUpdateItem(String trashId, int amount);
Future<CartOperationResponse> getCartItems();
Future<CartOperationResponse> deleteItem(String trashId);
Future<CartOperationResponse> clearCart();
Future<CartOperationResponse> incrementItemAmount(String trashId);
Future<CartOperationResponse> decrementItemAmount(String trashId);
}
class CartServiceImpl implements CartService {
final CartRepository _repository;
CartServiceImpl({CartRepository? repository})
: _repository = repository ?? CartRepositoryImpl();
@override
Future<CartOperationResponse> addOrUpdateItem(
String trashId,
int amount,
) async {
try {
if (amount <= 0) {
return CartOperationResponse(
result: CartOperationResult.failed,
message: 'Jumlah item harus lebih dari 0',
);
}
final request = AddOrUpdateCartRequest(trashId: trashId, amount: amount);
final response = await _repository.addOrUpdateCartItem(request);
if (response.isSuccess) {
return CartOperationResponse(
result: CartOperationResult.success,
message: response.message,
data: response.data,
);
} else {
return CartOperationResponse(
result: CartOperationResult.failed,
message: response.message,
);
}
} on ApiException catch (e) {
debugPrint('CartService - addOrUpdateItem error: ${e.message}');
if (e.statusCode == 401 || e.statusCode == 403) {
return CartOperationResponse(
result: CartOperationResult.unauthorized,
message: 'Sesi Anda telah berakhir, silakan login kembali',
);
}
return CartOperationResponse(
result:
e.statusCode >= 500
? CartOperationResult.networkError
: CartOperationResult.failed,
message: e.message,
);
} catch (e) {
debugPrint('CartService - addOrUpdateItem unexpected error: $e');
return CartOperationResponse(
result: CartOperationResult.networkError,
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
);
}
}
@override
Future<CartOperationResponse> getCartItems() async {
try {
final cart = await _repository.getCartItems();
return CartOperationResponse(
result: CartOperationResult.success,
message: 'Berhasil mengambil data keranjang',
data: cart,
);
} on ApiException catch (e) {
debugPrint('CartService - getCartItems error: ${e.message}');
if (e.statusCode == 401 || e.statusCode == 403) {
return CartOperationResponse(
result: CartOperationResult.unauthorized,
message: 'Sesi Anda telah berakhir, silakan login kembali',
);
}
return CartOperationResponse(
result:
e.statusCode >= 500
? CartOperationResult.networkError
: CartOperationResult.failed,
message: e.message,
);
} catch (e) {
debugPrint('CartService - getCartItems unexpected error: $e');
return CartOperationResponse(
result: CartOperationResult.networkError,
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
);
}
}
@override
Future<CartOperationResponse> deleteItem(String trashId) async {
try {
final response = await _repository.deleteCartItem(trashId);
if (response.isSuccess) {
return CartOperationResponse(
result: CartOperationResult.success,
message: response.message,
);
} else {
return CartOperationResponse(
result: CartOperationResult.failed,
message: response.message,
);
}
} on ApiException catch (e) {
debugPrint('CartService - deleteItem error: ${e.message}');
if (e.statusCode == 401 || e.statusCode == 403) {
return CartOperationResponse(
result: CartOperationResult.unauthorized,
message: 'Sesi Anda telah berakhir, silakan login kembali',
);
}
return CartOperationResponse(
result:
e.statusCode >= 500
? CartOperationResult.networkError
: CartOperationResult.failed,
message: e.message,
);
} catch (e) {
debugPrint('CartService - deleteItem unexpected error: $e');
return CartOperationResponse(
result: CartOperationResult.networkError,
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
);
}
}
@override
Future<CartOperationResponse> clearCart() async {
try {
final response = await _repository.clearCart();
if (response.isSuccess) {
return CartOperationResponse(
result: CartOperationResult.success,
message: response.message,
);
} else {
return CartOperationResponse(
result: CartOperationResult.failed,
message: response.message,
);
}
} on ApiException catch (e) {
debugPrint('CartService - clearCart error: ${e.message}');
if (e.statusCode == 401 || e.statusCode == 403) {
return CartOperationResponse(
result: CartOperationResult.unauthorized,
message: 'Sesi Anda telah berakhir, silakan login kembali',
);
}
return CartOperationResponse(
result:
e.statusCode >= 500
? CartOperationResult.networkError
: CartOperationResult.failed,
message: e.message,
);
} catch (e) {
debugPrint('CartService - clearCart unexpected error: $e');
return CartOperationResponse(
result: CartOperationResult.networkError,
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
);
}
}
@override
Future<CartOperationResponse> incrementItemAmount(String trashId) async {
try {
final cartResponse = await getCartItems();
if (!cartResponse.isSuccess) {
return cartResponse;
}
final cart = cartResponse.data as Cart;
final item = cart.cartItems.firstWhere(
(item) => item.trashId == trashId,
orElse: () => throw Exception('Item tidak ditemukan di keranjang'),
);
return await addOrUpdateItem(trashId, item.amount + 1);
} catch (e) {
debugPrint('CartService - incrementItemAmount error: $e');
return CartOperationResponse(
result: CartOperationResult.failed,
message: 'Gagal menambah jumlah item',
);
}
}
@override
Future<CartOperationResponse> decrementItemAmount(String trashId) async {
try {
final cartResponse = await getCartItems();
if (!cartResponse.isSuccess) {
return cartResponse;
}
final cart = cartResponse.data as Cart;
final item = cart.cartItems.firstWhere(
(item) => item.trashId == trashId,
orElse: () => throw Exception('Item tidak ditemukan di keranjang'),
);
if (item.amount <= 1) {
return await deleteItem(trashId);
}
return await addOrUpdateItem(trashId, item.amount - 1);
} catch (e) {
debugPrint('CartService - decrementItemAmount error: $e');
return CartOperationResponse(
result: CartOperationResult.failed,
message: 'Gagal mengurangi jumlah item',
);
}
}
}
class CartServiceProvider {
static CartService? _instance;
static CartService get instance {
_instance ??= CartServiceImpl();
return _instance!;
}
static void setInstance(CartService service) {
_instance = service;
}
}

View File

@ -1,721 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:gap/gap.dart';
import 'package:rijig_mobile/core/router.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
class OrderSummaryScreen extends StatefulWidget {
const OrderSummaryScreen({super.key});
@override
State<OrderSummaryScreen> createState() => _OrderSummaryScreenState();
}
class _OrderSummaryScreenState extends State<OrderSummaryScreen> {
List<Map<String, dynamic>> selectedItems = [
{
'name': 'Plastik',
'price': 1000,
'quantity': 1.0,
'icon': Icons.local_drink,
'backgroundColor': Colors.blue.shade100,
'iconColor': Colors.blue,
},
{
'name': 'Kertas Campur',
'price': 700,
'quantity': 2.5,
'icon': Icons.description,
'backgroundColor': Colors.orange.shade100,
'iconColor': Colors.orange,
},
];
void _removeItem(int index) {
if (index < 0 || index >= selectedItems.length) return;
final removedItem = selectedItems[index];
setState(() {
selectedItems.removeAt(index);
});
_showSnackbar('${removedItem['name']} berhasil dihapus');
}
void _clearAllItems() {
if (selectedItems.isEmpty) return;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Hapus Semua Item'),
content: Text(
'Apakah Anda yakin ingin menghapus semua item dari daftar?',
),
actions: [
TextButton(
onPressed: () => router.pop(context),
child: Text('Batal'),
),
ElevatedButton(
onPressed: () {
setState(() {
selectedItems.clear();
});
router.pop(context);
_showSnackbar('Semua item berhasil dihapus');
},
style: ElevatedButton.styleFrom(
backgroundColor: redColor,
foregroundColor: whiteColor,
),
child: Text('Hapus Semua'),
),
],
);
},
);
}
void _incrementQuantity(int index) {
if (index < 0 || index >= selectedItems.length) return;
setState(() {
selectedItems[index]['quantity'] += 0.5;
});
}
void _decrementQuantity(int index) {
if (index < 0 || index >= selectedItems.length) return;
setState(() {
final currentQuantity = selectedItems[index]['quantity'] as double;
if (currentQuantity > 0.5) {
selectedItems[index]['quantity'] = (currentQuantity - 0.5).clamp(
0.0,
double.infinity,
);
}
});
}
void _showQuantityDialog(int index) {
if (index < 0 || index >= selectedItems.length) return;
final TextEditingController controller = TextEditingController(
text: selectedItems[index]['quantity'].toString(),
);
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Input Jumlah ${selectedItems[index]['name']}'),
content: TextField(
controller: controller,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Jumlah (kg)',
border: OutlineInputBorder(),
suffixText: 'kg',
),
),
actions: [
TextButton(
onPressed: () => router.pop(context),
child: Text('Batal'),
),
ElevatedButton(
onPressed: () {
final newQuantity = double.tryParse(controller.text);
if (newQuantity != null && newQuantity > 0) {
setState(() {
selectedItems[index]['quantity'] = newQuantity;
});
router.pop(context);
} else {
_showSnackbar('Masukkan angka yang valid (lebih dari 0)');
}
},
child: Text('Simpan'),
),
],
);
},
);
}
void _showSnackbar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: Duration(seconds: 2)),
);
}
double get totalWeight {
return selectedItems.fold(
0.0,
(sum, item) => sum + (item['quantity'] as double),
);
}
int get estimatedEarnings {
return selectedItems.fold<int>(
0,
(sum, item) =>
sum + ((item['price'] as int) * (item['quantity'] as double)).round(),
);
}
int get applicationFee => 550;
int get estimatedIncome => estimatedEarnings - applicationFee;
bool get hasItems => selectedItems.isNotEmpty;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey.shade50,
appBar: AppBar(
backgroundColor: whiteColor,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => router.pop(context),
),
title: Text(
'Detail Pesanan',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
centerTitle: true,
actions: [
if (hasItems)
PopupMenuButton<String>(
icon: Icon(Icons.more_vert, color: Colors.black),
onSelected: (value) {
if (value == 'clear_all') {
_clearAllItems();
}
},
itemBuilder:
(BuildContext context) => [
PopupMenuItem<String>(
value: 'clear_all',
child: Row(
children: [
Icon(Icons.clear_all, color: redColor, size: 20),
Gap(8),
Text(
'Hapus Semua',
style: TextStyle(color: redColor),
),
],
),
),
],
),
],
),
body: Column(
children: [
Expanded(
child:
hasItems
? SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildItemsSection(),
Gap(20),
_buildEarningsSection(),
],
),
)
: _buildEmptyState(),
),
_buildBottomButton(),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.delete_outline,
size: 48,
color: Colors.grey.shade400,
),
),
Gap(16),
Text(
'Belum ada item',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
Gap(8),
Text(
'Tambahkan item sampah untuk melanjutkan',
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
),
Gap(24),
ElevatedButton.icon(
onPressed: () => router.pop(context),
icon: Icon(Icons.add),
label: Text('Tambah Item'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: whiteColor,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
),
);
}
Widget _buildItemsSection() {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
children: [
_buildSectionHeader(),
Gap(16),
...selectedItems.asMap().entries.map(
(entry) => _buildItemCard(entry.key, entry.value),
),
Gap(16),
_buildTotalWeight(),
],
),
);
}
Widget _buildSectionHeader() {
return Row(
children: [
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.delete_outline, color: Colors.orange, size: 20),
),
Gap(12),
Text(
'Jenis Sampah',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
Spacer(),
TextButton(
onPressed: () => router.pop(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.blue, size: 16),
Gap(4),
Text(
'Tambah',
style: TextStyle(
color: Colors.blue,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
Widget _buildItemCard(int index, Map<String, dynamic> item) {
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: Slidable(
key: ValueKey('${item['name']}_$index'),
endActionPane: ActionPane(
motion: ScrollMotion(),
children: [
SlidableAction(
onPressed: (context) => _removeItem(index),
backgroundColor: redColor,
foregroundColor: whiteColor,
icon: Icons.delete,
label: 'Hapus',
borderRadius: BorderRadius.circular(12),
),
],
),
child: Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Row(
children: [
_buildItemIcon(item),
Gap(12),
_buildItemInfo(item),
_buildQuantityControls(index, item),
],
),
),
),
);
}
Widget _buildItemIcon(Map<String, dynamic> item) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: item['backgroundColor'],
borderRadius: BorderRadius.circular(20),
),
child: Icon(item['icon'], color: item['iconColor'], size: 20),
);
}
Widget _buildItemInfo(Map<String, dynamic> item) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item['name'],
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
Gap(4),
Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${item['price']}/kg',
style: TextStyle(
color: whiteColor,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildQuantityControls(int index, Map<String, dynamic> item) {
return Row(
children: [
_buildQuantityButton(
icon: Icons.remove,
onTap: () => _decrementQuantity(index),
backgroundColor: whiteColor,
iconColor: Colors.grey.shade600,
),
Gap(8),
GestureDetector(
onTap: () => _showQuantityDialog(index),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
'${_formatQuantity(item['quantity'])} kg',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
),
Gap(8),
_buildQuantityButton(
icon: Icons.add,
onTap: () => _incrementQuantity(index),
backgroundColor: Colors.blue,
iconColor: whiteColor,
),
],
);
}
Widget _buildQuantityButton({
required IconData icon,
required VoidCallback onTap,
required Color backgroundColor,
required Color iconColor,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(6),
border:
backgroundColor == whiteColor
? Border.all(color: Colors.grey.shade300)
: null,
),
child: Icon(icon, color: iconColor, size: 16),
),
);
}
Widget _buildTotalWeight() {
return Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Container(
padding: EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
child: Icon(Icons.scale, color: Colors.grey.shade700, size: 16),
),
Gap(12),
Text(
'Berat total',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
Spacer(),
Text(
'${_formatQuantity(totalWeight)} kg',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
);
}
Widget _buildEarningsSection() {
return Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.folder, color: Colors.orange, size: 20),
),
Gap(12),
Text(
'Perkiraan Pendapatan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
],
),
Gap(16),
_buildIncomeRow(
'Estimasi pembayaran',
estimatedEarnings,
Colors.orange,
),
Gap(8),
_buildIncomeRow(
'Biaya jasa aplikasi',
applicationFee,
Colors.orange,
showInfo: true,
),
Gap(8),
_buildIncomeRow(
'Estimasi pendapatan',
estimatedIncome,
Colors.orange,
isBold: true,
),
],
),
);
}
Widget _buildIncomeRow(
String title,
int amount,
Color color, {
bool showInfo = false,
bool isBold = false,
}) {
return Row(
children: [
Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.currency_exchange, color: whiteColor, size: 12),
),
Gap(8),
Expanded(
child: Row(
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: isBold ? FontWeight.w600 : FontWeight.w400,
color: Colors.black,
),
),
if (showInfo) ...[
Gap(4),
Icon(Icons.info_outline, color: Colors.blue, size: 16),
],
],
),
),
Text(
'Rp ${_formatCurrency(amount)}',
style: TextStyle(
fontSize: 14,
fontWeight: isBold ? FontWeight.w600 : FontWeight.w500,
color: Colors.black,
),
),
],
);
}
Widget _buildBottomButton() {
return Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: whiteColor,
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 10,
offset: Offset(0, -2),
),
],
),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
hasItems
? () {
_showSnackbar('Lanjut ke proses selanjutnya');
// Handle continue action
}
: () => router.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: hasItems ? Colors.blue : Colors.grey.shade400,
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
hasItems ? 'Lanjut' : 'Tambah Item',
style: TextStyle(
color: whiteColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
);
}
String _formatQuantity(double quantity) {
return quantity % 1 == 0
? quantity.toInt().toString()
: quantity.toString();
}
String _formatCurrency(int amount) {
return amount.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
}

View File

@ -1,500 +0,0 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:rijig_mobile/core/router.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
import 'package:rijig_mobile/widget/appbar.dart';
class TestRequestPickScreen extends StatefulWidget {
const TestRequestPickScreen({super.key});
@override
State<TestRequestPickScreen> createState() => _TestRequestPickScreenState();
}
class _TestRequestPickScreenState extends State<TestRequestPickScreen> {
// Map untuk menyimpan quantity setiap item
Map<String, double> quantities = {
'Plastik': 1.0,
'Kertas Campur': 1.0,
'Kaca': 0.0,
'Minyak Jelantah': 0.0,
'Kaleng Alumunium': 0.0,
};
// Map untuk menyimpan harga per kg
Map<String, int> prices = {
'Plastik': 1000,
'Kertas Campur': 700,
'Kaca': 300,
'Minyak Jelantah': 2500,
'Kaleng Alumunium': 3500,
};
// Map untuk menyimpan icon data
Map<String, IconData> icons = {
'Plastik': Icons.local_drink,
'Kertas Campur': Icons.description,
'Kaca': Icons.wine_bar,
'Minyak Jelantah': Icons.opacity,
'Kaleng Alumunium': Icons.recycling,
};
// Map untuk menyimpan warna background
Map<String, Color> backgroundColors = {
'Plastik': Colors.blue.shade100,
'Kertas Campur': Colors.orange.shade100,
'Kaca': Colors.red.shade100,
'Minyak Jelantah': Colors.orange.shade200,
'Kaleng Alumunium': Colors.green.shade100,
};
// Map untuk menyimpan warna icon
Map<String, Color> iconColors = {
'Plastik': Colors.blue,
'Kertas Campur': Colors.orange,
'Kaca': Colors.red,
'Minyak Jelantah': Colors.orange.shade700,
'Kaleng Alumunium': Colors.green,
};
void _resetQuantity(String itemName) {
setState(() {
quantities[itemName] = 0.0;
});
}
void _incrementQuantity(String itemName) {
setState(() {
quantities[itemName] = (quantities[itemName]! + 2.5);
});
}
void _decrementQuantity(String itemName) {
setState(() {
if (quantities[itemName]! > 0) {
quantities[itemName] = (quantities[itemName]! - 2.5).clamp(
0.0,
double.infinity,
);
}
});
}
void _showQuantityDialog(String itemName) {
TextEditingController controller = TextEditingController(
text: quantities[itemName]!.toString(),
);
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Input Jumlah $itemName'),
content: TextField(
controller: controller,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Jumlah (kg)',
border: OutlineInputBorder(),
suffixText: 'kg',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('Batal'),
),
ElevatedButton(
onPressed: () {
double? newQuantity = double.tryParse(controller.text);
if (newQuantity != null && newQuantity >= 0) {
setState(() {
quantities[itemName] = newQuantity;
});
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Masukkan angka yang valid')),
);
}
},
child: Text('Simpan'),
),
],
);
},
);
}
double get totalWeight {
return quantities.values.fold(0.0, (sum, quantity) => sum + quantity);
}
int get totalPrice {
int total = 0;
quantities.forEach((item, quantity) {
total += (prices[item]! * quantity).round();
});
return total;
}
int get totalItems {
return quantities.values.where((quantity) => quantity > 0).length;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: whiteColor,
appBar: CustomAppBar(judul: "Pilih Sampah"),
// appBar: AppBar(
// backgroundColor: whiteColor,
// elevation: 0,
// leading: IconButton(
// icon: Icon(Icons.arrow_back, color: Colors.black),
// onPressed: () => Navigator.pop(context),
// ),
// title: Row(
// children: [
// Icon(Icons.location_on, color: Colors.grey),
// Gap(8),
// Text(
// 'Purbalingga',
// style: TextStyle(
// color: Colors.black,
// fontSize: 16,
// fontWeight: FontWeight.w500,
// ),
// ),
// Spacer(),
// TextButton(
// onPressed: () {},
// child: Text('Ganti', style: TextStyle(color: Colors.blue)),
// ),
// ],
// ),
// ),
body: Column(
children: [
// Header
Container(
width: double.infinity,
padding: PaddingCustom().paddingAll(16),
color: whiteColor,
child: Text(
'Pilih Sampah',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
// List Items
Expanded(
child: ListView.builder(
padding: PaddingCustom().paddingAll(16),
itemCount: quantities.keys.length,
itemBuilder: (context, index) {
String itemName = quantities.keys.elementAt(index);
double quantity = quantities[itemName]!;
int price = prices[itemName]!;
return Container(
margin: EdgeInsets.only(bottom: 12),
padding: PaddingCustom().paddingAll(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.1),
spreadRadius: 1,
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Row(
children: [
// Icon
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: backgroundColors[itemName],
borderRadius: BorderRadius.circular(25),
),
child: Icon(
icons[itemName],
color: iconColors[itemName],
size: 24,
),
),
Gap(16),
// Item info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
itemName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
Gap(4),
Container(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Rp $price/kg',
style: TextStyle(
color: whiteColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
// Show delete icon when quantity > 0 (below price)
Gap(4),
SizedBox(
height: 24, // Fixed height untuk consistency
child:
quantity > 0
? GestureDetector(
onTap: () => _resetQuantity(itemName),
child: Container(
padding: PaddingCustom().paddingAll(
4,
),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(
4,
),
),
child: Icon(
Icons.delete_outline,
color: Colors.red,
size: 16,
),
),
)
: SizedBox(), // Empty space when no quantity
),
],
),
),
// Quantity controls or Add button
quantity > 0
? Row(
children: [
// Decrease button
GestureDetector(
onTap: () => _decrementQuantity(itemName),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.remove,
color: Colors.red,
size: 20,
),
),
),
Gap(12),
// Quantity display (clickable)
GestureDetector(
onTap: () => _showQuantityDialog(itemName),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${quantity.toString().replaceAll('.0', '')} kg',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
Gap(12),
// Increase button
GestureDetector(
onTap: () => _incrementQuantity(itemName),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.add,
color: Colors.blue,
size: 20,
),
),
),
],
)
: Row(
children: [
// Add button when quantity is 0
GestureDetector(
onTap: () => _incrementQuantity(itemName),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Tambah',
style: TextStyle(
color: whiteColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
),
);
},
),
),
// Bottom summary
Container(
// padding: PaddingCustom().paddingAll(16),
padding: PaddingCustom().paddingAll(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 10,
offset: Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$totalItems jenis ${totalWeight.toString().replaceAll('.0', '')} kg',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
Gap(4),
Row(
children: [
Text(
'Est. ',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${totalPrice.toString()}',
style: TextStyle(
color: whiteColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
Gap(4),
Text(
'Minimum total berat 3kg',
style: TextStyle(fontSize: 12, color: Colors.red),
),
],
),
),
Gap(16),
ElevatedButton(
onPressed:
totalWeight >= 3
? () {
router.push('/ordersumary');
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor:
totalWeight >= 3 ? Colors.blue : Colors.grey,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
'Lanjut',
style: TextStyle(
color: whiteColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
);
}
}

View File

@ -14,6 +14,7 @@ import 'package:rijig_mobile/globaldata/about/about_vmod.dart';
import 'package:rijig_mobile/globaldata/article/article_vmod.dart'; import 'package:rijig_mobile/globaldata/article/article_vmod.dart';
import 'package:rijig_mobile/widget/buttoncard.dart'; import 'package:rijig_mobile/widget/buttoncard.dart';
import 'package:rijig_mobile/widget/card_withicon.dart'; import 'package:rijig_mobile/widget/card_withicon.dart';
import 'package:rijig_mobile/widget/formfiled.dart';
import 'package:rijig_mobile/widget/showmodal.dart'; import 'package:rijig_mobile/widget/showmodal.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@ -59,40 +60,24 @@ class _HomeScreenState extends State<HomeScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded( Text("Rijig", style: Tulisan.heading(color: primaryColor)),
child: Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Text( IconButton(
"Rijig", onPressed: () => router.push('/trashview'),
style: Tulisan.heading(color: primaryColor), icon: Icon(
Iconsax.notification_copy,
color: primaryColor,
), ),
Row( ),
mainAxisAlignment: MainAxisAlignment.end, IconButton(
children: [ onPressed: () {
IconButton( debugPrint('message icon tapped');
onPressed: () => router.push('/trashview'), },
icon: Icon( icon: Icon(Iconsax.message_copy, color: primaryColor),
Iconsax.notification_copy, ),
color: primaryColor, ],
),
),
Gap(10),
IconButton(
onPressed: () {
debugPrint('message tapped');
},
icon: Icon(
Iconsax.message_copy,
color: primaryColor,
),
),
],
),
],
),
), ),
], ],
), ),
@ -113,14 +98,29 @@ class _HomeScreenState extends State<HomeScreen> {
text: 'Process', text: 'Process',
number: '1', number: '1',
onTap: () { onTap: () {
CustomModalDialog.show( CustomModalDialog.showWidget(
customWidget: FormFieldOne(
// controllers: cPhoneController,
hintText: 'Masukkan nomor whatsapp anda!',
placeholder: "cth.62..",
isRequired: true,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.phone,
onTap: () {},
onChanged: (value) {},
fontSize: 14,
fontSizeField: 16,
onFieldSubmitted: (value) {},
readOnly: false,
enabled: true,
),
context: context, context: context,
variant: ModalVariant.textVersion, // variant: ModalVariant.textVersion,
title: 'Belum Tersedia', // title: 'Belum Tersedia',
content: 'Maaf, fitur ini belum tersedia', // content: 'Maaf, fitur ini belum tersedia',
buttonCount: 2, buttonCount: 2,
button1: CardButtonOne( button1: CardButtonOne(
textButton: "Ya, Hapus", textButton: "oke, deh",
onTap: () {}, onTap: () {},
fontSized: 14, fontSized: 14,
colorText: whiteColor, colorText: whiteColor,

View File

@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart';
import 'package:pin_code_fields/pin_code_fields.dart'; import 'package:pin_code_fields/pin_code_fields.dart';
import 'package:rijig_mobile/core/utils/guide.dart'; import 'package:rijig_mobile/core/utils/guide.dart';
@ -46,56 +48,47 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Container( backgroundColor: whiteColor,
decoration: BoxDecoration( body: SafeArea(
gradient: LinearGradient( child: Padding(
begin: Alignment.topLeft, padding: PaddingCustom().paddingHorizontal(24),
end: Alignment.bottomRight, child: Column(
colors: [secondaryColor, primaryColor], children: [
), Gap(60),
), Text(
child: SafeArea( 'Masukkan PIN Kamu',
child: Padding( style: Tulisan.heading(fontsize: 20, color: primaryColor),
padding: const EdgeInsets.symmetric(horizontal: 24.0), // style: TextStyle(
child: Column( // fontSize: 20,
children: [ // fontWeight: FontWeight.w600,
const SizedBox(height: 60), // color: primaryColor,
// ),
Text( textAlign: TextAlign.center,
'Masukkan Security Code Kamu', ),
style: TextStyle( Gap(60),
fontSize: 20, Padding(
fontWeight: FontWeight.w600, padding: PaddingCustom().paddingHorizontal(27),
color: whiteColor, child: PinCodeTextField(
),
textAlign: TextAlign.center,
),
const SizedBox(height: 60),
PinCodeTextField(
appContext: context, appContext: context,
length: 6, length: 6,
controller: textEditingController, controller: textEditingController,
readOnly: true, readOnly: true,
obscureText: true, obscureText: true,
obscuringCharacter: '.', obscuringCharacter: '.',
animationType: AnimationType.fade, animationType: AnimationType.slide,
pinTheme: PinTheme( pinTheme: PinTheme(
shape: PinCodeFieldShape.circle, shape: PinCodeFieldShape.circle,
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
fieldHeight: 20, fieldHeight: 20,
fieldWidth: 20, fieldWidth: 20,
activeFillColor: whiteColor, activeFillColor: primaryColor,
inactiveFillColor: whiteColor.withValues(alpha: 0.3), inactiveFillColor: greyColor,
selectedFillColor: whiteColor.withValues(alpha: 0.7), selectedFillColor: primaryColor,
activeColor: Colors.transparent, activeColor: primaryColor,
inactiveColor: Colors.transparent, inactiveColor: greyColor,
selectedColor: Colors.transparent, selectedColor: blackNavyColor,
), ),
animationDuration: const Duration(milliseconds: 300), animationDuration: const Duration(milliseconds: 200),
backgroundColor: Colors.transparent,
enableActiveFill: true, enableActiveFill: true,
onCompleted: (v) { onCompleted: (v) {
_validatePin(); _validatePin();
@ -104,26 +97,21 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
currentText = value; currentText = value;
}, },
), ),
),
const SizedBox(height: 40), Gap(40),
Text(
Text( 'Lupa PIN kamu?',
'Lupa Security Code', style: TextStyle(
style: TextStyle( fontSize: 16.sp,
fontSize: 16, color: blackNavyColor,
color: whiteColor, decoration: TextDecoration.underline,
decoration: TextDecoration.underline, decorationColor: primaryColor,
decorationColor: whiteColor,
),
), ),
),
const Spacer(), const Spacer(),
_buildKeypad(),
_buildKeypad(), Gap(40),
],
const SizedBox(height: 40),
],
),
), ),
), ),
), ),
@ -143,8 +131,7 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
_buildKeypadButton('3'), _buildKeypadButton('3'),
], ],
), ),
const SizedBox(height: 20), Gap(20),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -153,8 +140,7 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
_buildKeypadButton('6'), _buildKeypadButton('6'),
], ],
), ),
const SizedBox(height: 20), Gap(20),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -163,15 +149,10 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
_buildKeypadButton('9'), _buildKeypadButton('9'),
], ],
), ),
const SizedBox(height: 20), Gap(20),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [Gap(80), _buildKeypadButton('0'), _buildDeleteButton()],
const SizedBox(width: 80),
_buildKeypadButton('0'),
_buildDeleteButton(),
],
), ),
], ],
), ),
@ -184,21 +165,14 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
child: Container( child: Container(
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(color: primaryColor, shape: BoxShape.circle),
color: whiteColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
border: Border.all(
color: whiteColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Center( child: Center(
child: Text( child: Text(
number, number,
style: TextStyle( style: Tulisan.customText(
fontSize: 28,
fontWeight: FontWeight.w400,
color: whiteColor, color: whiteColor,
fontWeight: extraBold,
fontsize: 30,
), ),
), ),
), ),
@ -213,15 +187,11 @@ class _SecurityCodeScreenState extends State<SecurityCodeScreen> {
width: 80, width: 80,
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: whiteColor.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(color: primaryColor, width: 1),
color: whiteColor.withValues(alpha: 0.3),
width: 1,
),
), ),
child: Center( child: Center(
child: Icon(Icons.backspace_outlined, color: whiteColor, size: 24), child: Icon(Icons.backspace_outlined, color: primaryColor, size: 28),
), ),
), ),
); );

View File

@ -48,19 +48,6 @@ class _ProfilScreenState extends State<ProfilScreen> {
), ),
), ),
), ),
// Positioned(
// bottom: 5,
// right: 5,
// child: Container(
// width: 24,
// height: 24,
// decoration: const BoxDecoration(
// color: Color(0xFF3B82F6),
// shape: BoxShape.circle,
// ),
// child: Icon(Icons.check, color: whiteColor, size: 14),
// ),
// ),
], ],
), ),
Gap(16), Gap(16),
@ -227,7 +214,6 @@ class _ProfilScreenState extends State<ProfilScreen> {
), ),
], ],
), ),
if (showDivider) ...[ if (showDivider) ...[
Gap(20), Gap(20),
Divider(height: 1, color: Colors.grey.shade200), Divider(height: 1, color: Colors.grey.shade200),
@ -247,7 +233,8 @@ class _ProfilScreenState extends State<ProfilScreen> {
break; break;
case 'Ubah Pin': case 'Ubah Pin':
debugPrint('Uabh Pin'); debugPrint('Ubah Pin');
router.push('/pinsecureinput');
break; break;
case 'Alamat': case 'Alamat':
@ -263,7 +250,6 @@ class _ProfilScreenState extends State<ProfilScreen> {
break; break;
case 'Keluar': case 'Keluar':
// _showLogoutDialog();
CustomBottomSheet.show( CustomBottomSheet.show(
context: context, context: context,
title: "Logout Sekarang?", title: "Logout Sekarang?",
@ -275,18 +261,6 @@ class _ProfilScreenState extends State<ProfilScreen> {
], ],
), ),
button1: ButtonLogout(), button1: ButtonLogout(),
// 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( button2: CardButtonOne(
textButton: "Gak jadi..", textButton: "Gak jadi..",
onTap: () => router.pop(), onTap: () => router.pop(),

View File

@ -1,172 +0,0 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:custom_refresh_indicator/custom_refresh_indicator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:provider/provider.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
import 'package:rijig_mobile/features/cart/model/cartitem_model.dart';
import 'package:rijig_mobile/features/cart/presentation/viewmodel/cartitem_vmod.dart';
import 'package:rijig_mobile/globaldata/trash/trash_viewmodel.dart';
import 'package:rijig_mobile/widget/appbar.dart';
import 'package:rijig_mobile/widget/skeletonize.dart';
class RequestPickScreen extends StatefulWidget {
const RequestPickScreen({super.key});
@override
RequestPickScreenState createState() => RequestPickScreenState();
}
class RequestPickScreenState extends State<RequestPickScreen> {
bool isCartLoaded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
final trashVM = Provider.of<TrashViewModel>(context, listen: false);
final cartVM = Provider.of<CartViewModel>(context, listen: false);
await trashVM.loadCategories();
await cartVM.loadLocalCart();
setState(() => isCartLoaded = true);
});
}
@override
Widget build(BuildContext context) {
final String? baseUrl = dotenv.env["BASE_URL"];
return Scaffold(
backgroundColor: whiteColor,
appBar: CustomAppBar(judul: "Pilih Sampah"),
body: CustomMaterialIndicator(
onRefresh: () async {
await Provider.of<TrashViewModel>(
context,
listen: false,
).loadCategories();
},
backgroundColor: whiteColor,
indicatorBuilder: (context, controller) {
return Padding(
padding: PaddingCustom().paddingAll(6),
child: CircularProgressIndicator(
color: primaryColor,
value:
controller.state.isLoading
? null
: math.min(controller.value, 1.0),
),
);
},
child: Consumer2<TrashViewModel, CartViewModel>(
builder: (context, trashViewModel, cartViewModel, child) {
if (!isCartLoaded || trashViewModel.isLoading) {
return ListView.builder(
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => SkeletonCard(),
);
}
if (trashViewModel.errorMessage != null) {
return Center(child: Text(trashViewModel.errorMessage!));
}
final categories =
trashViewModel.trashCategoryResponse?.categories ?? [];
return ListView.builder(
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final cartItems = cartViewModel.cartItems;
final existingItem = cartItems.firstWhereOrNull(
(e) =>
e.trashId.trim().toLowerCase() ==
category.id.trim().toLowerCase(),
);
final amount = existingItem?.amount;
return Card(
margin: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 15,
),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: Image.network(
"$baseUrl${category.icon}",
width: 50,
height: 50,
fit: BoxFit.cover,
),
title: Text(category.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Rp${category.price} per kg"),
const SizedBox(height: 6),
(amount != null && amount > 0)
? Row(
children: [
IconButton(
onPressed: () {
final newAmount = (amount - 0.25).clamp(
0.25,
double.infinity,
);
cartViewModel.addOrUpdateItem(
CartItem(
trashId: category.id,
amount: newAmount,
),
);
},
icon: const Icon(Icons.remove),
),
Text("${amount.toStringAsFixed(2)} kg"),
IconButton(
onPressed: () {
final newAmount = amount + 0.25;
cartViewModel.addOrUpdateItem(
CartItem(
trashId: category.id,
amount: newAmount,
),
);
},
icon: const Icon(Icons.add),
),
],
)
: ElevatedButton.icon(
onPressed: () {
cartViewModel.addOrUpdateItem(
CartItem(trashId: category.id, amount: 0.25),
);
},
icon: const Icon(Icons.add),
label: const Text("Tambah ke Keranjang"),
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: whiteColor,
),
),
],
),
),
);
},
);
},
),
),
);
}
}

View File

@ -0,0 +1,723 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:gap/gap.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/cart/presentation/viewmodel/trashcart_vmod.dart';
import 'package:rijig_mobile/globaldata/trash/trash_viewmodel.dart';
import 'package:rijig_mobile/widget/appbar.dart';
import 'package:rijig_mobile/widget/buttoncard.dart';
import 'package:rijig_mobile/widget/formfiled.dart';
import 'package:rijig_mobile/widget/showmodal.dart';
import 'package:rijig_mobile/widget/skeletonize.dart';
import 'package:rijig_mobile/widget/unhope_handler.dart';
import 'package:toastification/toastification.dart';
class TestRequestPickScreen extends StatefulWidget {
const TestRequestPickScreen({super.key});
@override
State<TestRequestPickScreen> createState() => _TestRequestPickScreenState();
}
class _TestRequestPickScreenState extends State<TestRequestPickScreen>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _slideAnimation;
bool _isInitialized = false;
final Map<String, bool> _itemLoadingStates = {};
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeData();
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Future<void> _initializeData() async {
if (_isInitialized) return;
final trashViewModel = Provider.of<TrashViewModel>(context, listen: false);
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
await Future.wait([
trashViewModel.loadCategories(),
cartViewModel.loadCartItems(showLoading: false),
]);
if (mounted) {
_updateBottomSheetVisibility();
setState(() {
_isInitialized = true;
});
}
}
void _updateBottomSheetVisibility() {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
if (cartViewModel.isNotEmpty) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
Future<void> _handleQuantityChange(
String categoryId,
double newQuantity,
) async {
setState(() {
_itemLoadingStates[categoryId] = true;
});
try {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
if (newQuantity <= 0) {
await cartViewModel.deleteItem(categoryId, showUpdating: false);
} else {
await cartViewModel.addOrUpdateItem(
categoryId,
newQuantity.toInt(),
showUpdating: false,
);
}
if (mounted) {
_updateBottomSheetVisibility();
}
} finally {
if (mounted) {
setState(() {
_itemLoadingStates[categoryId] = false;
});
}
}
}
Future<void> _incrementQuantity(String categoryId) async {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
final currentAmount = cartViewModel.getItemAmount(categoryId);
await _handleQuantityChange(categoryId, (currentAmount + 2.5));
}
Future<void> _decrementQuantity(String categoryId) async {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
final currentAmount = cartViewModel.getItemAmount(categoryId);
final newAmount = (currentAmount - 2.5).clamp(0.0, double.infinity);
await _handleQuantityChange(categoryId, newAmount);
}
Future<void> _resetQuantity(String categoryId) async {
await _handleQuantityChange(categoryId, 0);
}
void _showQuantityDialog(String categoryId, String itemName) {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
final currentAmount = cartViewModel.getItemAmount(categoryId);
TextEditingController controller = TextEditingController(
text: currentAmount.toString().replaceAll('.0', ''),
);
CustomModalDialog.showWidget(
customWidget: FormFieldOne(
controllers: controller,
hintText: 'masukkan berat dengan benar ya..',
isRequired: true,
textInputAction: TextInputAction.done,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onTap: () {},
onChanged: (value) {},
fontSize: 14.sp,
fontSizeField: 16.sp,
onFieldSubmitted: (value) {},
readOnly: false,
enabled: true,
),
context: context,
buttonCount: 2,
button1: CardButtonOne(
textButton: "Simpan",
onTap: () async {
double? newQuantity = double.tryParse(controller.text);
if (newQuantity != null && newQuantity >= 0) {
Navigator.pop(context);
await _handleQuantityChange(categoryId, newQuantity);
} else {
toastification.show(
type: ToastificationType.warning,
title: const Text("Masukkan angka yang valid"),
autoCloseDuration: const Duration(seconds: 3),
);
}
},
fontSized: 14.sp,
colorText: whiteColor,
color: primaryColor,
borderRadius: 10.sp,
horizontal: double.infinity,
vertical: 50,
loadingTrue: false,
usingRow: false,
),
button2: CardButtonOne(
textButton: "Batal",
onTap: () => router.pop(),
fontSized: 14.sp,
colorText: primaryColor,
color: Colors.transparent,
borderRadius: 10,
horizontal: double.infinity,
vertical: 50,
loadingTrue: false,
usingRow: false,
),
);
}
Widget _buildQuantityControls(String categoryId) {
final isItemLoading = _itemLoadingStates[categoryId] ?? false;
return Consumer<CartViewModel>(
builder: (context, cartViewModel, child) {
final amount = cartViewModel.getItemAmount(categoryId);
if (amount > 0) {
return Row(
children: [
_buildControlButton(
icon: Icons.remove,
color: Colors.red,
onTap:
isItemLoading ? null : () => _decrementQuantity(categoryId),
isLoading: isItemLoading,
),
const Gap(12),
_buildQuantityDisplay(
categoryId,
amount.toDouble(),
isItemLoading,
),
const Gap(12),
_buildControlButton(
icon: Icons.add,
color: primaryColor,
onTap:
isItemLoading ? null : () => _incrementQuantity(categoryId),
isLoading: isItemLoading,
),
],
);
} else {
return GestureDetector(
onTap: isItemLoading ? null : () => _incrementQuantity(categoryId),
child: Container(
padding: PaddingCustom().paddingHorizontalVertical(16, 8),
decoration: BoxDecoration(
color: isItemLoading ? Colors.grey : primaryColor,
borderRadius: BorderRadius.circular(8),
),
child:
isItemLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(whiteColor),
),
)
: Text(
'Tambah',
style: TextStyle(
color: whiteColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
);
}
},
);
}
Widget _buildControlButton({
required IconData icon,
required Color color,
required VoidCallback? onTap,
bool isLoading = false,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color:
onTap != null
? color.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child:
isLoading
? Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
)
: Icon(
icon,
color: onTap != null ? color : Colors.grey,
size: 20,
),
),
);
}
Widget _buildQuantityDisplay(
String categoryId,
double quantity,
bool isLoading,
) {
final trashViewModel = Provider.of<TrashViewModel>(context, listen: false);
final category = trashViewModel.getCategoryById(categoryId);
return GestureDetector(
onTap:
isLoading
? null
: () => _showQuantityDialog(categoryId, category?.name ?? ''),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: isLoading ? Colors.orange.shade300 : Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(8),
color: isLoading ? Colors.orange.shade50 : Colors.transparent,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
Padding(
padding: const EdgeInsets.only(right: 8),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(Colors.orange),
),
),
),
Text(
'${quantity.toString().replaceAll('.0', '')} kg',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isLoading ? Colors.orange.shade700 : Colors.black,
),
),
],
),
),
);
}
Widget _buildTrashItem(dynamic category) {
final String? baseUrl = dotenv.env["BASE_URL"];
return Consumer<CartViewModel>(
builder: (context, cartViewModel, child) {
final amount = cartViewModel.getItemAmount(category.id);
num price =
(category.price is String)
? int.tryParse(category.price) ?? 0
: category.price ?? 0;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: 12),
padding: PaddingCustom().paddingAll(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(12),
border:
amount > 0
? Border.all(
color: primaryColor.withValues(alpha: 0.3),
width: 1.5,
)
: null,
boxShadow: [
BoxShadow(
color:
amount > 0
? primaryColor.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
spreadRadius: amount > 0 ? 2 : 1,
blurRadius: amount > 0 ? 6 : 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
_buildCategoryIcon(baseUrl, category),
const Gap(16),
Expanded(
child: _buildCategoryInfo(category, price, amount.toDouble()),
),
_buildQuantityControls(category.id),
],
),
);
},
);
}
Widget _buildCategoryIcon(String? baseUrl, dynamic category) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(25),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Image.network(
'$baseUrl${category.icon}',
width: 50,
height: 50,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.category, color: Colors.grey, size: 24);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
strokeWidth: 2,
value:
loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
),
),
);
}
Widget _buildCategoryInfo(dynamic category, num price, double quantity) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
category.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
const Gap(4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Rp ${price.toString()}/kg',
style: TextStyle(
color: whiteColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
const Gap(4),
SizedBox(
height: 24,
child:
quantity > 0
? GestureDetector(
onTap: () => _resetQuantity(category.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: PaddingCustom().paddingAll(4),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.delete_outline,
color: Colors.red,
size: 16,
),
),
)
: const SizedBox(),
),
],
);
}
Widget _buildBottomSheet() {
return Consumer<CartViewModel>(
builder: (context, cartViewModel, child) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: PaddingCustom().paddingAll(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(child: _buildOrderSummary(cartViewModel)),
const Gap(16),
_buildContinueButton(cartViewModel),
],
),
);
},
);
}
Widget _buildOrderSummary(CartViewModel cartViewModel) {
final meetsMinimumWeight = cartViewModel.totalItems >= 3;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Column(
key: ValueKey(cartViewModel.totalItems),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${cartViewModel.cartItems.length} jenis ${cartViewModel.totalItems.toString()} kg',
style: TextStyle(fontSize: 14, color: Colors.grey.shade700),
),
const Gap(10),
Row(
children: [
Text(
'Est. ',
style: TextStyle(fontSize: 14, color: Colors.grey.shade700),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(8),
),
child: Text(
cartViewModel.formattedTotalPrice,
style: TextStyle(
color: whiteColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
if (!meetsMinimumWeight) ...[
const Gap(10),
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: meetsMinimumWeight ? 0.0 : 1.0,
child: const Text(
'Minimum total berat 3kg',
style: TextStyle(fontSize: 12, color: Colors.red),
),
),
],
],
),
);
}
Widget _buildContinueButton(CartViewModel cartViewModel) {
final meetsMinimumWeight = cartViewModel.totalItems >= 3;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: ElevatedButton(
onPressed:
meetsMinimumWeight ? () => _handleContinue(cartViewModel) : null,
style: ElevatedButton.styleFrom(
backgroundColor: meetsMinimumWeight ? Colors.blue : Colors.grey,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
'Lanjut',
style: TextStyle(
color: whiteColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
);
}
void _handleContinue(CartViewModel cartViewModel) {
final trashViewModel = Provider.of<TrashViewModel>(context, listen: false);
Map<String, dynamic> orderData = {
'selectedItems':
cartViewModel.cartItems.map((cartItem) {
trashViewModel.getCategoryById(cartItem.trashId);
return {
'id': cartItem.trashId,
'name': cartItem.trashName,
'quantity': cartItem.amount.toDouble(),
'pricePerKg': cartItem.trashPrice,
'totalPrice': cartItem.subtotalEstimatedPrice.round(),
};
}).toList(),
'totalWeight': cartViewModel.totalItems.toDouble(),
'totalPrice': cartViewModel.totalPrice.round(),
'totalItems': cartViewModel.cartItems.length,
};
context.push('/ordersumary', extra: orderData);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: whiteColor,
appBar: const CustomAppBar(judul: "Pilih Sampah"),
body: Consumer2<TrashViewModel, CartViewModel>(
builder: (context, trashViewModel, cartViewModel, child) {
if (trashViewModel.isLoading) {
return ListView.builder(
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => SkeletonCard(),
);
}
if (trashViewModel.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const Gap(16),
const Text(
'Terjadi kesalahan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const Gap(8),
Padding(
padding: PaddingCustom().paddingHorizontal(32),
child: Text(
trashViewModel.errorMessage ?? 'Error tidak diketahui',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
),
const Gap(24),
ElevatedButton(
onPressed: () => _initializeData(),
child: const Text('Coba Lagi'),
),
],
),
);
}
if (!trashViewModel.hasData || trashViewModel.categories.isEmpty) {
return InfoStateWidget(type: InfoStateType.emptyData);
}
return Stack(
children: [
ListView.builder(
padding: PaddingCustom().paddingOnly(
top: 16,
right: 16,
bottom: cartViewModel.isNotEmpty ? 120 : 16,
left: 16,
),
itemCount: trashViewModel.categories.length,
itemBuilder: (context, index) {
final category = trashViewModel.categories[index];
return _buildTrashItem(category);
},
),
AnimatedBuilder(
animation: _slideAnimation,
builder: (context, child) {
return Positioned(
left: 0,
right: 0,
bottom:
cartViewModel.isNotEmpty
? (_slideAnimation.value - 1) * 200
: -200,
child:
cartViewModel.isNotEmpty
? _buildBottomSheet()
: const SizedBox.shrink(),
);
},
),
],
);
},
),
);
}
}

View File

@ -1,10 +1,10 @@
class Category { class Category {
final String id; final String id;
final String name; final String name;
final dynamic price; final dynamic price; // Consider using num or double instead of dynamic
final String icon; final String icon;
final String createdAt; final DateTime createdAt;
final String updatedAt; final DateTime updatedAt;
Category({ Category({
required this.id, required this.id,
@ -17,14 +17,39 @@ class Category {
factory Category.fromJson(Map<String, dynamic> json) { factory Category.fromJson(Map<String, dynamic> json) {
return Category( return Category(
id: json['id'], id: json['id'] ?? '',
name: json['name'], name: json['name'] ?? '',
price: json['estimatedprice'], price: json['estimatedprice'] ?? 0,
icon: json['icon'], icon: json['icon'] ?? '',
createdAt: json['createdAt'], createdAt: DateTime.tryParse(json['createdAt'] ?? '') ?? DateTime.now(),
updatedAt: json['updatedAt'], updatedAt: DateTime.tryParse(json['updatedAt'] ?? '') ?? DateTime.now(),
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'estimatedprice': price,
'icon': icon,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
@override
String toString() {
return 'Category(id: $id, name: $name, price: $price, icon: $icon)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Category && other.id == id;
}
@override
int get hashCode => id.hashCode;
} }
class TrashCategoryResponse { class TrashCategoryResponse {
@ -39,11 +64,32 @@ class TrashCategoryResponse {
}); });
factory TrashCategoryResponse.fromJson(Map<String, dynamic> json) { factory TrashCategoryResponse.fromJson(Map<String, dynamic> json) {
return TrashCategoryResponse( try {
categories: final dataList = json['data'] as List? ?? [];
(json['data'] as List).map((e) => Category.fromJson(e)).toList(), final meta = json['meta'] as Map<String, dynamic>? ?? {};
message: json['meta']['message'],
total: json['meta']['total'], return TrashCategoryResponse(
); categories:
dataList
.map((e) => Category.fromJson(e as Map<String, dynamic>))
.toList(),
message: meta['message'] as String? ?? '',
total: meta['total'] as int? ?? 0,
);
} catch (e) {
throw FormatException('Failed to parse TrashCategoryResponse: $e');
}
}
Map<String, dynamic> toJson() {
return {
'data': categories.map((e) => e.toJson()).toList(),
'meta': {'message': message, 'total': total},
};
}
@override
String toString() {
return 'TrashCategoryResponse(categories: ${categories.length}, message: $message, total: $total)';
} }
} }

View File

@ -1,11 +1,20 @@
import 'package:rijig_mobile/core/api/api_services.dart'; import 'package:rijig_mobile/core/api/api_services.dart';
import 'package:rijig_mobile/globaldata/trash/trash_model.dart'; import 'package:rijig_mobile/globaldata/trash/trash_model.dart';
class TrashCategoryRepository { abstract class ITrashCategoryRepository {
Future<TrashCategoryResponse> fetchCategories();
}
class TrashCategoryRepository implements ITrashCategoryRepository {
final Https _https = Https(); final Https _https = Https();
@override
Future<TrashCategoryResponse> fetchCategories() async { Future<TrashCategoryResponse> fetchCategories() async {
final response = await _https.get('/trash/categories'); try {
return TrashCategoryResponse.fromJson(response); final response = await _https.get('/trash/categories');
return TrashCategoryResponse.fromJson(response);
} catch (e) {
rethrow;
}
} }
} }

View File

@ -1,16 +1,37 @@
import 'package:rijig_mobile/globaldata/trash/trash_model.dart'; import 'package:rijig_mobile/globaldata/trash/trash_model.dart';
import 'package:rijig_mobile/globaldata/trash/trash_repository.dart'; import 'package:rijig_mobile/globaldata/trash/trash_repository.dart';
class TrashCategoryService { abstract class ITrashCategoryService {
final TrashCategoryRepository _trashCategoryRepository; Future<TrashCategoryResponse> getCategories();
}
TrashCategoryService(this._trashCategoryRepository); class TrashCategoryService implements ITrashCategoryService {
final ITrashCategoryRepository _repository;
TrashCategoryService(this._repository);
@override
Future<TrashCategoryResponse> getCategories() async { Future<TrashCategoryResponse> getCategories() async {
try { try {
return await _trashCategoryRepository.fetchCategories(); final response = await _repository.fetchCategories();
return response;
} catch (e) { } catch (e) {
throw Exception('Failed to load categories: $e'); throw TrashCategoryServiceException(
'Service Error: Failed to load categories - $e',
500,
);
} }
} }
} }
class TrashCategoryServiceException implements Exception {
final String message;
final int statusCode;
TrashCategoryServiceException(this.message, this.statusCode);
@override
String toString() =>
'TrashCategoryServiceException: $message (Status: $statusCode)';
}

View File

@ -2,27 +2,82 @@ import 'package:flutter/material.dart';
import 'package:rijig_mobile/globaldata/trash/trash_model.dart'; import 'package:rijig_mobile/globaldata/trash/trash_model.dart';
import 'package:rijig_mobile/globaldata/trash/trash_service.dart'; import 'package:rijig_mobile/globaldata/trash/trash_service.dart';
enum TrashCategoryState { initial, loading, loaded, error }
class TrashViewModel extends ChangeNotifier { class TrashViewModel extends ChangeNotifier {
final TrashCategoryService _trashCategoryService; final ITrashCategoryService _service;
TrashViewModel(this._trashCategoryService); TrashViewModel(this._service);
bool isLoading = false; TrashCategoryState _state = TrashCategoryState.initial;
String? errorMessage; String? _errorMessage;
TrashCategoryResponse? trashCategoryResponse; TrashCategoryResponse? _trashCategoryResponse;
Future<void> loadCategories() async { TrashCategoryState get state => _state;
isLoading = true; bool get isLoading => _state == TrashCategoryState.loading;
errorMessage = null; bool get hasError => _state == TrashCategoryState.error;
notifyListeners(); bool get hasData =>
_state == TrashCategoryState.loaded && _trashCategoryResponse != null;
String? get errorMessage => _errorMessage;
TrashCategoryResponse? get trashCategoryResponse => _trashCategoryResponse;
List<Category> get categories => _trashCategoryResponse?.categories ?? [];
try { void _setState(TrashCategoryState newState) {
trashCategoryResponse = await _trashCategoryService.getCategories(); _state = newState;
} catch (e) {
errorMessage = "Error: ${e.toString()}";
}
isLoading = false;
notifyListeners(); notifyListeners();
} }
Future<void> loadCategories({bool forceRefresh = false}) async {
if (_state == TrashCategoryState.loading) return;
if (_state == TrashCategoryState.loaded && !forceRefresh) return;
_setState(TrashCategoryState.loading);
_errorMessage = null;
try {
_trashCategoryResponse = await _service.getCategories();
_setState(TrashCategoryState.loaded);
} on TrashCategoryServiceException catch (e) {
_errorMessage = e.message;
_setState(TrashCategoryState.error);
} catch (e) {
_errorMessage = "Unexpected error: ${e.toString()}";
_setState(TrashCategoryState.error);
}
}
void clearError() {
if (_state == TrashCategoryState.error) {
_errorMessage = null;
_setState(TrashCategoryState.initial);
}
}
void reset() {
_state = TrashCategoryState.initial;
_errorMessage = null;
_trashCategoryResponse = null;
notifyListeners();
}
Category? getCategoryById(String id) {
try {
return categories.firstWhere((category) => category.id == id);
} catch (e) {
return null;
}
}
List<Category> searchCategories(String query) {
if (query.isEmpty) return categories;
return categories
.where(
(category) =>
category.name.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}
} }

View File

@ -1,46 +1,125 @@
// ===lib/widget/showmodal.dart===
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/router.dart';
import 'package:rijig_mobile/core/utils/guide.dart'; import 'package:rijig_mobile/core/utils/guide.dart';
enum ModalVariant { textVersion, imageVersion } enum ModalVariant { textVersion, imageVersion, widgetSupportVersion }
class CustomModalDialog extends StatelessWidget { class CustomModalDialog extends StatelessWidget {
final ModalVariant variant; final ModalVariant variant;
final String title; // Text and Image version properties
final String content; final String? title;
final String? content;
final String? imageAsset; final String? imageAsset;
// Widget support version properties
final Widget? customWidget;
final int buttonCount; final int buttonCount;
final Widget? button1; final Widget? button1;
final Widget? button2; final Widget? button2;
// Parameter boolean untuk mengontrol tampilan close icon
final bool showCloseIcon; final bool showCloseIcon;
const CustomModalDialog({ const CustomModalDialog({
super.key, super.key,
required this.variant, required this.variant,
required this.title, this.title,
required this.content, this.content,
this.imageAsset, this.imageAsset,
this.customWidget,
this.buttonCount = 0, this.buttonCount = 0,
this.button1, this.button1,
this.button2, this.button2,
this.showCloseIcon = true, // Default true untuk backward compatibility this.showCloseIcon = true,
}); }) : assert(
variant == ModalVariant.widgetSupportVersion
? customWidget != null
: (title != null && content != null),
'For widgetSupportVersion, customWidget must be provided. '
'For other variants, title and content must be provided.',
);
static void show({ // Static method for text version
static void showText({
required BuildContext context, required BuildContext context,
required ModalVariant variant,
required String title, required String title,
required String content, required String content,
String? imageAsset,
int buttonCount = 0, int buttonCount = 0,
Widget? button1, Widget? button1,
Widget? button2, Widget? button2,
bool showCloseIcon = true, // Parameter baru di static method bool showCloseIcon = true,
}) {
_showModal(
context: context,
variant: ModalVariant.textVersion,
title: title,
content: content,
buttonCount: buttonCount,
button1: button1,
button2: button2,
showCloseIcon: showCloseIcon,
);
}
// Static method for image version
static void showImage({
required BuildContext context,
required String title,
required String content,
required String imageAsset,
int buttonCount = 0,
Widget? button1,
Widget? button2,
bool showCloseIcon = true,
}) {
_showModal(
context: context,
variant: ModalVariant.imageVersion,
title: title,
content: content,
imageAsset: imageAsset,
buttonCount: buttonCount,
button1: button1,
button2: button2,
showCloseIcon: showCloseIcon,
);
}
// Static method for widget support version
static void showWidget({
required BuildContext context,
required Widget customWidget,
int buttonCount = 0,
Widget? button1,
Widget? button2,
bool showCloseIcon = true,
}) {
_showModal(
context: context,
variant: ModalVariant.widgetSupportVersion,
customWidget: customWidget,
buttonCount: buttonCount,
button1: button1,
button2: button2,
showCloseIcon: showCloseIcon,
);
}
// Private method to handle the modal display
static void _showModal({
required BuildContext context,
required ModalVariant variant,
String? title,
String? content,
String? imageAsset,
Widget? customWidget,
int buttonCount = 0,
Widget? button1,
Widget? button2,
bool showCloseIcon = true,
}) { }) {
showGeneralDialog( showGeneralDialog(
context: context, context: context,
@ -66,10 +145,11 @@ class CustomModalDialog extends StatelessWidget {
title: title, title: title,
content: content, content: content,
imageAsset: imageAsset, imageAsset: imageAsset,
customWidget: customWidget,
buttonCount: buttonCount, buttonCount: buttonCount,
button1: button1, button1: button1,
button2: button2, button2: button2,
showCloseIcon: showCloseIcon, // Pass parameter ke constructor showCloseIcon: showCloseIcon,
), ),
), ),
), ),
@ -81,6 +161,20 @@ class CustomModalDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget contentWidget;
switch (variant) {
case ModalVariant.textVersion:
contentWidget = _buildTextContent();
break;
case ModalVariant.imageVersion:
contentWidget = _buildImageContent(context);
break;
case ModalVariant.widgetSupportVersion:
contentWidget = customWidget!;
break;
}
final modalContent = Container( final modalContent = Container(
padding: PaddingCustom().paddingHorizontalVertical(20, 24), padding: PaddingCustom().paddingHorizontalVertical(20, 24),
margin: const EdgeInsets.only(top: 30), margin: const EdgeInsets.only(top: 30),
@ -91,58 +185,88 @@ class CustomModalDialog extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (variant == ModalVariant.imageVersion && imageAsset != null) contentWidget,
Padding( if (buttonCount > 0) ...[const Gap(24), _buildButtons()],
padding: PaddingCustom().paddingOnly(bottom: 20),
child: Image.asset(
imageAsset!,
width: MediaQuery.of(context).size.width * 0.6,
fit: BoxFit.contain,
),
),
Align(
alignment: Alignment.centerLeft,
child: Text(title, style: Tulisan.subheading(fontsize: 19)),
),
Gap(8),
Align(
alignment: Alignment.centerLeft,
child: Text(content, style: Tulisan.customText(fontsize: 14)),
),
Gap(24),
if (buttonCount == 1 && button1 != null) button1!,
if (buttonCount == 2 && button1 != null && button2 != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: button2!),
Gap(16),
Expanded(child: button1!),
],
),
], ],
), ),
); );
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [modalContent, if (showCloseIcon) _buildCloseButton()],
);
}
Widget _buildTextContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
modalContent, Align(
// Kondisional untuk menampilkan close icon alignment: Alignment.centerLeft,
if (showCloseIcon) child: Text(title!, style: Tulisan.subheading(fontsize: 19)),
Positioned( ),
top: -15, const Gap(8),
right: 5, Align(
child: GestureDetector( alignment: Alignment.centerLeft,
onTap: () => router.pop(), child: Text(content!, style: Tulisan.customText(fontsize: 14)),
child: CircleAvatar( ),
radius: 18,
backgroundColor: whiteColor,
child: Icon(Icons.close, color: blackNavyColor),
),
),
),
], ],
); );
} }
}
Widget _buildImageContent(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (imageAsset != null) ...[
Image.asset(
imageAsset!,
width: MediaQuery.of(context).size.width * 0.6,
fit: BoxFit.contain,
),
const Gap(20),
],
Align(
alignment: Alignment.centerLeft,
child: Text(title!, style: Tulisan.subheading(fontsize: 19)),
),
const Gap(8),
Align(
alignment: Alignment.centerLeft,
child: Text(content!, style: Tulisan.customText(fontsize: 14)),
),
],
);
}
Widget _buildButtons() {
if (buttonCount == 1 && button1 != null) {
return button1!;
} else if (buttonCount == 2 && button1 != null && button2 != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(child: button2!),
const Gap(16),
Expanded(child: button1!),
],
);
}
return const SizedBox.shrink();
}
Widget _buildCloseButton() {
return Positioned(
top: -15,
right: 5,
child: GestureDetector(
onTap: () => router.pop(),
child: CircleAvatar(
radius: 18,
backgroundColor: whiteColor,
child: Icon(Icons.close, color: blackNavyColor),
),
),
);
}
}

726
screen/trash_testview.dart Normal file
View File

@ -0,0 +1,726 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:provider/provider.dart';
import 'package:gap/gap.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/cart/presentation/viewmodel/trashcart_vmod.dart';
import 'package:rijig_mobile/globaldata/trash/trash_viewmodel.dart';
import 'package:rijig_mobile/widget/appbar.dart';
import 'package:rijig_mobile/widget/buttoncard.dart';
import 'package:rijig_mobile/widget/formfiled.dart';
import 'package:rijig_mobile/widget/showmodal.dart';
import 'package:rijig_mobile/widget/skeletonize.dart';
import 'package:rijig_mobile/widget/unhope_handler.dart';
import 'package:toastification/toastification.dart';
class TestRequestPickScreen extends StatefulWidget {
const TestRequestPickScreen({super.key});
@override
State<TestRequestPickScreen> createState() => _TestRequestPickScreenState();
}
class _TestRequestPickScreenState extends State<TestRequestPickScreen>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _slideAnimation;
bool _isInitialized = false;
// Track loading states per item
final Map<String, bool> _itemLoadingStates = {};
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_slideAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeData();
});
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
Future<void> _initializeData() async {
if (_isInitialized) return;
final trashViewModel = Provider.of<TrashViewModel>(context, listen: false);
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
await Future.wait([
trashViewModel.loadCategories(),
cartViewModel.loadCartItems(showLoading: false),
]);
if (mounted) {
_updateBottomSheetVisibility();
setState(() {
_isInitialized = true;
});
}
}
void _updateBottomSheetVisibility() {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
if (cartViewModel.isNotEmpty) {
_animationController.forward();
} else {
_animationController.reverse();
}
}
Future<void> _handleQuantityChange(
String categoryId,
double newQuantity,
) async {
// Set loading state
setState(() {
_itemLoadingStates[categoryId] = true;
});
try {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
if (newQuantity <= 0) {
await cartViewModel.deleteItem(categoryId, showUpdating: false);
} else {
await cartViewModel.addOrUpdateItem(
categoryId,
newQuantity.toInt(),
showUpdating: false,
);
}
if (mounted) {
_updateBottomSheetVisibility();
}
} finally {
// Clear loading state
if (mounted) {
setState(() {
_itemLoadingStates[categoryId] = false;
});
}
}
}
Future<void> _incrementQuantity(String categoryId) async {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
final currentAmount = cartViewModel.getItemAmount(categoryId);
await _handleQuantityChange(categoryId, (currentAmount + 2.5));
}
Future<void> _decrementQuantity(String categoryId) async {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
final currentAmount = cartViewModel.getItemAmount(categoryId);
final newAmount = (currentAmount - 2.5).clamp(0.0, double.infinity);
await _handleQuantityChange(categoryId, newAmount);
}
Future<void> _resetQuantity(String categoryId) async {
await _handleQuantityChange(categoryId, 0);
}
void _showQuantityDialog(String categoryId, String itemName) {
final cartViewModel = Provider.of<CartViewModel>(context, listen: false);
final currentAmount = cartViewModel.getItemAmount(categoryId);
TextEditingController controller = TextEditingController(
text: currentAmount.toString().replaceAll('.0', ''),
);
CustomModalDialog.showWidget(
customWidget: FormFieldOne(
controllers: controller,
hintText: 'masukkan berat dengan benar ya..',
isRequired: true,
textInputAction: TextInputAction.done,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onTap: () {},
onChanged: (value) {},
fontSize: 14.sp,
fontSizeField: 16.sp,
onFieldSubmitted: (value) {},
readOnly: false,
enabled: true,
),
context: context,
buttonCount: 2,
button1: CardButtonOne(
textButton: "Simpan",
onTap: () async {
double? newQuantity = double.tryParse(controller.text);
if (newQuantity != null && newQuantity >= 0) {
Navigator.pop(context);
await _handleQuantityChange(categoryId, newQuantity);
} else {
toastification.show(
type: ToastificationType.warning,
title: const Text("Masukkan angka yang valid"),
autoCloseDuration: const Duration(seconds: 3),
);
}
},
fontSized: 14.sp,
colorText: whiteColor,
color: primaryColor,
borderRadius: 10.sp,
horizontal: double.infinity,
vertical: 50,
loadingTrue: false,
usingRow: false,
),
button2: CardButtonOne(
textButton: "Batal",
onTap: () => router.pop(),
fontSized: 14.sp,
colorText: primaryColor,
color: Colors.transparent,
borderRadius: 10,
horizontal: double.infinity,
vertical: 50,
loadingTrue: false,
usingRow: false,
),
);
}
Widget _buildQuantityControls(String categoryId) {
final isItemLoading = _itemLoadingStates[categoryId] ?? false;
return Consumer<CartViewModel>(
builder: (context, cartViewModel, child) {
final amount = cartViewModel.getItemAmount(categoryId);
if (amount > 0) {
return Row(
children: [
_buildControlButton(
icon: Icons.remove,
color: Colors.red,
onTap:
isItemLoading ? null : () => _decrementQuantity(categoryId),
isLoading: isItemLoading,
),
const Gap(12),
_buildQuantityDisplay(
categoryId,
amount.toDouble(),
isItemLoading,
),
const Gap(12),
_buildControlButton(
icon: Icons.add,
color: primaryColor,
onTap:
isItemLoading ? null : () => _incrementQuantity(categoryId),
isLoading: isItemLoading,
),
],
);
} else {
return GestureDetector(
onTap: isItemLoading ? null : () => _incrementQuantity(categoryId),
child: Container(
padding: PaddingCustom().paddingHorizontalVertical(16, 8),
decoration: BoxDecoration(
color: isItemLoading ? Colors.grey : primaryColor,
borderRadius: BorderRadius.circular(8),
),
child:
isItemLoading
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(whiteColor),
),
)
: Text(
'Tambah',
style: TextStyle(
color: whiteColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
);
}
},
);
}
Widget _buildControlButton({
required IconData icon,
required Color color,
required VoidCallback? onTap,
bool isLoading = false,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color:
onTap != null
? color.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child:
isLoading
? Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
)
: Icon(
icon,
color: onTap != null ? color : Colors.grey,
size: 20,
),
),
);
}
Widget _buildQuantityDisplay(
String categoryId,
double quantity,
bool isLoading,
) {
final trashViewModel = Provider.of<TrashViewModel>(context, listen: false);
final category = trashViewModel.getCategoryById(categoryId);
return GestureDetector(
onTap:
isLoading
? null
: () => _showQuantityDialog(categoryId, category?.name ?? ''),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: isLoading ? Colors.orange.shade300 : Colors.grey.shade300,
),
borderRadius: BorderRadius.circular(8),
color: isLoading ? Colors.orange.shade50 : Colors.transparent,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
Padding(
padding: const EdgeInsets.only(right: 8),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: AlwaysStoppedAnimation<Color>(Colors.orange),
),
),
),
Text(
'${quantity.toString().replaceAll('.0', '')} kg',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isLoading ? Colors.orange.shade700 : Colors.black,
),
),
],
),
),
);
}
Widget _buildTrashItem(dynamic category) {
final String? baseUrl = dotenv.env["BASE_URL"];
return Consumer<CartViewModel>(
builder: (context, cartViewModel, child) {
final amount = cartViewModel.getItemAmount(category.id);
num price =
(category.price is String)
? int.tryParse(category.price) ?? 0
: category.price ?? 0;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: 12),
padding: PaddingCustom().paddingAll(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: BorderRadius.circular(12),
border:
amount > 0
? Border.all(
color: primaryColor.withValues(alpha: 0.3),
width: 1.5,
)
: null,
boxShadow: [
BoxShadow(
color:
amount > 0
? primaryColor.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
spreadRadius: amount > 0 ? 2 : 1,
blurRadius: amount > 0 ? 6 : 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
_buildCategoryIcon(baseUrl, category),
const Gap(16),
Expanded(
child: _buildCategoryInfo(category, price, amount.toDouble()),
),
_buildQuantityControls(category.id),
],
),
);
},
);
}
Widget _buildCategoryIcon(String? baseUrl, dynamic category) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(25),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Image.network(
'$baseUrl${category.icon}',
width: 50,
height: 50,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(Icons.category, color: Colors.grey, size: 24);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
strokeWidth: 2,
value:
loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
),
),
);
}
Widget _buildCategoryInfo(dynamic category, num price, double quantity) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
category.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
const Gap(4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Rp ${price.toString()}/kg',
style: TextStyle(
color: whiteColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
const Gap(4),
SizedBox(
height: 24,
child:
quantity > 0
? GestureDetector(
onTap: () => _resetQuantity(category.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: PaddingCustom().paddingAll(4),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.delete_outline,
color: Colors.red,
size: 16,
),
),
)
: const SizedBox(),
),
],
);
}
Widget _buildBottomSheet() {
return Consumer<CartViewModel>(
builder: (context, cartViewModel, child) {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: PaddingCustom().paddingAll(16),
decoration: BoxDecoration(
color: whiteColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.2),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
Expanded(child: _buildOrderSummary(cartViewModel)),
const Gap(16),
_buildContinueButton(cartViewModel),
],
),
);
},
);
}
Widget _buildOrderSummary(CartViewModel cartViewModel) {
final meetsMinimumWeight = cartViewModel.totalItems >= 3;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Column(
key: ValueKey(cartViewModel.totalItems),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${cartViewModel.cartItems.length} jenis ${cartViewModel.totalItems.toString()} kg',
style: TextStyle(fontSize: 14, color: Colors.grey.shade700),
),
const Gap(10),
Row(
children: [
Text(
'Est. ',
style: TextStyle(fontSize: 14, color: Colors.grey.shade700),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(8),
),
child: Text(
cartViewModel.formattedTotalPrice,
style: TextStyle(
color: whiteColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
if (!meetsMinimumWeight) ...[
const Gap(10),
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: meetsMinimumWeight ? 0.0 : 1.0,
child: const Text(
'Minimum total berat 3kg',
style: TextStyle(fontSize: 12, color: Colors.red),
),
),
],
],
),
);
}
Widget _buildContinueButton(CartViewModel cartViewModel) {
final meetsMinimumWeight = cartViewModel.totalItems >= 3;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: ElevatedButton(
onPressed:
meetsMinimumWeight ? () => _handleContinue(cartViewModel) : null,
style: ElevatedButton.styleFrom(
backgroundColor: meetsMinimumWeight ? Colors.blue : Colors.grey,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: Text(
'Lanjut',
style: TextStyle(
color: whiteColor,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
);
}
void _handleContinue(CartViewModel cartViewModel) {
final trashViewModel = Provider.of<TrashViewModel>(context, listen: false);
Map<String, dynamic> orderData = {
'selectedItems':
cartViewModel.cartItems.map((cartItem) {
trashViewModel.getCategoryById(cartItem.trashId);
return {
'id': cartItem.trashId,
'name': cartItem.trashName,
'quantity': cartItem.amount.toDouble(),
'pricePerKg': cartItem.trashPrice,
'totalPrice': cartItem.subtotalEstimatedPrice.round(),
};
}).toList(),
'totalWeight': cartViewModel.totalItems.toDouble(),
'totalPrice': cartViewModel.totalPrice.round(),
'totalItems': cartViewModel.cartItems.length,
};
context.push('/ordersumary', extra: orderData);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: whiteColor,
appBar: const CustomAppBar(judul: "Pilih Sampah"),
body: Consumer2<TrashViewModel, CartViewModel>(
builder: (context, trashViewModel, cartViewModel, child) {
if (trashViewModel.isLoading) {
return ListView.builder(
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => SkeletonCard(),
);
}
if (trashViewModel.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const Gap(16),
const Text(
'Terjadi kesalahan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const Gap(8),
Padding(
padding: PaddingCustom().paddingHorizontal(32),
child: Text(
trashViewModel.errorMessage ?? 'Error tidak diketahui',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
),
const Gap(24),
ElevatedButton(
onPressed: () => _initializeData(),
child: const Text('Coba Lagi'),
),
],
),
);
}
if (!trashViewModel.hasData || trashViewModel.categories.isEmpty) {
return InfoStateWidget(type: InfoStateType.emptyData);
}
return Stack(
children: [
ListView.builder(
padding: PaddingCustom().paddingOnly(
top: 16,
right: 16,
bottom: cartViewModel.isNotEmpty ? 120 : 16,
left: 16,
),
itemCount: trashViewModel.categories.length,
itemBuilder: (context, index) {
final category = trashViewModel.categories[index];
return _buildTrashItem(category);
},
),
// Bottom Sheet with Animation
AnimatedBuilder(
animation: _slideAnimation,
builder: (context, child) {
return Positioned(
left: 0,
right: 0,
bottom:
cartViewModel.isNotEmpty
? (_slideAnimation.value - 1) * 200
: -200,
child:
cartViewModel.isNotEmpty
? _buildBottomSheet()
: const SizedBox.shrink(),
);
},
),
],
);
},
),
);
}
}