import 'package:flutter/material.dart'; import '../../../../app/di/injection.dart'; import '../../../../core/auth/session_store.dart'; import '../../../../core/logging/logger.dart'; import '../../../../data/network/api_client.dart'; import '../../../../data/storage/local_kv_store.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/utils/time_format.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../data/apis/points_api.dart'; import '../../data/models/ledger_item.dart'; class PointsLedgerScreen extends StatefulWidget { const PointsLedgerScreen({super.key}); @override State createState() => _PointsLedgerScreenState(); } class _PointsLedgerScreenState extends State { final Logger _logger = getLogger('features.points.ledger_screen'); late final ScrollController _scrollController; late final PointsApi _api; final List _items = []; String? _nextCursor; bool _hasMore = false; bool _isLoading = false; bool _isLoadingMore = false; Object? _error; @override void initState() { super.initState(); _scrollController = ScrollController()..addListener(_onScroll); final sessionStore = SessionStore(LocalKvStore()); final apiClient = ApiClient( baseUrl: appDependencies.backendUrl, tokenProvider: sessionStore.getToken, ); _api = PointsApi(apiClient.rawDio); _loadInitial(); } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); super.dispose(); } void _onScroll() { if (!_scrollController.hasClients) return; final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 240) { _loadMore(); } } Future _loadInitial() async { if (_isLoading) return; setState(() { _isLoading = true; _error = null; }); try { final result = await _api.getLedger(); if (mounted) { setState(() { _items.clear(); _items.addAll(result.items); _nextCursor = result.nextCursor; _hasMore = result.hasMore; _isLoading = false; }); } } catch (e, stackTrace) { _logger.error( message: 'Failed to load ledger', error: e, stackTrace: stackTrace, ); if (mounted) { setState(() { _error = e; _isLoading = false; }); } } } Future _loadMore() async { if (_isLoadingMore || !_hasMore || _nextCursor == null) return; setState(() { _isLoadingMore = true; }); try { final result = await _api.getLedger(cursor: _nextCursor); if (mounted) { setState(() { _items.addAll(result.items); _nextCursor = result.nextCursor; _hasMore = result.hasMore; _isLoadingMore = false; }); } } catch (e, stackTrace) { _logger.error( message: 'Failed to load more ledger items', error: e, stackTrace: stackTrace, ); if (mounted) { setState(() { _isLoadingMore = false; }); } } } String _getDisplayText(AppLocalizations l10n, String changeType) { return switch (changeType) { 'register' => l10n.pointsLedgerTypeRegister, 'purchase' => l10n.pointsLedgerTypePurchase, 'consume' => l10n.pointsLedgerTypeConsume, 'adjust' => l10n.pointsLedgerTypeAdjust, 'refund' => l10n.pointsLedgerTypeRefund, _ => changeType, }; } IconData _getIcon(String changeType) { return switch (changeType) { 'register' => Icons.card_giftcard_rounded, 'purchase' => Icons.shopping_bag_rounded, 'consume' => Icons.chat_bubble_outline_rounded, 'adjust' => Icons.tune_rounded, 'refund' => Icons.replay_rounded, _ => Icons.swap_vert_rounded, }; } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; final palette = Theme.of(context).extension()!; return Scaffold( backgroundColor: colors.surfaceContainerLow, appBar: AppBar( title: Text(l10n.pointsLedgerTitle), centerTitle: true, backgroundColor: colors.surfaceContainerLow, surfaceTintColor: colors.surfaceContainerLow, ), body: _buildBody(l10n, colors, palette), ); } Widget _buildBody( AppLocalizations l10n, ColorScheme colors, AppColorPalette palette, ) { if (_isLoading) { return const Center(child: AppLoadingIndicator()); } if (_error != null && _items.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: colors.error), const SizedBox(height: AppSpacing.md), Text( l10n.errorRequestGeneric, style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: AppSpacing.lg), FilledButton(onPressed: _loadInitial, child: Text(l10n.retry)), ], ), ); } if (_items.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.receipt_long_outlined, size: 64, color: colors.onSurfaceVariant.withValues(alpha: 0.5), ), const SizedBox(height: AppSpacing.md), Text( l10n.pointsLedgerEmpty, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), ), ], ), ); } return ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(AppSpacing.lg), itemCount: _items.length + (_hasMore ? 1 : 0), itemBuilder: (context, index) { if (index == _items.length) { if (_isLoadingMore) { return const Padding( padding: EdgeInsets.all(AppSpacing.lg), child: Center(child: AppLoadingIndicator()), ); } return const SizedBox.shrink(); } final item = _items[index]; final isIncome = item.direction == 1; final amountText = isIncome ? '+${item.amount}' : '-${item.amount}'; return _LedgerItemCard( icon: _getIcon(item.changeType), displayText: _getDisplayText(l10n, item.changeType), amountText: amountText, isIncome: isIncome, balanceAfter: item.balanceAfter, balanceText: l10n.pointsLedgerBalance(item.balanceAfter), dateTime: formatCompactLocalDateTime(item.createdAt), colors: colors, palette: palette, ); }, ); } } class _LedgerItemCard extends StatelessWidget { const _LedgerItemCard({ required this.icon, required this.displayText, required this.amountText, required this.isIncome, required this.balanceAfter, required this.balanceText, required this.dateTime, required this.colors, required this.palette, }); final IconData icon; final String displayText; final String amountText; final bool isIncome; final int balanceAfter; final String balanceText; final String dateTime; final ColorScheme colors; final AppColorPalette palette; @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(bottom: AppSpacing.sm), padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(AppRadius.lg), border: Border.all(color: colors.outlineVariant), ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: isIncome ? palette.incomeGreenBg.withValues(alpha: 0.15) : colors.errorContainer.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(AppRadius.md), ), child: Icon( icon, size: 22, color: isIncome ? palette.incomeGreenText : colors.error, ), ), const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayText, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 2), Text( dateTime, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colors.onSurfaceVariant, ), ), ], ), ), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( amountText, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w700, color: isIncome ? palette.incomeGreenText : colors.error, ), ), const SizedBox(height: 2), Text( balanceText, style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), ), ], ), ], ), ); } }