From 9368cc9cc61f2cde3f2bccac451a966eeeb7e4f3 Mon Sep 17 00:00:00 2001 From: kyawkhantwin Date: Wed, 1 Apr 2026 10:31:35 +0630 Subject: [PATCH] print ok reset password ok : left download button and print button to work --- .../repositories/api_auth_repository.dart | 84 ++ .../repositories/api_receipt_repository.dart | 51 + .../api_transaction_repository.dart | 8 + .../repositories/mock_auth_repository.dart | 23 + lib/domain/entities/login_user.dart | 2 + lib/domain/exceptions/auth_exceptions.dart | 13 + lib/domain/repositories/auth_repository.dart | 4 + .../repositories/receipt_repository.dart | 8 + .../repositories/transaction_repository.dart | 4 + lib/main.dart | 10 + lib/presentation/auth/logout_state.dart | 24 + lib/presentation/auth/logout_view_model.dart | 54 + lib/presentation/home/home_screen.dart | 15 +- lib/presentation/login/login_page.dart | 35 +- lib/presentation/login/login_state.dart | 13 +- lib/presentation/login/login_view_model.dart | 14 + .../reset_password_form_providers.dart | 40 + .../reset_password/reset_password_page.dart | 334 ++++++ .../reset_password/reset_password_state.dart | 28 + .../reset_password_view_model.dart | 54 + .../terminal/terminal_next_screen.dart | 974 +++++++++++------- .../transaction_pagination_providers.dart | 4 + .../terminal/transaction_paging_state.dart | 45 + .../transaction_paging_view_model.dart | 151 +++ .../terminal/transaction_receipt_screen.dart | 330 +++++- pubspec.lock | 101 ++ pubspec.yaml | 1 + 27 files changed, 1972 insertions(+), 452 deletions(-) create mode 100644 lib/domain/exceptions/auth_exceptions.dart create mode 100644 lib/presentation/auth/logout_state.dart create mode 100644 lib/presentation/auth/logout_view_model.dart create mode 100644 lib/presentation/reset_password/reset_password_form_providers.dart create mode 100644 lib/presentation/reset_password/reset_password_page.dart create mode 100644 lib/presentation/reset_password/reset_password_state.dart create mode 100644 lib/presentation/reset_password/reset_password_view_model.dart create mode 100644 lib/presentation/terminal/transaction_pagination_providers.dart create mode 100644 lib/presentation/terminal/transaction_paging_state.dart create mode 100644 lib/presentation/terminal/transaction_paging_view_model.dart diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index 2e81105..96bc275 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.dart'; import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; import 'package:http/http.dart' as http; @@ -42,6 +43,21 @@ class ApiAuthRepository implements AuthRepository { ); } + final data = _asMap(response.body); + final payload = _extractPayload(data); + final require = + _pickString(data, const ['require', 'required']) ?? + _pickString(payload, const ['require', 'required']); + if (require == 'PASSWORD_UPDATE_REQUIRED') { + final userId = _extractUserId(data); + if (userId != null) { + throw PasswordUpdateRequiredException( + userId: userId, + message: _extractErrorMessage(response.body), + ); + } + } + throw Exception(_extractErrorMessage(response.body)); } @@ -77,6 +93,56 @@ class ApiAuthRepository implements AuthRepository { throw Exception(_extractErrorMessage(response.body)); } + @override + Future resetPassword({ + required String userId, + required String password, + }) async { + final uri = Uri.parse('$baseUrl/auth/reset-password'); + final requestBody = jsonEncode({ + 'userId': userId, + 'password': password, + }); + + final response = await _client.put( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + + throw Exception(_extractErrorMessage(response.body)); + } + + @override + Future logout({required String refreshToken}) async { + final uri = Uri.parse('$baseUrl/auth/logout'); + final requestBody = jsonEncode({ + 'refreshToken': refreshToken, + }); + + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + }, + body: requestBody, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + + throw Exception(_extractErrorMessage(response.body)); + } + Map _asMap(String body) { try { final decoded = jsonDecode(body); @@ -98,6 +164,24 @@ class ApiAuthRepository implements AuthRepository { return message.toString(); } + String? _extractUserId(Map data) { + final rootUser = data['user']; + if (rootUser is Map) { + final id = _pickString(rootUser, const ['id', 'userId', 'user_id']); + if (id != null) { + return id; + } + } + + final payload = _extractPayload(data); + final payloadUser = payload['user']; + if (payloadUser is Map) { + return _pickString(payloadUser, const ['id', 'userId', 'user_id']); + } + + return _pickString(payload, const ['userId', 'user_id', 'id']); + } + LoginUser _parseUserFromResponse( String body, { required String fallbackUsername, diff --git a/lib/data/repositories/api_receipt_repository.dart b/lib/data/repositories/api_receipt_repository.dart index a6f663b..f2234b0 100644 --- a/lib/data/repositories/api_receipt_repository.dart +++ b/lib/data/repositories/api_receipt_repository.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; @@ -62,6 +63,56 @@ class ApiReceiptRepository implements ReceiptRepository { return ReceiptHtmlContent(html: html, contentType: contentType); } + @override + Future getTransactionReceiptPdfBytes({ + required String token, + required String transactionId, + required String copyFor, + }) async { + final uri = _normalizeLocalhostForAndroid( + Uri.parse('$baseUrl/transaction/pdf-html').replace( + queryParameters: { + 'transactionId': transactionId, + 'copyFor': copyFor, + }, + ), + ); + + final request = http.Request('GET', uri); + request.headers.addAll({ + 'Accept': 'application/pdf', + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}', + }); + + final streamed = await _client.send(request); + final bytes = await streamed.stream.toBytes(); + final contentType = streamed.headers['content-type']?.trim() ?? ''; + + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + final body = const Utf8Decoder( + allowMalformed: true, + ).convert(bytes).trim(); + throw Exception( + body.isNotEmpty && body.length <= 500 + ? body + : 'Failed to load receipt PDF (${streamed.statusCode}) $contentType', + ); + } + + if (_looksLikePdf(bytes: bytes, contentType: contentType)) { + return bytes; + } + + final body = const Utf8Decoder(allowMalformed: true).convert(bytes).trim(); + throw Exception( + body.isNotEmpty && body.length <= 500 + ? body + : 'Server did not return a PDF ($contentType)', + ); + } + Uri _normalizeLocalhostForAndroid(Uri uri) { if (!Platform.isAndroid) { return uri; diff --git a/lib/data/repositories/api_transaction_repository.dart b/lib/data/repositories/api_transaction_repository.dart index ee2e1cf..bd945bd 100644 --- a/lib/data/repositories/api_transaction_repository.dart +++ b/lib/data/repositories/api_transaction_repository.dart @@ -20,11 +20,19 @@ class ApiTransactionRepository implements TransactionRepository { required String token, required String serial, required String range, + int page = 1, + int limit = 8, + String searchTerm = '', + String sort = '', }) async { final uri = Uri.parse('$baseUrl/transaction').replace( queryParameters: { + 'page': page.toString(), + 'limit': limit.toString(), 'serial': serial, 'range': range, + if (searchTerm.trim().isNotEmpty) 'search': searchTerm.trim(), + if (sort.trim().isNotEmpty) 'sort': sort.trim(), }, ); diff --git a/lib/data/repositories/mock_auth_repository.dart b/lib/data/repositories/mock_auth_repository.dart index 3b31480..3ed6875 100644 --- a/lib/data/repositories/mock_auth_repository.dart +++ b/lib/data/repositories/mock_auth_repository.dart @@ -44,4 +44,27 @@ class MockAuthRepository implements AuthRepository { role: role, ); } + + @override + Future resetPassword({ + required String userId, + required String password, + }) async { + await Future.delayed(const Duration(milliseconds: 500)); + + if (userId.trim().isEmpty) { + throw Exception('User ID is required.'); + } + if (password.length < 8) { + throw Exception('Password must be at least 8 characters.'); + } + } + + @override + Future logout({required String refreshToken}) async { + await Future.delayed(const Duration(milliseconds: 250)); + if (refreshToken.trim().isEmpty) { + throw Exception('Refresh token is required.'); + } + } } diff --git a/lib/domain/entities/login_user.dart b/lib/domain/entities/login_user.dart index 2cf27f3..c0e1f07 100644 --- a/lib/domain/entities/login_user.dart +++ b/lib/domain/entities/login_user.dart @@ -12,4 +12,6 @@ class LoginUser { final String role; bool get isAdmin => role.toLowerCase() == 'admin'; + + bool get isCashier => role.toLowerCase() == 'cashier'; } diff --git a/lib/domain/exceptions/auth_exceptions.dart b/lib/domain/exceptions/auth_exceptions.dart new file mode 100644 index 0000000..02f9832 --- /dev/null +++ b/lib/domain/exceptions/auth_exceptions.dart @@ -0,0 +1,13 @@ +class PasswordUpdateRequiredException implements Exception { + const PasswordUpdateRequiredException({ + required this.userId, + this.message = 'Password update required', + }); + + final String userId; + final String message; + + @override + String toString() => message; +} + diff --git a/lib/domain/repositories/auth_repository.dart b/lib/domain/repositories/auth_repository.dart index a30bd34..cb898ec 100644 --- a/lib/domain/repositories/auth_repository.dart +++ b/lib/domain/repositories/auth_repository.dart @@ -8,4 +8,8 @@ abstract class AuthRepository { required String refreshToken, required String role, }); + + Future resetPassword({required String userId, required String password}); + + Future logout({required String refreshToken}); } diff --git a/lib/domain/repositories/receipt_repository.dart b/lib/domain/repositories/receipt_repository.dart index 07bb68d..f2f7c52 100644 --- a/lib/domain/repositories/receipt_repository.dart +++ b/lib/domain/repositories/receipt_repository.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; abstract class ReceiptRepository { @@ -6,4 +8,10 @@ abstract class ReceiptRepository { required String transactionId, required String copyFor, }); + + Future getTransactionReceiptPdfBytes({ + required String token, + required String transactionId, + required String copyFor, + }); } diff --git a/lib/domain/repositories/transaction_repository.dart b/lib/domain/repositories/transaction_repository.dart index bbc6d66..105a340 100644 --- a/lib/domain/repositories/transaction_repository.dart +++ b/lib/domain/repositories/transaction_repository.dart @@ -5,5 +5,9 @@ abstract class TransactionRepository { required String token, required String serial, required String range, + int page = 1, + int limit = 8, + String searchTerm = '', + String sort = '', }); } diff --git a/lib/main.dart b/lib/main.dart index 4177846..b4b2dcf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,10 @@ import 'package:e_receipt_mobile/core/theme/app_colors.dart'; +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/home/home_screen.dart'; import 'package:e_receipt_mobile/presentation/login/login_page.dart'; import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; +import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -81,6 +83,14 @@ class AppRoot extends ConsumerWidget { if (user == null) { return const LoginPage(); } + + final role = user.role.trim().toLowerCase(); + if (role == 'cashier') { + final serial = user.username.trim().toUpperCase(); + final terminal = Terminal(serial: serial, name: serial); + return TerminalNextScreen(merchantName: serial, terminal: terminal); + } + return HomeScreen(user: user); } } diff --git a/lib/presentation/auth/logout_state.dart b/lib/presentation/auth/logout_state.dart new file mode 100644 index 0000000..7f1a581 --- /dev/null +++ b/lib/presentation/auth/logout_state.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class LogoutState { + const LogoutState({ + this.isLoading = false, + this.lastError, + }); + + final bool isLoading; + final Object? lastError; + + LogoutState copyWith({ + bool? isLoading, + Object? lastError, + bool clearError = false, + }) { + return LogoutState( + isLoading: isLoading ?? this.isLoading, + lastError: clearError ? null : lastError ?? this.lastError, + ); + } +} + diff --git a/lib/presentation/auth/logout_view_model.dart b/lib/presentation/auth/logout_view_model.dart new file mode 100644 index 0000000..1796714 --- /dev/null +++ b/lib/presentation/auth/logout_view_model.dart @@ -0,0 +1,54 @@ +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_state.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final logoutViewModelProvider = + StateNotifierProvider((ref) { + return LogoutViewModel( + authRepository: ref.watch(authRepositoryProvider), + sessionController: ref.watch(sessionControllerProvider.notifier), + sessionUserProvider: () => ref.read(sessionControllerProvider), + ); +}); + +class LogoutViewModel extends StateNotifier { + LogoutViewModel({ + required AuthRepository authRepository, + required SessionController sessionController, + required SessionUserProvider sessionUserProvider, + }) : _authRepository = authRepository, + _sessionController = sessionController, + _sessionUserProvider = sessionUserProvider, + super(const LogoutState()); + + final AuthRepository _authRepository; + final SessionController _sessionController; + final SessionUserProvider _sessionUserProvider; + + Future logout() async { + if (state.isLoading) { + return; + } + + state = state.copyWith(isLoading: true, clearError: true); + + final sessionUser = _sessionUserProvider(); + final refreshToken = sessionUser?.refreshToken.trim() ?? ''; + + try { + if (refreshToken.isNotEmpty) { + await _authRepository.logout(refreshToken: refreshToken); + } + } catch (error) { + state = state.copyWith(lastError: error); + } finally { + _sessionController.clearUser(); + state = state.copyWith(isLoading: false); + } + } +} + +typedef SessionUserProvider = LoginUser? Function(); diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index beac438..014fdc7 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,5 +1,5 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart'; -import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/auth/logout_view_model.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'; @@ -19,6 +19,7 @@ class HomeScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final pagingState = ref.watch(merchantPagingViewModelProvider); final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); + final logoutState = ref.watch(logoutViewModelProvider); final colorScheme = Theme.of(context).colorScheme; final query = ref.watch(merchantSearchQueryProvider); @@ -56,6 +57,10 @@ class HomeScreen extends ConsumerWidget { _showComingSoon(context, 'Help'); }, onLogout: () async { + if (logoutState.isLoading) { + return; + } + final shouldLogout = await showDialog( context: context, builder: (context) { @@ -81,7 +86,13 @@ class HomeScreen extends ConsumerWidget { } Navigator.of(context).pop(); // close drawer - ref.read(sessionControllerProvider.notifier).clearUser(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Signed out'), + behavior: SnackBarBehavior.floating, + ), + ); + await ref.read(logoutViewModelProvider.notifier).logout(); }, ), body: pagingState.isLoading && pagingState.items.isEmpty diff --git a/lib/presentation/login/login_page.dart b/lib/presentation/login/login_page.dart index 4157782..badbbbe 100644 --- a/lib/presentation/login/login_page.dart +++ b/lib/presentation/login/login_page.dart @@ -3,6 +3,7 @@ import 'package:e_receipt_mobile/presentation/login/login_state.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart'; import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.dart'; +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -41,6 +42,39 @@ class LoginPage extends ConsumerWidget { final state = ref.watch(loginViewModelProvider); final colorScheme = Theme.of(context).colorScheme; + ref.listen(loginViewModelProvider, (previous, next) { + final action = next.requiredAction; + if (action == null || action == previous?.requiredAction) { + return; + } + + switch (action) { + case LoginRequiredAction.passwordUpdateRequired: + final userId = (next.requiredUserId ?? '').trim(); + if (userId.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password update required (missing user ID)'), + behavior: SnackBarBehavior.floating, + ), + ); + ref.read(loginViewModelProvider.notifier).clearRequirement(); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Password update required'), + behavior: SnackBarBehavior.floating, + ), + ); + ref.read(loginViewModelProvider.notifier).clearRequirement(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ResetPasswordPage(userId: userId)), + ); + } + }); + final formKey = ref.watch(loginFormKeyProvider); final usernameController = ref.watch(loginUsernameControllerProvider); final passwordController = ref.watch(loginPasswordControllerProvider); @@ -293,4 +327,3 @@ class LoginPage extends ConsumerWidget { ); } } - diff --git a/lib/presentation/login/login_state.dart b/lib/presentation/login/login_state.dart index 69281e3..1c88300 100644 --- a/lib/presentation/login/login_state.dart +++ b/lib/presentation/login/login_state.dart @@ -1,6 +1,8 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:flutter/foundation.dart'; +enum LoginRequiredAction { passwordUpdateRequired } + @immutable class LoginState { const LoginState({ @@ -8,21 +10,28 @@ class LoginState { this.errorMessage, this.successMessage, this.user, + this.requiredAction, + this.requiredUserId, }); final bool isLoading; final String? errorMessage; final String? successMessage; final LoginUser? user; + final LoginRequiredAction? requiredAction; + final String? requiredUserId; LoginState copyWith({ bool? isLoading, String? errorMessage, String? successMessage, LoginUser? user, + LoginRequiredAction? requiredAction, + String? requiredUserId, bool clearError = false, bool clearSuccess = false, bool clearUser = false, + bool clearRequired = false, }) { return LoginState( isLoading: isLoading ?? this.isLoading, @@ -31,11 +40,13 @@ class LoginState { ? null : successMessage ?? this.successMessage, user: clearUser ? null : user ?? this.user, + requiredAction: clearRequired ? null : requiredAction ?? this.requiredAction, + requiredUserId: clearRequired ? null : requiredUserId ?? this.requiredUserId, ); } @override String toString() { - return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user)'; + return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user, requiredAction: $requiredAction, requiredUserId: $requiredUserId)'; } } diff --git a/lib/presentation/login/login_view_model.dart b/lib/presentation/login/login_view_model.dart index 963b5a1..5e23e4d 100644 --- a/lib/presentation/login/login_view_model.dart +++ b/lib/presentation/login/login_view_model.dart @@ -4,6 +4,7 @@ import 'package:e_receipt_mobile/core/network/auth_http_client.dart'; import 'package:e_receipt_mobile/core/network/logging_http_client.dart'; import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart'; import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/domain/exceptions/auth_exceptions.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/login/login_state.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -100,6 +101,7 @@ class LoginViewModel extends StateNotifier { clearError: true, clearSuccess: true, clearUser: true, + clearRequired: true, ); _sessionController.clearUser(); @@ -114,6 +116,14 @@ class LoginViewModel extends StateNotifier { user: user, successMessage: 'Welcome ${user.username} (${user.role})', ); + } on PasswordUpdateRequiredException catch (error) { + state = state.copyWith( + isLoading: false, + requiredAction: LoginRequiredAction.passwordUpdateRequired, + requiredUserId: error.userId, + clearError: true, + clearSuccess: true, + ); } catch (error) { state = state.copyWith( isLoading: false, @@ -125,4 +135,8 @@ class LoginViewModel extends StateNotifier { void clearMessages() { state = state.copyWith(clearError: true, clearSuccess: true); } + + void clearRequirement() { + state = state.copyWith(clearRequired: true); + } } diff --git a/lib/presentation/reset_password/reset_password_form_providers.dart b/lib/presentation/reset_password/reset_password_form_providers.dart new file mode 100644 index 0000000..032a23e --- /dev/null +++ b/lib/presentation/reset_password/reset_password_form_providers.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final resetPasswordFormKeyProvider = + Provider.autoDispose>((ref) { + return GlobalKey(); +}); + +final resetPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; +}); + +final resetConfirmPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; +}); + +final resetPasswordFocusNodeProvider = Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final resetConfirmPasswordFocusNodeProvider = + Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final resetObscurePasswordProvider = StateProvider.autoDispose((ref) => true); +final resetObscureConfirmPasswordProvider = + StateProvider.autoDispose((ref) => true); + +final resetAttemptedSubmitProvider = StateProvider.autoDispose((ref) => false); diff --git a/lib/presentation/reset_password/reset_password_page.dart b/lib/presentation/reset_password/reset_password_page.dart new file mode 100644 index 0000000..410104e --- /dev/null +++ b/lib/presentation/reset_password/reset_password_page.dart @@ -0,0 +1,334 @@ +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_form_providers.dart'; +import 'package:e_receipt_mobile/presentation/reset_password/reset_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 ResetPasswordPage extends ConsumerWidget { + const ResetPasswordPage({required this.userId, super.key}); + + final String userId; + + bool _isStrongPassword(String value) { + return value.length >= 8 && + RegExp(r'[a-z]').hasMatch(value) && + RegExp(r'[A-Z]').hasMatch(value) && + RegExp(r'[0-9]').hasMatch(value) && + RegExp(r'[^A-Za-z0-9]').hasMatch(value); + } + + void _submit({ + required BuildContext context, + required WidgetRef ref, + required GlobalKey formKey, + required TextEditingController passwordController, + required TextEditingController confirmController, + required bool isLoading, + }) { + if (isLoading) { + return; + } + + ref.read(resetPasswordViewModelProvider.notifier).clearMessages(); + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(resetAttemptedSubmitProvider.notifier).state = true; + + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + final password = passwordController.text; + final confirm = confirmController.text; + if (password != confirm) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Passwords do not match'), + behavior: SnackBarBehavior.floating, + ), + ); + return; + } + + ref + .read(resetPasswordViewModelProvider.notifier) + .resetPassword(userId: userId, password: password); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(resetPasswordViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; + + ref.listen(resetPasswordViewModelProvider, (previous, next) { + final message = next.errorMessage; + if (message != null && message != previous?.errorMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + ), + ); + } + final success = next.successMessage; + if (success != null && success != previous?.successMessage) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success), + behavior: SnackBarBehavior.floating, + ), + ); + Navigator.of(context).pop(); + } + }); + + final formKey = ref.watch(resetPasswordFormKeyProvider); + final passwordController = ref.watch(resetPasswordControllerProvider); + final confirmController = ref.watch(resetConfirmPasswordControllerProvider); + final passwordFocusNode = ref.watch(resetPasswordFocusNodeProvider); + final confirmFocusNode = ref.watch(resetConfirmPasswordFocusNodeProvider); + final obscure = ref.watch(resetObscurePasswordProvider); + final obscureConfirm = ref.watch(resetObscureConfirmPasswordProvider); + final attemptedSubmit = ref.watch(resetAttemptedSubmitProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Reset 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.withOpacity( + Theme.of(context).brightness == Brightness.dark ? 0.75 : 0.92, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide( + color: colorScheme.outlineVariant.withOpacity(0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: Form( + key: formKey, + autovalidateMode: attemptedSubmit + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + child: Column( + mainAxisSize: MainAxisSize.min, + 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.lock_reset_outlined, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Update your password', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w800), + ), + Text( + 'Your account requires a password change.', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + TextFormField( + controller: passwordController, + focusNode: passwordFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.next, + obscureText: obscure, + onFieldSubmitted: (_) => + FocusScope.of(context).requestFocus(confirmFocusNode), + validator: (value) { + final text = (value ?? '').trim(); + if (text.isEmpty) { + return 'New password is required'; + } + if (!_isStrongPassword(text)) { + return 'Use 8+ chars with upper, lower, number, and symbol'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'New password', + hintText: 'Enter a strong password', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + suffixIcon: IconButton( + tooltip: + obscure ? 'Show password' : 'Hide password', + onPressed: state.isLoading + ? null + : () => ref + .read( + resetObscurePasswordProvider.notifier, + ) + .state = + !obscure, + icon: Icon( + obscure + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: confirmController, + focusNode: confirmFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.done, + obscureText: obscureConfirm, + onFieldSubmitted: (_) => _submit( + context: context, + ref: ref, + formKey: formKey, + passwordController: passwordController, + confirmController: confirmController, + isLoading: state.isLoading, + ), + validator: (value) { + if ((value ?? '').trim().isEmpty) { + return 'Confirm password is required'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Confirm new password', + hintText: 'Re-enter your password', + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), + suffixIcon: IconButton( + tooltip: obscureConfirm + ? 'Show password' + : 'Hide password', + onPressed: state.isLoading + ? null + : () => ref + .read( + resetObscureConfirmPasswordProvider + .notifier, + ) + .state = + !obscureConfirm, + icon: Icon( + obscureConfirm + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: state.isLoading + ? null + : () => _submit( + context: context, + ref: ref, + formKey: formKey, + passwordController: passwordController, + confirmController: confirmController, + 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...' : 'Reset password', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 10), + Text( + 'Tip: use a unique password you don’t use elsewhere.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/reset_password/reset_password_state.dart b/lib/presentation/reset_password/reset_password_state.dart new file mode 100644 index 0000000..015da9f --- /dev/null +++ b/lib/presentation/reset_password/reset_password_state.dart @@ -0,0 +1,28 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class ResetPasswordState { + const ResetPasswordState({ + this.isLoading = false, + this.errorMessage, + this.successMessage, + }); + + final bool isLoading; + final String? errorMessage; + final String? successMessage; + + ResetPasswordState copyWith({ + bool? isLoading, + String? errorMessage, + String? successMessage, + bool clearError = false, + bool clearSuccess = false, + }) { + return ResetPasswordState( + isLoading: isLoading ?? this.isLoading, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + successMessage: clearSuccess ? null : successMessage ?? this.successMessage, + ); + } +} diff --git a/lib/presentation/reset_password/reset_password_view_model.dart b/lib/presentation/reset_password/reset_password_view_model.dart new file mode 100644 index 0000000..f754d70 --- /dev/null +++ b/lib/presentation/reset_password/reset_password_view_model.dart @@ -0,0 +1,54 @@ +import 'package:e_receipt_mobile/domain/repositories/auth_repository.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:e_receipt_mobile/presentation/reset_password/reset_password_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final resetPasswordViewModelProvider = + StateNotifierProvider.autoDispose( + (ref) { + return ResetPasswordViewModel(ref.watch(authRepositoryProvider)); + }, +); + +class ResetPasswordViewModel extends StateNotifier { + ResetPasswordViewModel(this._authRepository) : super(const ResetPasswordState()); + + final AuthRepository _authRepository; + + Future resetPassword({ + required String userId, + required String password, + }) async { + if (userId.trim().isEmpty) { + state = state.copyWith( + isLoading: false, + errorMessage: 'Missing user ID', + clearSuccess: true, + ); + return; + } + + state = state.copyWith( + isLoading: true, + clearError: true, + clearSuccess: true, + ); + + try { + await _authRepository.resetPassword(userId: userId.trim(), password: password); + state = state.copyWith( + isLoading: false, + successMessage: 'Password updated', + ); + } 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/terminal/terminal_next_screen.dart b/lib/presentation/terminal/terminal_next_screen.dart index 27b2a1b..838b2df 100644 --- a/lib/presentation/terminal/terminal_next_screen.dart +++ b/lib/presentation/terminal/terminal_next_screen.dart @@ -1,7 +1,12 @@ 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 { @@ -19,437 +24,571 @@ class TerminalNextScreen extends ConsumerStatefulWidget { } class _TerminalNextScreenState extends ConsumerState { - static const List _ranges = ['1d', '1w', '1m']; - String _selectedRange = '1d'; + 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 transactionsAsync = hasSerial - ? ref.watch(transactionListProvider(query)) + 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)), + 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) { + if (!hasSerial || pagingViewModel == null) { return; } - await ref.refresh(transactionListProvider(query).future); + await pagingViewModel.refresh(); }, - 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: 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, - ), + 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, ), - Text( - terminalName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], + ), + 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, ), - ], - ), - 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'), ), - SliverToBoxAdapter( + 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 ['description', 'DE39', 'status']) ?? + 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.fromLTRB(16, 0, 16, 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), child: Row( children: [ - Text( - 'Transactions', - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w800), - ), - const Spacer(), - SegmentedButton( - segments: _ranges - .map( - (range) => ButtonSegment( - value: range, - label: Text(range.toUpperCase()), - ), - ) - .toList(), - selected: {_selectedRange}, - onSelectionChanged: (value) { - final next = value.firstOrNull; - if (next == null || next == _selectedRange) { - return; - } - setState(() => _selectedRange = next); - }, - style: ButtonStyle( - visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + 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 (!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 - ...transactionsAsync!.when( - loading: () => [ - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ), - ], - error: (error, _) => [ - 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( - '$error', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 14), - FilledButton.tonal( - onPressed: () => ref.invalidate( - transactionListProvider(query), - ), - child: const Text('Try again'), - ), - ], - ), - ), - ), - ), - ], - data: (transactions) { - if (transactions.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, - ), - ), - ], - ), - ), - ), - ), - ]; - } - - return [ - SliverPadding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - MediaQuery.viewPaddingOf(context).bottom + 16, - ), - sliver: SliverList.separated( - itemCount: transactions.length, - separatorBuilder: (_, __) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final item = transactions[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 [ - 'description', - 'DE39', - 'status', - ]) ?? - 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) { @@ -493,6 +632,53 @@ class _TerminalNextScreenState extends ConsumerState { } } +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}); @@ -567,3 +753,5 @@ class _StatusChip extends StatelessWidget { ); } } + +enum _CashierMenuAction { logout } diff --git a/lib/presentation/terminal/transaction_pagination_providers.dart b/lib/presentation/terminal/transaction_pagination_providers.dart new file mode 100644 index 0000000..aa14f09 --- /dev/null +++ b/lib/presentation/terminal/transaction_pagination_providers.dart @@ -0,0 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final transactionPageSizeProvider = Provider((ref) => 8); + diff --git a/lib/presentation/terminal/transaction_paging_state.dart b/lib/presentation/terminal/transaction_paging_state.dart new file mode 100644 index 0000000..311435d --- /dev/null +++ b/lib/presentation/terminal/transaction_paging_state.dart @@ -0,0 +1,45 @@ +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class TransactionPagingState { + const TransactionPagingState({ + this.items = const [], + this.page = 1, + this.limit = 8, + this.isLoading = false, + this.isLoadingMore = false, + this.hasMore = true, + this.errorMessage, + }); + + final List items; + final int page; + final int limit; + final bool isLoading; + final bool isLoadingMore; + final bool hasMore; + final String? errorMessage; + + TransactionPagingState copyWith({ + List? items, + int? page, + int? limit, + bool? isLoading, + bool? isLoadingMore, + bool? hasMore, + String? errorMessage, + bool clearError = false, + }) { + return TransactionPagingState( + items: items ?? this.items, + page: page ?? this.page, + limit: limit ?? this.limit, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + ); + } +} + diff --git a/lib/presentation/terminal/transaction_paging_view_model.dart b/lib/presentation/terminal/transaction_paging_view_model.dart new file mode 100644 index 0000000..0296fc9 --- /dev/null +++ b/lib/presentation/terminal/transaction_paging_view_model.dart @@ -0,0 +1,151 @@ +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_pagination_providers.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_paging_state.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final transactionPagingViewModelProvider = + StateNotifierProvider.autoDispose.family< + TransactionPagingViewModel, + TransactionPagingState, + TransactionQuery + >((ref, query) { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final limit = ref.watch(transactionPageSizeProvider); + final viewModel = TransactionPagingViewModel( + repository: ref.watch(transactionRepositoryProvider), + token: sessionUser.token, + serial: query.serial, + range: query.range, + limit: limit, + ); + viewModel.loadInitial(); + return viewModel; + }); + +class TransactionPagingViewModel extends StateNotifier { + TransactionPagingViewModel({ + required TransactionRepository repository, + required String token, + required String serial, + required String range, + required int limit, + }) : _repository = repository, + _token = token, + _serial = serial, + _range = range, + super(TransactionPagingState(limit: limit)); + + final TransactionRepository _repository; + final String _token; + final String _serial; + final String _range; + + Future loadInitial() async { + await refresh(); + } + + Future refresh() async { + state = state.copyWith( + isLoading: true, + isLoadingMore: false, + page: 1, + items: const [], + hasMore: true, + clearError: true, + ); + + try { + final results = await _repository.getTransactions( + token: _token, + serial: _serial, + range: _range, + page: 1, + limit: state.limit, + ); + state = state.copyWith( + isLoading: false, + items: results, + page: 1, + hasMore: results.length >= state.limit, + ); + } catch (error) { + state = state.copyWith( + isLoading: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + Future loadMore() async { + if (state.isLoading || state.isLoadingMore || !state.hasMore) { + return; + } + + state = state.copyWith(isLoadingMore: true, clearError: true); + final nextPage = state.page + 1; + + try { + final results = await _repository.getTransactions( + token: _token, + serial: _serial, + range: _range, + page: nextPage, + limit: state.limit, + ); + + final merged = _mergeUnique(state.items, results); + final addedCount = merged.length - state.items.length; + final hasMore = results.length >= state.limit && addedCount > 0; + + state = state.copyWith( + isLoadingMore: false, + items: merged, + page: nextPage, + hasMore: hasMore, + ); + } catch (error) { + state = state.copyWith( + isLoadingMore: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } + + List _mergeUnique( + List existing, + List incoming, + ) { + if (existing.isEmpty) { + return incoming; + } + + final keys = existing.map(_keyFor).toSet(); + final merged = [...existing]; + for (final item in incoming) { + final key = _keyFor(item); + if (keys.add(key)) { + merged.add(item); + } + } + return merged; + } + + String _keyFor(TransactionRecord item) { + return [ + item.id, + item.rrn, + item.createdAt, + item.amount?.toString(), + item.status, + item.type, + ].join('|'); + } +} + diff --git a/lib/presentation/terminal/transaction_receipt_screen.dart b/lib/presentation/terminal/transaction_receipt_screen.dart index 1b965dd..a6cd2cf 100644 --- a/lib/presentation/terminal/transaction_receipt_screen.dart +++ b/lib/presentation/terminal/transaction_receipt_screen.dart @@ -1,12 +1,18 @@ +import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; +import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/terminal/receipt_view_model.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_pdfview/flutter_pdfview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:printing/printing.dart'; import 'package:webview_flutter/webview_flutter.dart'; class TransactionReceiptScreen extends ConsumerWidget { @@ -104,40 +110,89 @@ class TransactionReceiptScreen extends ConsumerWidget { ), ), ), - data: (viewData) => switch (viewData) { - ReceiptPdfViewData() => _ReceiptViewport( - child: PDFView( - filePath: viewData.file.path, - enableSwipe: true, - swipeHorizontal: false, - autoSpacing: true, - pageFling: true, - pageSnap: true, - fitEachPage: true, - fitPolicy: FitPolicy.WIDTH, - backgroundColor: Theme.of(context).colorScheme.surface, - onError: (error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('PDF error: ${Error.safeToString(error)}'), + data: (viewData) => Column( + children: [ + Expanded( + child: switch (viewData) { + ReceiptPdfViewData() => _ReceiptViewport( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: PDFView( + filePath: viewData.file.path, + enableSwipe: true, + swipeHorizontal: false, + autoSpacing: false, + pageFling: true, + pageSnap: true, + fitEachPage: true, + fitPolicy: FitPolicy.WIDTH, + backgroundColor: Theme.of(context).colorScheme.primary, + onError: (error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'PDF error: ${Error.safeToString(error)}', + ), + ), + ); + }, + onPageError: (page, error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'PDF page $page error: ${Error.safeToString(error)}', + ), + ), + ); + }, ), - ); - }, - onPageError: (page, error) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'PDF page $page error: ${Error.safeToString(error)}', - ), - ), - ); + ), + ReceiptHtmlViewData() => _ReceiptViewport( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: _ReceiptHtmlView(html: viewData.html), + ), }, ), - ), - ReceiptHtmlViewData() => _ReceiptViewport( - child: _ReceiptHtmlView(html: viewData.html), - ), - }, + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: () => _downloadReceipt( + context: context, + ref: ref, + transactionId: transactionId, + ), + icon: const Icon(Icons.download_outlined), + label: const Text('Download PDF'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.tonal( + onPressed: () => _printReceipt( + context: context, + ref: ref, + transactionId: transactionId, + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.print_outlined), + SizedBox(width: 8), + Text('Print PDF'), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), ), ), ); @@ -145,9 +200,10 @@ class TransactionReceiptScreen extends ConsumerWidget { } class _ReceiptViewport extends StatelessWidget { - const _ReceiptViewport({required this.child}); + const _ReceiptViewport({required this.child, this.padding}); final Widget child; + final EdgeInsetsGeometry? padding; @override Widget build(BuildContext context) { @@ -156,26 +212,19 @@ class _ReceiptViewport extends StatelessWidget { final size = MediaQuery.sizeOf(context); final maxHeight = size.height * 0.60; - // Flutter layout uses logical pixels, not real millimeters. We approximate - // "58mm slip" width using the Android baseline of 160dp/in. - const receiptWidthMm = 58.0; - const mmPerInch = 25.4; - const dpPerInch = 160.0; - const receiptWidthDp = receiptWidthMm / mmPerInch * dpPerInch; - return Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16), + padding: + padding ?? EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16), child: LayoutBuilder( builder: (context, constraints) { - final double targetWidth = - receiptWidthDp.clamp(0, constraints.maxWidth).toDouble(); - final double targetHeight = - maxHeight.clamp(0, constraints.maxHeight).toDouble(); + final double targetHeight = maxHeight + .clamp(0, constraints.maxHeight) + .toDouble(); return Align( alignment: Alignment.center, child: SizedBox( - width: targetWidth, + width: 230, height: targetHeight, child: ClipRRect( borderRadius: BorderRadius.circular(16), @@ -196,6 +245,178 @@ class _ReceiptViewport extends StatelessWidget { } } +Future _downloadReceipt({ + required BuildContext context, + required WidgetRef ref, + required String transactionId, +}) async { + try { + final sanitizedId = transactionId.replaceAll( + RegExp(r'[^A-Za-z0-9_-]'), + '_', + ); + final stamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + + final Uint8List bytes = await _runWithProgress( + context, + message: 'Preparing receipt…', + task: () => _fetchReceiptPdfBytes( + ref: ref, + transactionId: transactionId, + copyFor: 'Merchant', + ), + ); + + final candidateBaseDirs = []; + if (!kIsWeb) { + if (Platform.isAndroid) { + final dir = await getExternalStorageDirectory(); + if (dir != null) { + candidateBaseDirs.add(dir); + } + } + final downloads = await getDownloadsDirectory(); + if (downloads != null) { + candidateBaseDirs.add(downloads); + } + } + candidateBaseDirs.add(await getApplicationDocumentsDirectory()); + + File? outFile; + Object? lastError; + for (final baseDir in candidateBaseDirs) { + try { + final receiptsDir = Directory( + '${baseDir.path}${Platform.pathSeparator}receipts', + ); + if (!await receiptsDir.exists()) { + await receiptsDir.create(recursive: true); + } + + final candidate = File( + '${receiptsDir.path}${Platform.pathSeparator}receipt_${sanitizedId}_merchant_$stamp.pdf', + ); + await candidate.writeAsBytes(bytes, flush: true); + outFile = candidate; + break; + } catch (e) { + lastError = e; + } + } + + if (outFile == null) { + throw lastError ?? Exception('Failed to save receipt PDF'); + } + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Saved: ${outFile.path}'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Download failed: $e'))); + } + } +} + +Future _printReceipt({ + required BuildContext context, + required WidgetRef ref, + required String transactionId, +}) async { + try { + final Uint8List bytes = await _runWithProgress( + context, + message: 'Preparing print…', + task: () => _fetchReceiptPdfBytes( + ref: ref, + transactionId: transactionId, + copyFor: 'Merchant', + ), + ); + + await Printing.layoutPdf(onLayout: (_) async => bytes); + } catch (e) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Print failed: $e'))); + } +} + +Future _fetchReceiptPdfBytes({ + required WidgetRef ref, + required String transactionId, + required String copyFor, +}) async { + final sessionUser = ref.read(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final content = await ref.read(receiptRepositoryProvider).getTransactionReceipt( + token: sessionUser.token, + transactionId: transactionId, + copyFor: copyFor, + ); + + switch (content) { + case ReceiptPdfContent(): + return content.bytes; + case ReceiptHtmlContent(): + try { + return await Printing.convertHtml(html: _wrapHtml(content.html)); + } catch (e) { + throw Exception('Server did not return PDF, and HTML→PDF failed: $e'); + } + } +} + +Future _runWithProgress( + BuildContext context, { + required String message, + required Future Function() task, +}) async { + if (!context.mounted) { + return task(); + } + + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return PopScope( + canPop: false, + child: AlertDialog( + content: Row( + children: [ + const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + ), + ); + }, + ), + ); + + try { + return await task(); + } finally { + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + } +} + class _ReceiptHtmlWebView extends StatefulWidget { const _ReceiptHtmlWebView({required this.html}); @@ -212,16 +433,15 @@ class _ReceiptHtmlWebViewState extends State<_ReceiptHtmlWebView> { void initState() { super.initState(); - _controller = - WebViewController() - ..setJavaScriptMode(JavaScriptMode.disabled) - ..setBackgroundColor(Colors.transparent) - ..setNavigationDelegate( - NavigationDelegate( - onNavigationRequest: (request) => NavigationDecision.prevent, - ), - ) - ..loadHtmlString(_wrapHtml(widget.html)); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.disabled) + ..setBackgroundColor(Colors.transparent) + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) => NavigationDecision.prevent, + ), + ) + ..loadHtmlString(_wrapHtml(widget.html)); } @override diff --git a/pubspec.lock b/pubspec.lock index 60d8d40..da875ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" async: dependency: transitive description: @@ -9,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -123,6 +147,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -155,6 +184,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" leak_tracker: dependency: transitive description: @@ -243,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -291,6 +336,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: transitive + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -307,6 +376,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + printing: + dependency: "direct main" + description: + name: printing + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" + source: hosted + version: "5.14.3" pub_semver: dependency: transitive description: @@ -315,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" riverpod: dependency: transitive description: @@ -456,6 +549,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5d316c9..c7cdf68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: flutter_pdfview: ^1.4.0 path_provider: ^2.1.5 webview_flutter: ^4.0.0 + printing: ^5.12.0 dev_dependencies: flutter_test: