EnakPoint & EnakCoin wallet, PIN, outlet, order, community, reward

- Wallet EnakPoint & EnakCoin: beranda, riwayat, saldo kedaluwarsa, kode
  bayar, tukar EnakCoin, transfer
- PIN (buat, ganti, lupa), outlet, riwayat order, game spin
- Halaman community, webview, coming soon, dan ikon menu beranda baru
- Redesign Reward Saya: kartu tiket, filter chip, detail kode + QR
- Hapus customer point loader, menu, dan checkout lama
- Base URL ke enaklo-pos-api.altru.id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-01 01:43:22 +07:00
co-authored by Claude Opus 5.5
parent 5954510b93
commit f5e3075205
253 changed files with 47212 additions and 17437 deletions
@@ -0,0 +1,380 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import '../../../application/wallet/wallet_history_loader/wallet_history_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/wallet/wallet.dart';
import '../../../injection.dart';
import '../../components/button/button.dart';
import '../../components/toast/flushbar.dart';
import 'wallet_ui.dart';
import 'widgets/wallet_transaction_tile.dart';
/// Riwayat mutasi (§4.2).
@RoutePage()
class WalletHistoryPage extends StatefulWidget implements AutoRouteWrapper {
const WalletHistoryPage({
super.key,
this.initialCurrency = WalletCurrency.point,
});
final WalletCurrency initialCurrency;
@override
State<WalletHistoryPage> createState() => _WalletHistoryPageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) =>
getIt<WalletHistoryLoaderBloc>()
..add(WalletHistoryLoaderEvent.currencyChanged(initialCurrency)),
child: this,
);
}
class _WalletHistoryPageState extends State<WalletHistoryPage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_tabController = TabController(
length: WalletCurrency.values.length,
vsync: this,
initialIndex: WalletCurrency.values.indexOf(widget.initialCurrency),
);
_tabController.addListener(() {
if (_tabController.indexIsChanging) return;
context.read<WalletHistoryLoaderBloc>().add(
WalletHistoryLoaderEvent.currencyChanged(
WalletCurrency.values[_tabController.index],
),
);
});
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_tabController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) {
context.read<WalletHistoryLoaderBloc>().add(
const WalletHistoryLoaderEvent.loadedMore(),
);
}
}
Future<void> _openFilter(WalletHistoryLoaderState state) async {
final result = await showModalBottomSheet<_HistoryFilter>(
context: context,
isScrollControlled: true,
backgroundColor: AppColor.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _FilterSheet(
initial: _HistoryFilter(
types: state.types,
from: state.from,
to: state.to,
),
),
);
if (result == null || !mounted) return;
context.read<WalletHistoryLoaderBloc>().add(
WalletHistoryLoaderEvent.filterChanged(
types: result.types,
from: result.from,
to: result.to,
),
);
}
@override
Widget build(BuildContext context) {
return BlocConsumer<WalletHistoryLoaderBloc, WalletHistoryLoaderState>(
listenWhen: (p, c) =>
p.failureOption != c.failureOption && c.items.isNotEmpty,
listener: (context, state) => state.failureOption.fold(
() {},
(f) => AppFlushbar.showError(context, walletFailureMessage(f)),
),
builder: (context, state) {
return Scaffold(
backgroundColor: AppColor.background,
appBar: AppBar(
title: const Text('Riwayat'),
actions: [
IconButton(
onPressed: () => _openFilter(state),
icon: Badge(
isLabelVisible: state.hasFilter,
backgroundColor: AppColor.primary,
smallSize: 8,
child: const Icon(Icons.filter_list),
),
),
],
bottom: TabBar(
controller: _tabController,
tabs: WalletCurrency.values
.map((currency) => Tab(text: currency.label))
.toList(),
),
),
body: Column(
children: [
if (state.hasFilter) _buildActiveFilter(state),
Expanded(child: _buildList(state)),
],
),
);
},
);
}
Widget _buildActiveFilter(WalletHistoryLoaderState state) {
final parts = <String>[
if (state.types.isNotEmpty) '${state.types.length} tipe',
if (state.from != null || state.to != null)
'${state.from == null ? '…' : formatShortDate(state.from!)} – '
'${state.to == null ? '…' : formatShortDate(state.to!)}',
];
return Container(
color: AppColor.white,
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
child: Row(
children: [
Expanded(
child: Text(
'Filter: ${parts.join(' · ')}',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
),
TextButton(
onPressed: () => context.read<WalletHistoryLoaderBloc>().add(
const WalletHistoryLoaderEvent.filterChanged(types: []),
),
child: Text(
'Hapus filter',
style: AppStyle.sm.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Widget _buildList(WalletHistoryLoaderState state) {
if (state.isFetching && state.items.isEmpty) {
return const Center(
child: SpinKitFadingCircle(color: AppColor.primary, size: 36),
);
}
final failure = state.failureOption.toNullable();
if (failure != null && state.items.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(walletFailureMessage(failure), style: AppStyle.md),
const SizedBox(height: 16),
AppOutlineButton(
title: 'Coba lagi',
width: 160,
onPressed: () => context.read<WalletHistoryLoaderBloc>().add(
const WalletHistoryLoaderEvent.refreshed(),
),
),
],
),
);
}
return RefreshIndicator(
color: AppColor.primary,
onRefresh: () async {
final bloc = context.read<WalletHistoryLoaderBloc>();
bloc.add(const WalletHistoryLoaderEvent.refreshed());
await bloc.stream.firstWhere((s) => !s.isFetching);
},
child: state.items.isEmpty
? ListView(
children: [
const SizedBox(height: 120),
Text(
state.hasFilter
? 'Tidak ada transaksi yang cocok dengan filter'
: 'Belum ada transaksi',
textAlign: TextAlign.center,
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
)
: ListView.separated(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: state.items.length + (state.hasMore ? 1 : 0),
separatorBuilder: (_, __) =>
const Divider(height: 1, color: AppColor.borderLight),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SpinKitFadingCircle(
color: AppColor.primary,
size: 24,
),
),
);
}
return Container(
color: AppColor.white,
child: WalletTransactionTile(transaction: state.items[index]),
);
},
),
);
}
}
class _HistoryFilter {
const _HistoryFilter({required this.types, this.from, this.to});
final List<WalletTransactionType> types;
final DateTime? from;
final DateTime? to;
}
class _FilterSheet extends StatefulWidget {
const _FilterSheet({required this.initial});
final _HistoryFilter initial;
@override
State<_FilterSheet> createState() => _FilterSheetState();
}
class _FilterSheetState extends State<_FilterSheet> {
late final Set<WalletTransactionType> _types = {...widget.initial.types};
late DateTime? _from = widget.initial.from;
late DateTime? _to = widget.initial.to;
Future<void> _pickRange() async {
final now = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: now,
initialDateRange: _from != null && _to != null
? DateTimeRange(start: _from!, end: _to!)
: null,
);
if (picked == null) return;
setState(() {
_from = picked.start;
_to = picked.end;
});
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filter riwayat',
style: AppStyle.xl.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
Text(
'Tipe',
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: WalletTransactionType.filterable.map((type) {
final selected = _types.contains(type);
return FilterChip(
label: Text(type.label, style: AppStyle.sm),
selected: selected,
selectedColor: AppColor.primaryWithOpacity(0.15),
checkmarkColor: AppColor.primary,
onSelected: (value) => setState(
() => value ? _types.add(type) : _types.remove(type),
),
);
}).toList(),
),
const SizedBox(height: 16),
Text(
'Tanggal',
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _pickRange,
icon: const Icon(Icons.date_range),
label: Text(
_from == null
? 'Pilih rentang tanggal'
: '${formatShortDate(_from!)} – ${formatShortDate(_to!)}',
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: AppOutlineButton(
title: 'Reset',
onPressed: () => Navigator.of(
context,
).pop(const _HistoryFilter(types: [])),
),
),
const SizedBox(width: 12),
Expanded(
child: AppElevatedButton(
title: 'Terapkan',
onPressed: () => Navigator.of(context).pop(
_HistoryFilter(
types: WalletTransactionType.filterable
.where(_types.contains)
.toList(),
from: _from,
to: _to,
),
),
),
),
],
),
],
),
),
);
}
}