update pesanan

This commit is contained in:
Efril
2026-05-14 14:55:49 +07:00
parent 9f2097a268
commit c4618bbc1b
7 changed files with 719 additions and 207 deletions
@@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shimmer/shimmer.dart';
import '../../../../application/order/order_loader/order_loader_bloc.dart';
import '../../../../application/outlet/outlet_list_loader/outlet_list_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../injection.dart';
@@ -23,10 +24,19 @@ class OrderPage extends StatefulWidget implements AutoRouteWrapper {
State<OrderPage> createState() => _OrderPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) =>
getIt<OrderLoaderBloc>()
..add(OrderLoaderEvent.fetched(isRefresh: true)),
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(
create: (_) =>
getIt<OrderLoaderBloc>()
..add(OrderLoaderEvent.fetched(isRefresh: true)),
),
BlocProvider(
create: (_) =>
getIt<OutletListLoaderBloc>()
..add(const OutletListLoaderEvent.fetched()),
),
],
child: this,
);
}
@@ -179,6 +189,14 @@ class _OrderPageState extends State<OrderPage> with TickerProviderStateMixin {
backgroundColor: AppColor.background,
body: MultiBlocListener(
listeners: [
BlocListener<OrderLoaderBloc, OrderLoaderState>(
listenWhen: (p, c) => p.outletId != c.outletId,
listener: (context, state) {
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.fetched(isRefresh: true),
);
},
),
BlocListener<OrderLoaderBloc, OrderLoaderState>(
listenWhen: (p, c) => p.status != c.status,
listener: (context, state) {
@@ -198,137 +216,151 @@ class _OrderPageState extends State<OrderPage> with TickerProviderStateMixin {
},
),
],
child: BlocBuilder<OrderLoaderBloc, OrderLoaderState>(
builder: (context, state) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
_scrollController.position.extentAfter == 0) {
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.fetched(),
);
return true;
}
child: BlocBuilder<OutletListLoaderBloc, OutletListLoaderState>(
builder: (context, outletListState) {
return BlocBuilder<OrderLoaderBloc, OrderLoaderState>(
builder: (context, state) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification &&
_scrollController.position.extentAfter == 0) {
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.fetched(),
);
return true;
}
return true;
},
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Custom App Bar with Hero Effect
SliverAppBar(
expandedHeight: 120,
floating: true,
pinned: true,
backgroundColor: AppColor.primary,
centerTitle: false,
flexibleSpace: CustomAppBar(
title: context.lang.orders,
isBack: false,
),
),
// Pinned Filter Section
SliverPersistentHeader(
pinned: true,
delegate: FilterHeaderDelegate(
backgroundColor: AppColor.background,
padding: EdgeInsets.fromLTRB(
AppValue.padding,
10,
AppValue.padding,
10,
),
startDate: state.dateFrom,
endDate: state.dateTo,
filterOptions: filterOptions,
selectedFilter: state.status,
onDateChanged: (startDate, endDate) {
if (startDate != null && endDate != null) {
log('Date changed');
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.rangeDateChanged(
startDate,
endDate,
),
);
}
},
onFilterChanged: (filter) {
final status = filter.toLowerCase();
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.statusChanged(status),
);
},
),
),
// Content
SliverPadding(
padding: EdgeInsets.all(AppValue.padding),
sliver: SliverList(
delegate: SliverChildListDelegate([
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
children: [
// Show filtered transaction count
if (state.status != 'all' && !state.isFetching)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Text(
'${state.orders.length} ${state.status.toLowerCase()} ${context.lang.orders}',
style: TextStyle(
color: AppColor.textSecondary,
fontSize: 14,
),
),
],
),
),
// Order List with Shimmer Loading
if (state.isFetching)
_buildShimmerList()
else if (state.orders.isEmpty)
EmptyWidget(
title: context.lang.order,
message: context.lang.no_order_with_status(
state.status.toLowerCase(),
),
)
else
ListView.builder(
itemCount: state.orders.length,
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
return OrderTile(
onTap: () => context.router.push(
OrderDetailRoute(
order: state.orders[index],
),
),
order: state.orders[index],
);
},
),
],
),
),
return true;
},
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Custom App Bar with Hero Effect
SliverAppBar(
expandedHeight: 120,
floating: true,
pinned: true,
backgroundColor: AppColor.primary,
centerTitle: false,
flexibleSpace: CustomAppBar(
title: context.lang.orders,
isBack: false,
),
]),
),
),
// Pinned Filter Section
SliverPersistentHeader(
pinned: true,
delegate: FilterHeaderDelegate(
backgroundColor: AppColor.background,
padding: EdgeInsets.fromLTRB(
AppValue.padding,
10,
AppValue.padding,
10,
),
startDate: state.dateFrom,
endDate: state.dateTo,
filterOptions: filterOptions,
selectedFilter: state.status,
selectedOutletId: state.outletId,
outletListState: outletListState,
onDateChanged: (startDate, endDate) {
if (startDate != null && endDate != null) {
log('Date changed');
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.rangeDateChanged(
startDate,
endDate,
),
);
}
},
onFilterChanged: (filter) {
final status = filter.toLowerCase();
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.statusChanged(status),
);
},
onOutletChanged: (outletId) {
context.read<OrderLoaderBloc>().add(
OrderLoaderEvent.outletChanged(outletId),
);
},
),
),
// Content
SliverPadding(
padding: EdgeInsets.all(AppValue.padding),
sliver: SliverList(
delegate: SliverChildListDelegate([
FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: Column(
children: [
// Show filtered transaction count
if (state.status != 'all' &&
!state.isFetching)
Padding(
padding: const EdgeInsets.only(
bottom: 16,
),
child: Row(
children: [
Text(
'${state.orders.length} ${state.status.toLowerCase()} ${context.lang.orders}',
style: TextStyle(
color: AppColor.textSecondary,
fontSize: 14,
),
),
],
),
),
// Order List with Shimmer Loading
if (state.isFetching)
_buildShimmerList()
else if (state.orders.isEmpty)
EmptyWidget(
title: context.lang.order,
message:
context.lang.no_order_with_status(
state.status.toLowerCase(),
),
)
else
ListView.builder(
itemCount: state.orders.length,
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemBuilder: (context, index) {
return OrderTile(
onTap: () => context.router.push(
OrderDetailRoute(
order: state.orders[index],
),
),
order: state.orders[index],
);
},
),
],
),
),
),
]),
),
),
],
),
],
),
);
},
);
},
),
@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import '../../../../../application/outlet/outlet_list_loader/outlet_list_loader_bloc.dart';
import '../../../../../common/extension/extension.dart';
import '../../../../../common/theme/theme.dart';
import '../../../../../domain/outlet/outlet.dart';
import '../../../../components/field/date_range_picker_field.dart';
import 'status_tile.dart';
@@ -11,8 +14,11 @@ class FilterHeaderDelegate extends SliverPersistentHeaderDelegate {
final DateTime? endDate;
final List<String> filterOptions;
final String selectedFilter;
final String? selectedOutletId;
final OutletListLoaderState outletListState;
final Function(DateTime?, DateTime?) onDateChanged;
final Function(String) onFilterChanged;
final Function(String?) onOutletChanged;
FilterHeaderDelegate({
required this.backgroundColor,
@@ -21,15 +27,18 @@ class FilterHeaderDelegate extends SliverPersistentHeaderDelegate {
required this.endDate,
required this.filterOptions,
required this.selectedFilter,
required this.selectedOutletId,
required this.outletListState,
required this.onDateChanged,
required this.onFilterChanged,
required this.onOutletChanged,
});
@override
double get minExtent => 130;
double get minExtent => 190;
@override
double get maxExtent => 130;
double get maxExtent => 190;
@override
Widget build(
@@ -37,51 +46,55 @@ class FilterHeaderDelegate extends SliverPersistentHeaderDelegate {
double shrinkOffset,
bool overlapsContent,
) {
final selectedOutlet = outletListState.outlets
.where((o) => o.id == selectedOutletId)
.firstOrNull;
return Container(
color: backgroundColor,
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: DateRangePickerField(
maxDate: DateTime.now(),
startDate: startDate,
endDate: endDate,
onChanged: (start, end) {
onDateChanged(start, end);
},
),
// Date range picker
DateRangePickerField(
maxDate: DateTime.now(),
startDate: startDate,
endDate: endDate,
onChanged: (start, end) => onDateChanged(start, end),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: filterOptions.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
return Padding(
padding: EdgeInsets.only(
right: index < filterOptions.length - 1 ? 8 : 0,
),
child: OrderStatusTile(
label: option == "All"
? context.lang.all
: option == "Completed"
? context.lang.completed
: context.lang.pending,
isSelected: option == selectedFilter,
onSelected: (isSelected) {
if (isSelected) {
onFilterChanged(option);
}
},
),
);
}).toList(),
),
const SizedBox(height: 8),
// Outlet selector + status chips
_OutletSelectorField(
selectedOutlet: selectedOutlet,
isLoading: outletListState.isFetching,
onTap: () => _showOutletSheet(context),
),
const SizedBox(height: 8),
// Status chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: filterOptions.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
return Padding(
padding: EdgeInsets.only(
right: index < filterOptions.length - 1 ? 8 : 0,
),
child: OrderStatusTile(
label: option == 'All'
? context.lang.all
: option == 'Completed'
? context.lang.completed
: context.lang.pending,
isSelected: option.toLowerCase() == selectedFilter,
onSelected: (isSelected) {
if (isSelected) onFilterChanged(option);
},
),
);
}).toList(),
),
),
],
@@ -89,12 +102,258 @@ class FilterHeaderDelegate extends SliverPersistentHeaderDelegate {
);
}
void _showOutletSheet(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _OutletBottomSheet(
outlets: outletListState.outlets,
selectedOutletId: selectedOutletId,
onSelected: (outletId) {
Navigator.pop(context);
onOutletChanged(outletId);
},
),
);
}
@override
bool shouldRebuild(covariant FilterHeaderDelegate oldDelegate) {
return oldDelegate.startDate != startDate ||
oldDelegate.endDate != endDate ||
oldDelegate.selectedFilter != selectedFilter ||
oldDelegate.selectedOutletId != selectedOutletId ||
oldDelegate.outletListState.outlets.length !=
outletListState.outlets.length ||
oldDelegate.outletListState.isFetching != outletListState.isFetching ||
oldDelegate.filterOptions.length != filterOptions.length ||
oldDelegate.backgroundColor != backgroundColor;
}
}
// Field outlet — ikut style DateRangePickerField
class _OutletSelectorField extends StatefulWidget {
final Outlet? selectedOutlet;
final bool isLoading;
final VoidCallback onTap;
const _OutletSelectorField({
required this.selectedOutlet,
required this.isLoading,
required this.onTap,
});
@override
State<_OutletSelectorField> createState() => _OutletSelectorFieldState();
}
class _OutletSelectorFieldState extends State<_OutletSelectorField> {
bool _isPressed = false;
@override
Widget build(BuildContext context) {
final hasValue = widget.selectedOutlet != null;
final label = widget.selectedOutlet?.name ?? 'Semua Outlet';
return GestureDetector(
onTap: widget.isLoading ? null : widget.onTap,
onTapDown: (_) => setState(() => _isPressed = true),
onTapUp: (_) => setState(() => _isPressed = false),
onTapCancel: () => setState(() => _isPressed = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: _isPressed ? AppColor.backgroundLight : AppColor.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _isPressed ? AppColor.primary : AppColor.border,
width: _isPressed ? 2 : 1,
),
boxShadow: _isPressed
? [
BoxShadow(
color: AppColor.primary.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: hasValue ? FontWeight.w500 : FontWeight.w400,
color: hasValue
? AppColor.textPrimary
: AppColor.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: widget.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColor.primary,
),
)
: const Icon(
Icons.store_rounded,
size: 20,
color: AppColor.primary,
),
),
],
),
),
);
}
}
// Bottom sheet pilih outlet
class _OutletBottomSheet extends StatelessWidget {
final List<Outlet> outlets;
final String? selectedOutletId;
final Function(String?) onSelected;
const _OutletBottomSheet({
required this.outlets,
required this.selectedOutletId,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Text(
'Pilih Outlet',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
_OutletItem(
label: 'Semua Outlet',
icon: Icons.store_rounded,
isSelected: selectedOutletId == null,
onTap: () => onSelected(null),
),
const Divider(height: 1),
...outlets.map(
(outlet) => Column(
children: [
_OutletItem(
label: outlet.name,
icon: Icons.storefront_rounded,
isSelected: selectedOutletId == outlet.id,
isActive: outlet.isActive,
onTap: () => onSelected(outlet.id),
),
if (outlet != outlets.last) const Divider(height: 1),
],
),
),
],
),
),
);
}
}
class _OutletItem extends StatelessWidget {
final String label;
final IconData icon;
final bool isSelected;
final bool? isActive;
final VoidCallback onTap;
const _OutletItem({
required this.label,
required this.icon,
required this.isSelected,
required this.onTap,
this.isActive,
});
@override
Widget build(BuildContext context) {
return ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isSelected
? AppColor.primary.withOpacity(0.1)
: AppColor.background,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
size: 18,
color: isSelected ? AppColor.primary : AppColor.textSecondary,
),
),
title: Text(
label,
style: AppStyle.md.copyWith(
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
color: isSelected ? AppColor.primary : AppColor.textPrimary,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isActive != null)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive! ? AppColor.success : AppColor.error,
),
),
if (isActive != null) const SizedBox(width: 8),
if (isSelected)
const Icon(
Icons.check_rounded,
color: AppColor.primary,
size: 20,
),
],
),
);
}
}