diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index a7446cc..49806cd 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -33,7 +33,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.statusCode >= 200) { return _parseUserFromResponse( response.body, fallbackUsername: username.trim(), @@ -73,7 +73,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.statusCode >= 200) { return _parseUserFromResponse( response.body, fallbackUsername: username, @@ -99,7 +99,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.statusCode >= 200) { return; } @@ -119,7 +119,7 @@ class ApiAuthRepository implements AuthRepository { headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, ); - if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.statusCode >= 200) { return _extractSuccessMessage( response.body, fallback: 'Password reset successfully', @@ -129,6 +129,35 @@ class ApiAuthRepository implements AuthRepository { throw Exception(_extractErrorMessage(response.body)); } + @override + Future changePassword({ + required String oldPassword, + required String newPassword, + required String confirmPassword, + }) async { + final uri = Uri.parse('$baseUrl/user/change-password'); + final requestBody = jsonEncode({ + 'oldPassword': oldPassword, + 'newPassword': newPassword, + 'confirmPassword': confirmPassword, + }); + + final response = await _client.post( + uri, + headers: {'Content-Type': 'application/json', 'x-api-key': apiSecret}, + body: requestBody, + ); + + if (response.statusCode >= 200) { + return _extractSuccessMessage( + response.body, + fallback: 'Password changed successfully', + ); + } + + throw Exception(_extractErrorMessage(response.body)); + } + @override Future logout({required String refreshToken}) async { final uri = Uri.parse('$baseUrl/auth/logout'); @@ -140,7 +169,7 @@ class ApiAuthRepository implements AuthRepository { body: requestBody, ); - if (response.statusCode >= 200 && response.statusCode < 300) { + if (response.statusCode >= 200) { return; } diff --git a/lib/data/repositories/mock_auth_repository.dart b/lib/data/repositories/mock_auth_repository.dart index f649e3f..ba97db1 100644 --- a/lib/data/repositories/mock_auth_repository.dart +++ b/lib/data/repositories/mock_auth_repository.dart @@ -71,6 +71,24 @@ class MockAuthRepository implements AuthRepository { return 'Password reset successfully'; } + @override + Future changePassword({ + required String oldPassword, + required String newPassword, + required String confirmPassword, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + + if (oldPassword.isEmpty || newPassword.isEmpty || confirmPassword.isEmpty) { + throw Exception('All fields are required'); + } + if (newPassword != confirmPassword) { + throw Exception('New passwords do not match'); + } + + return 'Password changed successfully'; + } + @override Future logout({required String refreshToken}) async { await Future.delayed(const Duration(milliseconds: 250)); diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart index f80424c..637a587 100644 --- a/lib/domain/repositories/auth_repository.dart +++ b/lib/domain/repositories/auth_repository.dart @@ -16,5 +16,11 @@ abstract class AuthRepository { Future resetCashierPassword({required String username}); + Future changePassword({ + required String oldPassword, + required String newPassword, + required String confirmPassword, + }); + Future logout({required String refreshToken}); } diff --git a/lib/presentation/change_password/change_password_form_providers.dart b/lib/presentation/change_password/change_password_form_providers.dart new file mode 100644 index 0000000..e8d52d6 --- /dev/null +++ b/lib/presentation/change_password/change_password_form_providers.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final changePasswordFormKeyProvider = + Provider.autoDispose>((ref) { + return GlobalKey(); + }); + +final changeOldPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final changeNewPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final changeConfirmPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final changeOldPasswordFocusNodeProvider = Provider.autoDispose(( + ref, +) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final changeNewPasswordFocusNodeProvider = Provider.autoDispose(( + ref, +) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final changeConfirmPasswordFocusNodeProvider = Provider.autoDispose(( + ref, +) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final changeOldPasswordObscureProvider = StateProvider.autoDispose( + (ref) => true, +); + +final changeNewPasswordObscureProvider = StateProvider.autoDispose( + (ref) => true, +); + +final changeConfirmPasswordObscureProvider = StateProvider.autoDispose( + (ref) => true, +); + +final changePasswordAttemptedSubmitProvider = StateProvider.autoDispose( + (ref) => false, +); diff --git a/lib/presentation/change_password/change_password_page.dart b/lib/presentation/change_password/change_password_page.dart new file mode 100644 index 0000000..e573e05 --- /dev/null +++ b/lib/presentation/change_password/change_password_page.dart @@ -0,0 +1,372 @@ +import 'package:e_receipt_mobile/presentation/change_password/change_password_form_providers.dart'; +import 'package:e_receipt_mobile/presentation/change_password/change_password_view_model.dart'; +import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ChangePasswordPage extends ConsumerWidget { + const ChangePasswordPage({super.key}); + + void _submit({ + required BuildContext context, + required WidgetRef ref, + required GlobalKey formKey, + required TextEditingController oldPasswordController, + required TextEditingController newPasswordController, + required TextEditingController confirmPasswordController, + required bool isLoading, + }) { + if (isLoading) { + return; + } + + ref.read(changePasswordViewModelProvider.notifier).clearMessages(); + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(changePasswordAttemptedSubmitProvider.notifier).state = true; + + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + ref + .read(changePasswordViewModelProvider.notifier) + .changePassword( + oldPassword: oldPasswordController.text, + newPassword: newPasswordController.text, + confirmPassword: confirmPasswordController.text, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(changePasswordViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + + ref.listen(changePasswordViewModelProvider, (previous, next) { + final error = next.errorMessage; + if (error != null && error != previous?.errorMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), behavior: SnackBarBehavior.floating), + ); + } + + final success = next.successMessage; + if (success != null && success != previous?.successMessage) { + Navigator.of(context).pop(success); + } + }); + + final formKey = ref.watch(changePasswordFormKeyProvider); + final oldPasswordController = ref.watch( + changeOldPasswordControllerProvider, + ); + final newPasswordController = ref.watch( + changeNewPasswordControllerProvider, + ); + final confirmPasswordController = ref.watch( + changeConfirmPasswordControllerProvider, + ); + final oldPasswordFocusNode = ref.watch(changeOldPasswordFocusNodeProvider); + final newPasswordFocusNode = ref.watch(changeNewPasswordFocusNodeProvider); + final confirmPasswordFocusNode = ref.watch( + changeConfirmPasswordFocusNodeProvider, + ); + final obscureOld = ref.watch(changeOldPasswordObscureProvider); + final obscureNew = ref.watch(changeNewPasswordObscureProvider); + final obscureConfirm = ref.watch(changeConfirmPasswordObscureProvider); + final attemptedSubmit = ref.watch(changePasswordAttemptedSubmitProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Change password')), + body: AnnotatedRegion( + value: Theme.of(context).brightness == Brightness.dark + ? SystemUiOverlayStyle.light + : SystemUiOverlayStyle.dark, + child: LoginBackground( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Card( + elevation: 0, + color: colorScheme.surface.withValues( + alpha: Theme.of(context).brightness == Brightness.dark + ? 0.75 + : 0.92, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide( + color: colorScheme.outlineVariant.withValues( + alpha: 0.35, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: Form( + key: formKey, + autovalidateMode: attemptedSubmit + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary, + colorScheme.tertiary, + ], + ), + ), + child: const Icon( + Icons.password_outlined, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Change your password', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + 'Use your current password to set a new permanent one.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + _PasswordField( + controller: oldPasswordController, + focusNode: oldPasswordFocusNode, + enabled: !state.isLoading, + obscureText: obscureOld, + labelText: 'Current password', + hintText: 'Enter your current password', + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => FocusScope.of( + context, + ).requestFocus(newPasswordFocusNode), + onToggleVisibility: () => + ref + .read( + changeOldPasswordObscureProvider + .notifier, + ) + .state = + !obscureOld, + validator: (value) { + if ((value ?? '').isEmpty) { + return 'Current password is required'; + } + return null; + }, + ), + const SizedBox(height: 12), + _PasswordField( + controller: newPasswordController, + focusNode: newPasswordFocusNode, + enabled: !state.isLoading, + obscureText: obscureNew, + labelText: 'New password', + hintText: 'Enter your new password', + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => FocusScope.of( + context, + ).requestFocus(confirmPasswordFocusNode), + onToggleVisibility: () => + ref + .read( + changeNewPasswordObscureProvider + .notifier, + ) + .state = + !obscureNew, + validator: (value) { + if ((value ?? '').isEmpty) { + return 'New password is required'; + } + return null; + }, + ), + const SizedBox(height: 12), + _PasswordField( + controller: confirmPasswordController, + focusNode: confirmPasswordFocusNode, + enabled: !state.isLoading, + obscureText: obscureConfirm, + labelText: 'Confirm new password', + hintText: 'Re-enter your new password', + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit( + context: context, + ref: ref, + formKey: formKey, + oldPasswordController: oldPasswordController, + newPasswordController: newPasswordController, + confirmPasswordController: + confirmPasswordController, + isLoading: state.isLoading, + ), + onToggleVisibility: () => + ref + .read( + changeConfirmPasswordObscureProvider + .notifier, + ) + .state = + !obscureConfirm, + validator: (value) { + if ((value ?? '').isEmpty) { + return 'Confirm password is required'; + } + return null; + }, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: state.isLoading + ? null + : () => _submit( + context: context, + ref: ref, + formKey: formKey, + oldPasswordController: + oldPasswordController, + newPasswordController: + newPasswordController, + confirmPasswordController: + confirmPasswordController, + isLoading: state.isLoading, + ), + icon: state.isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.check), + label: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + child: Text( + state.isLoading + ? 'Updating...' + : 'Change password', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.focusNode, + required this.enabled, + required this.obscureText, + required this.labelText, + required this.hintText, + required this.textInputAction, + required this.onFieldSubmitted, + required this.onToggleVisibility, + required this.validator, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final bool enabled; + final bool obscureText; + final String labelText; + final String hintText; + final TextInputAction textInputAction; + final ValueChanged onFieldSubmitted; + final VoidCallback onToggleVisibility; + final FormFieldValidator validator; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return TextFormField( + controller: controller, + focusNode: focusNode, + enabled: enabled, + textInputAction: textInputAction, + obscureText: obscureText, + onFieldSubmitted: onFieldSubmitted, + validator: validator, + decoration: InputDecoration( + labelText: labelText, + hintText: hintText, + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues( + alpha: Theme.of(context).brightness == Brightness.dark ? 0.55 : 0.9, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(18)), + suffixIcon: IconButton( + tooltip: obscureText ? 'Show password' : 'Hide password', + onPressed: enabled ? onToggleVisibility : null, + icon: Icon( + obscureText + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/change_password/change_password_state.dart b/lib/presentation/change_password/change_password_state.dart new file mode 100644 index 0000000..c37f20e --- /dev/null +++ b/lib/presentation/change_password/change_password_state.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class ChangePasswordState { + const ChangePasswordState({ + this.isLoading = false, + this.errorMessage, + this.successMessage, + }); + + final bool isLoading; + final String? errorMessage; + final String? successMessage; + + ChangePasswordState copyWith({ + bool? isLoading, + String? errorMessage, + String? successMessage, + bool clearError = false, + bool clearSuccess = false, + }) { + return ChangePasswordState( + isLoading: isLoading ?? this.isLoading, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + successMessage: clearSuccess + ? null + : successMessage ?? this.successMessage, + ); + } +} diff --git a/lib/presentation/change_password/change_password_view_model.dart b/lib/presentation/change_password/change_password_view_model.dart new file mode 100644 index 0000000..f69a100 --- /dev/null +++ b/lib/presentation/change_password/change_password_view_model.dart @@ -0,0 +1,60 @@ +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/presentation/change_password/change_password_state.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final changePasswordViewModelProvider = + StateNotifierProvider.autoDispose< + ChangePasswordViewModel, + ChangePasswordState + >((ref) { + return ChangePasswordViewModel( + ref.watch(authenticatedAuthRepositoryProvider), + ); + }); + +class ChangePasswordViewModel extends StateNotifier { + ChangePasswordViewModel(this._authRepository) + : super(const ChangePasswordState()); + + final AuthRepository _authRepository; + + Future changePassword({ + required String oldPassword, + required String newPassword, + required String confirmPassword, + }) async { + if (oldPassword.isEmpty || newPassword.isEmpty || confirmPassword.isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'All fields are required', + clearSuccess: true, + ); + return; + } + + state = state.copyWith( + isLoading: true, + clearError: true, + clearSuccess: true, + ); + + try { + final responseMessage = await _authRepository.changePassword( + oldPassword: oldPassword, + newPassword: newPassword, + confirmPassword: confirmPassword, + ); + state = state.copyWith(isLoading: false, successMessage: responseMessage); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + void clearMessages() { + state = state.copyWith(clearError: true, clearSuccess: true); + } +} diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index ed33d57..fb879e1 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,6 +1,7 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/presentation/admin_reset_password/admin_reset_password_page.dart'; import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; +import 'package:e_receipt_mobile/presentation/change_password/change_password_page.dart'; import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; import 'package:e_receipt_mobile/presentation/home/merchant_paging_view_model.dart'; import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.dart'; @@ -38,6 +39,22 @@ class HomeScreen extends ConsumerWidget { ), drawer: HomeDrawer( user: user, + onChangePassword: () async { + Navigator.of(context).pop(); + final successMessage = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ChangePasswordPage(), + ), + ); + if (successMessage != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(successMessage), + behavior: SnackBarBehavior.floating, + ), + ); + } + }, onResetCashierPassword: user.isAdmin ? () { Navigator.of(context).pop(); diff --git a/lib/presentation/home/widgets/home_drawer.dart b/lib/presentation/home/widgets/home_drawer.dart index aea770b..a7296e9 100644 --- a/lib/presentation/home/widgets/home_drawer.dart +++ b/lib/presentation/home/widgets/home_drawer.dart @@ -6,12 +6,14 @@ class HomeDrawer extends StatelessWidget { const HomeDrawer({ required this.user, required this.onLogout, + this.onChangePassword, this.onResetCashierPassword, super.key, }); final LoginUser user; final VoidCallback onLogout; + final VoidCallback? onChangePassword; final VoidCallback? onResetCashierPassword; @override @@ -23,9 +25,17 @@ class HomeDrawer extends StatelessWidget { child: Column( children: [ _DrawerHeader(user: user), + Padding( + padding: const EdgeInsets.fromLTRB(8, 12, 8, 0), + child: _DrawerItem( + icon: Icons.password_outlined, + label: 'Change password', + onTap: onChangePassword ?? () {}, + ), + ), if (user.isAdmin) Padding( - padding: const EdgeInsets.fromLTRB(8, 12, 8, 0), + padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), child: _DrawerItem( icon: Icons.lock_reset_outlined, label: 'Reset cashier password', diff --git a/lib/presentation/terminal/terminal_next_screen.dart b/lib/presentation/terminal/terminal_next_screen.dart index 329e022..9aa9989 100644 --- a/lib/presentation/terminal/terminal_next_screen.dart +++ b/lib/presentation/terminal/terminal_next_screen.dart @@ -1,756 +1,777 @@ -import 'package:e_receipt_mobile/domain/entities/terminal.dart'; -import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_view_model.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_receipt_screen.dart'; -import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class TerminalNextScreen extends ConsumerStatefulWidget { - const TerminalNextScreen({ - required this.merchantName, - required this.terminal, - super.key, - }); - - final String merchantName; - final Terminal terminal; - - @override - ConsumerState createState() => _TerminalNextScreenState(); -} - -class _TerminalNextScreenState extends ConsumerState { - static const String _selectedRange = '4m'; - - @override - Widget build(BuildContext context) { - final user = ref.watch(sessionControllerProvider); - final isCashier = (user?.role ?? '').trim().toLowerCase() == 'cashier'; - final logoutState = ref.watch(logoutViewModelProvider); - final serial = widget.terminal.serial?.trim() ?? ''; - final hasSerial = serial.isNotEmpty; - final query = TransactionQuery(serial: serial, range: _selectedRange); - final pagingState = hasSerial - ? ref.watch(transactionPagingViewModelProvider(query)) - : null; - final pagingViewModel = hasSerial - ? ref.read(transactionPagingViewModelProvider(query).notifier) - : null; - final colorScheme = Theme.of(context).colorScheme; - final terminalName = - _sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-'; - - return Scaffold( - appBar: AppBar( - title: Text(terminalName), - actions: [ - if (isCashier) - IconButton( - tooltip: 'Account', - icon: const Icon(Icons.more_vert), - onPressed: logoutState.isLoading - ? null - : () => _showCashierMenu( - context, - username: (user?.username ?? '').trim(), - ), - ), - ], - ), - body: SafeArea( - child: RefreshIndicator.adaptive( - onRefresh: () async { - if (!hasSerial || pagingViewModel == null) { - return; - } - await pagingViewModel.refresh(); - }, - child: NotificationListener( - onNotification: (notification) { - if (!hasSerial || - pagingState == null || - pagingViewModel == null || - !pagingState.hasMore || - pagingState.isLoadingMore) { - return false; - } - if (notification.metrics.axis != Axis.vertical) { - return false; - } - if (notification.metrics.maxScrollExtent <= 0) { - return false; - } - if (notification.metrics.pixels >= - notification.metrics.maxScrollExtent - 240) { - pagingViewModel.loadMore(); - } - return false; - }, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), - child: Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(22), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.55), - ), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: colorScheme.primary.withOpacity( - 0.14, - ), - ), - child: Icon( - Icons.point_of_sale_outlined, - color: colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - widget.merchantName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - Text( - terminalName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: - colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - _InfoRow( - label: 'Serial', - value: widget.terminal.serial, - ), - _InfoRow( - label: 'Address', - value: _sanitizeMultiline( - widget.terminal.address, - ), - ), - ], - ), - ), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - 'Transactions (4M)', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - ), - ), - if (!hasSerial) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.info_outline, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Serial is required to load transactions', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - 'This terminal does not have a serial number.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ) - else - ..._buildPagingSlivers( - context: context, - colorScheme: colorScheme, - pagingState: pagingState!, - pagingViewModel: pagingViewModel!, - ), - ], - ), - ), - ), - ), - ); - } - - Future _showCashierMenu( - BuildContext context, { - required String username, - }) async { - HapticFeedback.selectionClick(); - - final action = await showModalBottomSheet<_CashierMenuAction>( - context: context, - showDragHandle: true, - isScrollControlled: true, - useSafeArea: true, - builder: (context) { - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final displayName = username.isEmpty ? 'Cashier' : username; - final initial = displayName.trim().isEmpty - ? '?' - : displayName.trim().characters.first.toUpperCase(); - - return Padding( - padding: EdgeInsets.fromLTRB(8, 0, 8, 16 + bottomInset), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: CircleAvatar(child: Text(initial)), - title: Text(displayName), - subtitle: const Text('Cashier'), - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.logout, color: Colors.redAccent), - title: const Text('Logout'), - onTap: () => Navigator.of(context).pop(_CashierMenuAction.logout), - ), - ], - ), - ); - }, - ); - - if (!mounted || action == null) { - return; - } - - switch (action) { - case _CashierMenuAction.logout: - await _logout(context); - } - } - - Future _logout(BuildContext context) async { - final shouldLogout = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure you want to logout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - FilledButton.tonal( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Logout'), - ), - ], - ); - }, - ); - - if (shouldLogout != true || !context.mounted) { - return; - } - - HapticFeedback.mediumImpact(); - Navigator.of(context).popUntil((route) => route.isFirst); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Signed out'), - behavior: SnackBarBehavior.floating, - ), - ); - - await ref.read(logoutViewModelProvider.notifier).logout(); - } - - List _buildPagingSlivers({ - required BuildContext context, - required ColorScheme colorScheme, - required TransactionPagingState pagingState, - required TransactionPagingViewModel pagingViewModel, - }) { - if (pagingState.isLoading && pagingState.items.isEmpty) { - return const [ - SliverFillRemaining(child: Center(child: CircularProgressIndicator())), - ]; - } - - if (pagingState.errorMessage != null && pagingState.items.isEmpty) { - return [ - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.wifi_off_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'Failed to load transactions', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - pagingState.errorMessage!, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: pagingViewModel.refresh, - child: const Text('Try again'), - ), - ], - ), - ), - ), - ), - ]; - } - - if (pagingState.items.isEmpty) { - return [ - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.receipt_long_outlined, - size: 56, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 12), - Text( - 'No transactions found', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - 'Pull down to refresh.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - ]; - } - - final bottomInset = MediaQuery.viewPaddingOf(context).bottom; - final items = pagingState.items; - final hasFooter = pagingState.hasMore; - - return [ - SliverPadding( - padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset + 16), - sliver: SliverList.separated( - itemCount: items.length + (hasFooter ? 1 : 0), - separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) { - if (index >= items.length) { - return _TransactionPaginationFooter( - isLoadingMore: pagingState.isLoadingMore, - onLoadMore: pagingViewModel.loadMore, - ); - } - - final item = items[index]; - final raw = item.raw ?? const {}; - final amount = - _first(raw, const ['DE4']) ?? item.amount?.toString() ?? '-'; - final currency = _first(raw, const ['DE49']) ?? 'MMK'; - final status = _statusLabel( - _first(raw, const ['DE39']) ?? - item.status, - ); - final type = _first(raw, const ['DE3']) ?? item.type ?? '-'; - final rrn = _first(raw, const ['DE37']) ?? item.rrn ?? '-'; - final createdAt = - _first(raw, const ['CREATED_AT', 'de7_date']) ?? item.createdAt; - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: BorderSide( - color: colorScheme.outlineVariant.withOpacity(0.55), - ), - ), - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TransactionReceiptScreen( - merchantName: widget.merchantName, - terminal: widget.terminal, - transaction: item, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - child: Row( - children: [ - Container( - height: 44, - width: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: colorScheme.primary.withOpacity(0.14), - ), - child: Icon( - Icons.receipt_long_outlined, - color: colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - '$amount $currency', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(fontWeight: FontWeight.w800), - ), - ), - const SizedBox(width: 8), - _StatusChip(status: status), - ], - ), - const SizedBox(height: 4), - Text( - 'Type: $type • RRN: $rrn', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if ((createdAt ?? '').trim().isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - createdAt!.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - ), - ), - ); - }, - ), - ), - if (pagingState.errorMessage != null && pagingState.items.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 8, - 16, - MediaQuery.viewPaddingOf(context).bottom + 8, - ), - child: Material( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: colorScheme.onErrorContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - pagingState.errorMessage!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onErrorContainer, - ), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: pagingViewModel.loadMore, - child: const Text('Retry'), - ), - ], - ), - ), - ), - ), - ), - ]; - } - - String? _first(Map raw, List keys) { - for (final key in keys) { - final value = raw[key]; - if (value != null) { - final text = value.toString().trim(); - if (text.isNotEmpty) { - return text; - } - } - } - return null; - } - - String _statusLabel(String? status) { - final normalized = status?.trim().toUpperCase(); - if (normalized == 'A') { - return 'SUCCESS'; - } - if (normalized == 'E' ) { - return 'FAILED'; - } - return 'SUCCESS'; - - } - - String? _sanitizeMultiline(String? value) { - if (value == null) { - return null; - } - final cleaned = value - .replaceAll('\r', ' ') - .replaceAll('\n', ' ') - .replaceAll(r'\n', ' ') - .replaceAll(r'/n', ' ') - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - return cleaned.isEmpty ? null : cleaned; - } -} - -class _TransactionPaginationFooter extends StatelessWidget { - const _TransactionPaginationFooter({ - required this.isLoadingMore, - required this.onLoadMore, - }); - - final bool isLoadingMore; - final VoidCallback onLoadMore; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - return Card( - elevation: 0, - color: colorScheme.surfaceContainerLow, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: Text( - isLoadingMore ? 'Loading more...' : 'More results available', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isLoadingMore) - const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - FilledButton.tonal( - onPressed: onLoadMore, - child: const Text('Load more'), - ), - ], - ), - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - const _InfoRow({required this.label, required this.value}); - - final String label; - final String? value; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final text = (value ?? '').trim().isEmpty ? '-' : value!.trim(); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - text, - textAlign: TextAlign.right, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - -class _StatusChip extends StatelessWidget { - const _StatusChip({required this.status}); - - final String status; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final normalized = status.toUpperCase(); - final isSuccess = normalized == 'SUCCESS'; - - final background = isSuccess - ? Colors.green.withOpacity(0.12) - : colorScheme.errorContainer.withOpacity(0.9); - final foreground = isSuccess - ? Colors.green.shade700 - : colorScheme.onErrorContainer; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: foreground.withOpacity(0.25)), - ), - child: Text( - normalized.isEmpty ? '-' : normalized, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w800, - color: foreground, - ), - ), - ); - } -} - -enum _CashierMenuAction { logout } +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_view_model.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/change_password/change_password_page.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_view_model.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_receipt_screen.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TerminalNextScreen extends ConsumerStatefulWidget { + const TerminalNextScreen({ + required this.merchantName, + required this.terminal, + super.key, + }); + + final String merchantName; + final Terminal terminal; + + @override + ConsumerState createState() => _TerminalNextScreenState(); +} + +class _TerminalNextScreenState extends ConsumerState { + static const String _selectedRange = '4m'; + + @override + Widget build(BuildContext context) { + final user = ref.watch(sessionControllerProvider); + final isCashier = (user?.role ?? '').trim().toLowerCase() == 'cashier'; + final logoutState = ref.watch(logoutViewModelProvider); + final serial = widget.terminal.serial?.trim() ?? ''; + final hasSerial = serial.isNotEmpty; + final query = TransactionQuery(serial: serial, range: _selectedRange); + final pagingState = hasSerial + ? ref.watch(transactionPagingViewModelProvider(query)) + : null; + final pagingViewModel = hasSerial + ? ref.read(transactionPagingViewModelProvider(query).notifier) + : null; + final colorScheme = Theme.of(context).colorScheme; + final terminalName = + _sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-'; + + return Scaffold( + appBar: AppBar( + title: Text(terminalName), + actions: [ + if (isCashier) + IconButton( + tooltip: 'Account', + icon: const Icon(Icons.more_vert), + onPressed: logoutState.isLoading + ? null + : () => _showCashierMenu( + context, + username: (user?.username ?? '').trim(), + ), + ), + ], + ), + body: SafeArea( + child: RefreshIndicator.adaptive( + onRefresh: () async { + if (!hasSerial || pagingViewModel == null) { + return; + } + await pagingViewModel.refresh(); + }, + child: NotificationListener( + onNotification: (notification) { + if (!hasSerial || + pagingState == null || + pagingViewModel == null || + !pagingState.hasMore || + pagingState.isLoadingMore) { + return false; + } + if (notification.metrics.axis != Axis.vertical) { + return false; + } + if (notification.metrics.maxScrollExtent <= 0) { + return false; + } + if (notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 240) { + pagingViewModel.loadMore(); + } + return false; + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.55), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: colorScheme.primary.withOpacity( + 0.14, + ), + ), + child: Icon( + Icons.point_of_sale_outlined, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.merchantName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + Text( + terminalName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: + colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + _InfoRow( + label: 'Serial', + value: widget.terminal.serial, + ), + _InfoRow( + label: 'Address', + value: _sanitizeMultiline( + widget.terminal.address, + ), + ), + ], + ), + ), + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'Transactions (4M)', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + ), + if (!hasSerial) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.info_outline, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Serial is required to load transactions', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'This terminal does not have a serial number.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + ..._buildPagingSlivers( + context: context, + colorScheme: colorScheme, + pagingState: pagingState!, + pagingViewModel: pagingViewModel!, + ), + ], + ), + ), + ), + ), + ); + } + + Future _showCashierMenu( + BuildContext context, { + required String username, + }) async { + HapticFeedback.selectionClick(); + + final action = await showModalBottomSheet<_CashierMenuAction>( + context: context, + showDragHandle: true, + isScrollControlled: true, + useSafeArea: true, + builder: (context) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final displayName = username.isEmpty ? 'Cashier' : username; + final initial = displayName.trim().isEmpty + ? '?' + : displayName.trim().characters.first.toUpperCase(); + + return Padding( + padding: EdgeInsets.fromLTRB(8, 0, 8, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: CircleAvatar(child: Text(initial)), + title: Text(displayName), + subtitle: const Text('Cashier'), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.password_outlined), + title: const Text('Change password'), + onTap: () => Navigator.of( + context, + ).pop(_CashierMenuAction.changePassword), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.logout, color: Colors.redAccent), + title: const Text('Logout'), + onTap: () => + Navigator.of(context).pop(_CashierMenuAction.logout), + ), + ], + ), + ); + }, + ); + + if (!mounted || action == null) { + return; + } + + switch (action) { + case _CashierMenuAction.changePassword: + final successMessage = await Navigator.of(this.context).push( + MaterialPageRoute(builder: (_) => const ChangePasswordPage()), + ); + if (successMessage != null && mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(successMessage), + behavior: SnackBarBehavior.floating, + ), + ); + } + break; + case _CashierMenuAction.logout: + await _logout(); + } + } + + Future _logout() async { + final shouldLogout = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Logout'), + ), + ], + ); + }, + ); + + if (shouldLogout != true || !mounted) { + return; + } + + HapticFeedback.mediumImpact(); + Navigator.of(context).popUntil((route) => route.isFirst); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Signed out'), + behavior: SnackBarBehavior.floating, + ), + ); + + await ref.read(logoutViewModelProvider.notifier).logout(); + } + + List _buildPagingSlivers({ + required BuildContext context, + required ColorScheme colorScheme, + required TransactionPagingState pagingState, + required TransactionPagingViewModel pagingViewModel, + }) { + if (pagingState.isLoading && pagingState.items.isEmpty) { + return const [ + SliverFillRemaining(child: Center(child: CircularProgressIndicator())), + ]; + } + + if (pagingState.errorMessage != null && pagingState.items.isEmpty) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.wifi_off_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'Failed to load transactions', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + pagingState.errorMessage!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: pagingViewModel.refresh, + child: const Text('Try again'), + ), + ], + ), + ), + ), + ), + ]; + } + + if (pagingState.items.isEmpty) { + return [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No transactions found', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Pull down to refresh.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ]; + } + + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final items = pagingState.items; + final hasFooter = pagingState.hasMore; + + return [ + SliverPadding( + padding: EdgeInsets.fromLTRB(16, 0, 16, bottomInset + 16), + sliver: SliverList.separated( + itemCount: items.length + (hasFooter ? 1 : 0), + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index >= items.length) { + return _TransactionPaginationFooter( + isLoadingMore: pagingState.isLoadingMore, + onLoadMore: pagingViewModel.loadMore, + ); + } + + final item = items[index]; + final raw = item.raw ?? const {}; + final amount = + _first(raw, const ['DE4']) ?? item.amount?.toString() ?? '-'; + final currency = _first(raw, const ['DE49']) ?? 'MMK'; + final status = _statusLabel( + _first(raw, const ['DE39']) ?? item.status, + ); + final type = _first(raw, const ['DE3']) ?? item.type ?? '-'; + final rrn = _first(raw, const ['DE37']) ?? item.rrn ?? '-'; + final createdAt = + _first(raw, const ['CREATED_AT', 'de7_date']) ?? item.createdAt; + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.55), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TransactionReceiptScreen( + merchantName: widget.merchantName, + terminal: widget.terminal, + transaction: item, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + child: Row( + children: [ + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: colorScheme.primary.withOpacity(0.14), + ), + child: Icon( + Icons.receipt_long_outlined, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '$amount $currency', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 8), + _StatusChip(status: status), + ], + ), + const SizedBox(height: 4), + Text( + 'Type: $type • RRN: $rrn', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + if ((createdAt ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + createdAt!.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + }, + ), + ), + if (pagingState.errorMessage != null && pagingState.items.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.viewPaddingOf(context).bottom + 8, + ), + child: Material( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: pagingViewModel.loadMore, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ), + ]; + } + + String? _first(Map raw, List keys) { + for (final key in keys) { + final value = raw[key]; + if (value != null) { + final text = value.toString().trim(); + if (text.isNotEmpty) { + return text; + } + } + } + return null; + } + + String _statusLabel(String? status) { + final normalized = status?.trim().toUpperCase(); + if (normalized == 'A') { + return 'SUCCESS'; + } + if (normalized == 'E') { + return 'FAILED'; + } + return 'SUCCESS'; + } + + String? _sanitizeMultiline(String? value) { + if (value == null) { + return null; + } + final cleaned = value + .replaceAll('\r', ' ') + .replaceAll('\n', ' ') + .replaceAll(r'\n', ' ') + .replaceAll(r'/n', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned.isEmpty ? null : cleaned; + } +} + +class _TransactionPaginationFooter extends StatelessWidget { + const _TransactionPaginationFooter({ + required this.isLoadingMore, + required this.onLoadMore, + }); + + final bool isLoadingMore; + final VoidCallback onLoadMore; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 0, + color: colorScheme.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text( + isLoadingMore ? 'Loading more...' : 'More results available', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + if (isLoadingMore) + const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + FilledButton.tonal( + onPressed: onLoadMore, + child: const Text('Load more'), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String? value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final text = (value ?? '').trim().isEmpty ? '-' : value!.trim(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final normalized = status.toUpperCase(); + final isSuccess = normalized == 'SUCCESS'; + + final background = isSuccess + ? Colors.green.withOpacity(0.12) + : colorScheme.errorContainer.withOpacity(0.9); + final foreground = isSuccess + ? Colors.green.shade700 + : colorScheme.onErrorContainer; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: foreground.withOpacity(0.25)), + ), + child: Text( + normalized.isEmpty ? '-' : normalized, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w800, + color: foreground, + ), + ), + ); + } +} + +enum _CashierMenuAction { changePassword, logout }