diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 437ee30..e4ece6f 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -1,7 +1,7 @@ class AppConfig { const AppConfig._(); - static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; - // static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; + // static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; + static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; static const String apiSecret = 'y812J21lhha11OS'; } diff --git a/lib/core/network/api_key_http_client.dart b/lib/core/network/api_key_http_client.dart new file mode 100644 index 0000000..1ddf7ab --- /dev/null +++ b/lib/core/network/api_key_http_client.dart @@ -0,0 +1,24 @@ +import 'package:http/http.dart' as http; + +class ApiKeyHttpClient extends http.BaseClient { + ApiKeyHttpClient({required http.Client innerClient, required this.apiSecret}) + : _innerClient = innerClient; + + final http.Client _innerClient; + final String apiSecret; + + @override + Future send(http.BaseRequest request) { + if (apiSecret.trim().isNotEmpty && + !request.headers.keys.any((k) => k.toLowerCase() == 'x-api-key')) { + request.headers['x-api-key'] = apiSecret.trim(); + } + return _innerClient.send(request); + } + + @override + void close() { + _innerClient.close(); + super.close(); + } +} diff --git a/lib/core/network/logging_http_client.dart b/lib/core/network/logging_http_client.dart index 98ee174..c5ea7f5 100644 --- a/lib/core/network/logging_http_client.dart +++ b/lib/core/network/logging_http_client.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart' as http; class LoggingHttpClient extends http.BaseClient { LoggingHttpClient([http.Client? innerClient]) - : _innerClient = innerClient ?? http.Client(); + : _innerClient = innerClient ?? http.Client(); final http.Client _innerClient; @@ -17,7 +17,10 @@ class LoggingHttpClient extends http.BaseClient { try { final response = await _innerClient.send(request); final responseBytes = await response.stream.toBytes(); - final responseBody = utf8.decode(responseBytes); + final responseBody = _formatBodyForLogging( + responseBytes: responseBytes, + contentType: response.headers['content-type'], + ); _logResponse(response, responseBody); @@ -84,4 +87,32 @@ class LoggingHttpClient extends http.BaseClient { return safeHeaders; } + + String _formatBodyForLogging({ + required List responseBytes, + required String? contentType, + }) { + final normalizedType = contentType?.toLowerCase() ?? ''; + final isBinary = + normalizedType.contains('application/pdf') || + normalizedType.contains('application/octet-stream') || + normalizedType.startsWith('image/') || + normalizedType.startsWith('audio/') || + normalizedType.startsWith('video/'); + + if (isBinary) { + return ''; + } + + final decoded = utf8.decode(responseBytes, allowMalformed: true).trim(); + if (decoded.isEmpty) { + return ''; + } + + const maxChars = 4000; + if (decoded.length <= maxChars) { + return decoded; + } + return '${decoded.substring(0, maxChars)}…'; + } } diff --git a/lib/data/repositories/api_auth_repository.dart b/lib/data/repositories/api_auth_repository.dart index bf171e2..2e81105 100644 --- a/lib/data/repositories/api_auth_repository.dart +++ b/lib/data/repositories/api_auth_repository.dart @@ -20,7 +20,7 @@ class ApiAuthRepository implements AuthRepository { required String username, required String password, }) async { - final uri = Uri.parse('$baseUrl/receipt/auth/login'); + final uri = Uri.parse('$baseUrl/auth/login'); final requestBody = jsonEncode({ 'username': username.trim().toLowerCase(), 'password': password, @@ -51,7 +51,7 @@ class ApiAuthRepository implements AuthRepository { required String refreshToken, required String role, }) async { - final uri = Uri.parse('$baseUrl/receipt/auth/refresh-token'); + final uri = Uri.parse('$baseUrl/auth/refresh-token'); final requestBody = jsonEncode({ 'refreshToken': refreshToken, }); diff --git a/lib/data/repositories/api_merchant_repository.dart b/lib/data/repositories/api_merchant_repository.dart index d5d6ef2..5582d9b 100644 --- a/lib/data/repositories/api_merchant_repository.dart +++ b/lib/data/repositories/api_merchant_repository.dart @@ -17,8 +17,20 @@ class ApiMerchantRepository implements MerchantRepository { final http.Client _client; @override - Future> getMerchants({required String token}) async { - final uri = Uri.parse('$baseUrl/receipt/merchant'); + Future> getMerchants({ + required String token, + int page = 1, + int limit = 10, + String searchTerm = '', + }) async { + final queryParameters = { + 'page': page.toString(), + 'limit': limit.toString(), + if (searchTerm.trim().isNotEmpty) 'searchTerm': searchTerm.trim(), + }; + final uri = Uri.parse( + '$baseUrl/merchant', + ).replace(queryParameters: queryParameters); final response = await _client.get( uri, headers: { @@ -40,7 +52,7 @@ class ApiMerchantRepository implements MerchantRepository { required String token, required String merchantId, }) async { - final uri = Uri.parse('$baseUrl/receipt/terminal/list/$merchantId'); + final uri = Uri.parse('$baseUrl/terminal/list/$merchantId'); final response = await _client.get( uri, headers: { @@ -172,10 +184,10 @@ class ApiMerchantRepository implements MerchantRepository { mids: _pickStringList(item, const ['mid', 'mids']), merchantId: _pickString(item, const ['merchantId', 'merchant_id']), status: _pickString(item, const ['status']), - totalTransactions: _pickInt( - item, - const ['totalTransactions', 'total_transactions'], - ), + totalTransactions: _pickInt(item, const [ + 'totalTransactions', + 'total_transactions', + ]), totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']), createdAt: _pickString(item, const ['createdAt', 'created_at']), updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), @@ -247,7 +259,8 @@ class ApiMerchantRepository implements MerchantRepository { String _extractErrorMessage(String body) { final decoded = _tryDecode(body); if (decoded is Map) { - final message = decoded['message'] ?? decoded['error'] ?? decoded['detail']; + final message = + decoded['message'] ?? decoded['error'] ?? decoded['detail']; if (message != null) { return message.toString(); } diff --git a/lib/data/repositories/api_receipt_repository.dart b/lib/data/repositories/api_receipt_repository.dart new file mode 100644 index 0000000..a6f663b --- /dev/null +++ b/lib/data/repositories/api_receipt_repository.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; +import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart'; +import 'package:http/http.dart' as http; + +class ApiReceiptRepository implements ReceiptRepository { + ApiReceiptRepository({ + required this.baseUrl, + required this.apiSecret, + required http.Client client, + }) : _client = client; + + final String baseUrl; + final String apiSecret; + final http.Client _client; + + @override + Future getTransactionReceipt({ + 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,text/html', + '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 (${streamed.statusCode}) $contentType', + ); + } + + if (_looksLikePdf(bytes: bytes, contentType: contentType)) { + return ReceiptPdfContent(bytes); + } + + final html = const Utf8Decoder(allowMalformed: true).convert(bytes); + return ReceiptHtmlContent(html: html, contentType: contentType); + } + + Uri _normalizeLocalhostForAndroid(Uri uri) { + if (!Platform.isAndroid) { + return uri; + } + if (uri.host != 'localhost') { + return uri; + } + return uri.replace(host: '10.0.2.2'); + } + + bool _looksLikePdf({required List bytes, required String contentType}) { + final type = contentType.toLowerCase(); + if (type.contains('application/pdf')) { + return true; + } + if (bytes.length < 4) { + return false; + } + return bytes[0] == 0x25 && // % + bytes[1] == 0x50 && // P + bytes[2] == 0x44 && // D + bytes[3] == 0x46; // F + } +} diff --git a/lib/data/repositories/api_transaction_repository.dart b/lib/data/repositories/api_transaction_repository.dart index 73ea711..ee2e1cf 100644 --- a/lib/data/repositories/api_transaction_repository.dart +++ b/lib/data/repositories/api_transaction_repository.dart @@ -21,7 +21,7 @@ class ApiTransactionRepository implements TransactionRepository { required String serial, required String range, }) async { - final uri = Uri.parse('$baseUrl/receipt/transaction').replace( + final uri = Uri.parse('$baseUrl/transaction').replace( queryParameters: { 'serial': serial, 'range': range, diff --git a/lib/domain/entities/receipt_content.dart b/lib/domain/entities/receipt_content.dart new file mode 100644 index 0000000..949213a --- /dev/null +++ b/lib/domain/entities/receipt_content.dart @@ -0,0 +1,18 @@ +import 'dart:typed_data'; + +sealed class ReceiptContent { + const ReceiptContent(); +} + +class ReceiptPdfContent extends ReceiptContent { + const ReceiptPdfContent(this.bytes); + + final Uint8List bytes; +} + +class ReceiptHtmlContent extends ReceiptContent { + const ReceiptHtmlContent({required this.html, required this.contentType}); + + final String html; + final String contentType; +} diff --git a/lib/domain/repositories/merchant_repository.dart b/lib/domain/repositories/merchant_repository.dart index ab40216..a41a566 100644 --- a/lib/domain/repositories/merchant_repository.dart +++ b/lib/domain/repositories/merchant_repository.dart @@ -2,7 +2,12 @@ import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart'; abstract class MerchantRepository { - Future> getMerchants({required String token}); + Future> getMerchants({ + required String token, + int page = 1, + int limit = 10, + String searchTerm = '', + }); Future> getTerminalsByMerchantId({ required String token, diff --git a/lib/domain/repositories/receipt_repository.dart b/lib/domain/repositories/receipt_repository.dart new file mode 100644 index 0000000..07bb68d --- /dev/null +++ b/lib/domain/repositories/receipt_repository.dart @@ -0,0 +1,9 @@ +import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; + +abstract class ReceiptRepository { + Future getTransactionReceipt({ + required String token, + required String transactionId, + required String copyFor, + }); +} diff --git a/lib/main.dart b/lib/main.dart index d6b0cd3..4177846 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ import 'package:e_receipt_mobile/core/theme/app_colors.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:flutter/material.dart'; @@ -65,7 +67,20 @@ class EReceiptApp extends ConsumerWidget { ), useMaterial3: true, ), - home: const LoginPage(), + home: const AppRoot(), ); } } + +class AppRoot extends ConsumerWidget { + const AppRoot({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(sessionControllerProvider); + if (user == null) { + return const LoginPage(); + } + return HomeScreen(user: user); + } +} diff --git a/lib/presentation/home/home_pagination_providers.dart b/lib/presentation/home/home_pagination_providers.dart new file mode 100644 index 0000000..b022413 --- /dev/null +++ b/lib/presentation/home/home_pagination_providers.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final merchantPageSizeProvider = Provider((ref) => 10); + +final merchantSearchQueryProvider = StateProvider.autoDispose((ref) { + return ''; +}); diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 52af276..beac438 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -1,8 +1,10 @@ -import 'package:e_receipt_mobile/core/theme/app_colors.dart'; 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/home/home_view_model.dart'; -import 'package:e_receipt_mobile/presentation/login/login_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'; +import 'package:e_receipt_mobile/presentation/home/widgets/merchant_header.dart'; +import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.dart'; import 'package:e_receipt_mobile/presentation/settings/settings_screen.dart'; import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; import 'package:flutter/material.dart'; @@ -15,218 +17,190 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final merchantsAsync = ref.watch(merchantListProvider); + final pagingState = ref.watch(merchantPagingViewModelProvider); + final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier); + final colorScheme = Theme.of(context).colorScheme; + final query = ref.watch(merchantSearchQueryProvider); return Scaffold( appBar: AppBar( - title: Text("Merchants"), - centerTitle: true, - ), - drawer: Drawer( - backgroundColor: Colors.white, - - child: Column( - children: [ - UserAccountsDrawerHeader( - decoration: const BoxDecoration(color: AppColors.primary), - accountName: Text( - user.username, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - ), - ), - accountEmail: Text( - 'Role: ${user.role}', - style: const TextStyle(color: Colors.white70), - ), - currentAccountPicture: CircleAvatar( - backgroundColor: Colors.white, - child: Text( - (user.username.isNotEmpty ? user.username[0] : 'U') - .toUpperCase(), - style: const TextStyle(color: AppColors.primary), - ), - ), - ), - ListTile( - leading: const Icon(Icons.person_outline, color: Colors.black87), - title: const Text( - 'Profile', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - ), - onTap: () { - Navigator.of(context).pop(); - _showProfile(context); - }, - ), - // ListTile( - // leading: const Icon(Icons.storefront_outlined), - // title: const Text('Merchants'), - // onTap: () => Navigator.of(context).pop(), - // ), - ListTile( - leading: const Icon(Icons.analytics_outlined, color: Colors.black87), - title: const Text( - 'Reports', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - ), - onTap: () { - Navigator.of(context).pop(); - _showComingSoon(context, 'Reports'); - }, - ), - ListTile( - leading: const Icon(Icons.settings_outlined, color: Colors.black87), - title: const Text( - 'Settings', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - ), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const SettingsScreen(), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.help_outline, color: Colors.black87), - title: const Text( - 'Help', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w500, - ), - ), - onTap: () { - Navigator.of(context).pop(); - _showComingSoon(context, 'Help'); - }, - ), - const Spacer(), - const Divider(height: 1, color: Colors.black12), - ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - minTileHeight: 64, - leading: const Icon(Icons.logout, color: Colors.black87), - title: const Text( - 'Logout', - style: TextStyle( - color: Colors.black87, - fontWeight: FontWeight.w600, - ), - ), - onTap: () async { - final shouldLogout = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Logout'), - content: const Text('Are you sure want to logout?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Logout'), - ), - ], - ); - }, - ); - - if (shouldLogout != true || !context.mounted) { - return; - } - - ref.read(sessionControllerProvider.notifier).clearUser(); - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute( - builder: (_) => const LoginPage(), - ), - (route) => false, - ); - }, - ), - ], + title: const Text('Merchants'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: pagingState.isLoading + ? null + : () => pagingViewModel.refresh(), + icon: const Icon(Icons.refresh), ), + ], ), - body: Padding( - padding: const EdgeInsets.all(16), - child: merchantsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text('Failed to load merchants: $error'), - ), - data: (merchants) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + drawer: HomeDrawer( + user: user, + onProfile: () { + Navigator.of(context).pop(); + _showProfile(context, user); + }, + onReports: () { + Navigator.of(context).pop(); + _showComingSoon(context, 'Reports'); + }, + onSettings: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ); + }, + onHelp: () { + Navigator.of(context).pop(); + _showComingSoon(context, 'Help'); + }, + onLogout: () 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'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Logout'), + ), + ], + ); + }, + ); + + if (shouldLogout != true || !context.mounted) { + return; + } + + Navigator.of(context).pop(); // close drawer + ref.read(sessionControllerProvider.notifier).clearUser(); + }, + ), + body: pagingState.isLoading && pagingState.items.isEmpty + ? const Center(child: CircularProgressIndicator()) + : pagingState.errorMessage != null && pagingState.items.isEmpty + ? Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 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 merchants', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + pagingState.errorMessage!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: pagingViewModel.refresh, + child: const Text('Try again'), + ), + ], + ), + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - 'Total (${merchants.length})', - style: Theme.of(context).textTheme.titleLarge, + MerchantHeader( + loadedCount: pagingState.items.length, + query: query, + onQueryChanged: (value) { + ref.read(merchantSearchQueryProvider.notifier).state = + value; + pagingViewModel.setSearchTerm(value); + }, + onClear: () { + ref.read(merchantSearchQueryProvider.notifier).state = ''; + pagingViewModel.setSearchTerm(''); + }, ), - const SizedBox(height: 8), Expanded( - child: RefreshIndicator( - onRefresh: () => ref.refresh(merchantListProvider.future), - child: merchants.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox(height: 120), - Center(child: Text('No merchants found')), - ], - ) - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - itemCount: merchants.length, - separatorBuilder: (_, __) => - const SizedBox(height: 10), - itemBuilder: (context, index) { - final merchant = merchants[index]; - return Card( - child: ListTile( - onTap: merchant.id == null - ? null - : () => _openTerminalSelection( - context, - merchant.id!, - merchant.name ?? '-', - ), - leading: const Icon(Icons.storefront_outlined), - trailing: const Icon(Icons.chevron_right), - title: Text( - '${index + 1}. ${merchant.name ?? '-'}', - ), - subtitle: Text(merchant.address ?? '-'), - ), - ); - }, - ), + child: MerchantListView( + merchants: pagingState.items, + hasMore: pagingState.hasMore, + isLoadingMore: pagingState.isLoadingMore, + onRefresh: pagingViewModel.refresh, + onMerchantTap: (merchant) { + final id = merchant.id; + if (id == null) { + return; + } + _openTerminalSelection(context, id, merchant.name ?? '-'); + }, + onLoadMore: pagingViewModel.loadMore, + onEndReached: pagingViewModel.loadMore, ), ), + if (pagingState.errorMessage != null && + pagingState.items.isNotEmpty) + 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'), + ), + ], + ), + ), + ), + ), ], - ); - }, - ), - ), + ), ); } @@ -236,7 +210,7 @@ class HomeScreen extends ConsumerWidget { ).showSnackBar(SnackBar(content: Text('$feature is coming soon'))); } - void _showProfile(BuildContext context) { + void _showProfile(BuildContext context, LoginUser user) { showDialog( context: context, builder: (context) { diff --git a/lib/presentation/home/home_view_model.dart b/lib/presentation/home/home_view_model.dart index 6f7569b..8ccbd89 100644 --- a/lib/presentation/home/home_view_model.dart +++ b/lib/presentation/home/home_view_model.dart @@ -1,6 +1,5 @@ import 'package:e_receipt_mobile/core/config/app_config.dart'; import 'package:e_receipt_mobile/data/repositories/api_merchant_repository.dart'; -import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; @@ -15,7 +14,10 @@ final merchantRepositoryProvider = Provider((ref) { ); }); -final merchantListProvider = FutureProvider>((ref) async { +final terminalListProvider = FutureProvider.family, String>(( + ref, + merchantId, +) async { final sessionUser = ref.watch(sessionControllerProvider); if (sessionUser == null) { throw Exception('No active session'); @@ -23,20 +25,8 @@ final merchantListProvider = FutureProvider>((ref) async { return ref .watch(merchantRepositoryProvider) - .getMerchants(token: sessionUser.token); + .getTerminalsByMerchantId( + token: sessionUser.token, + merchantId: merchantId, + ); }); - -final terminalListProvider = - FutureProvider.family, String>((ref, merchantId) async { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } - - return ref - .watch(merchantRepositoryProvider) - .getTerminalsByMerchantId( - token: sessionUser.token, - merchantId: merchantId, - ); - }); diff --git a/lib/presentation/home/merchant_paging_state.dart b/lib/presentation/home/merchant_paging_state.dart new file mode 100644 index 0000000..06c4612 --- /dev/null +++ b/lib/presentation/home/merchant_paging_state.dart @@ -0,0 +1,49 @@ +import 'package:e_receipt_mobile/domain/entities/merchant.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class MerchantPagingState { + const MerchantPagingState({ + this.items = const [], + this.page = 1, + this.limit = 10, + this.searchTerm = '', + this.isLoading = false, + this.isLoadingMore = false, + this.hasMore = true, + this.errorMessage, + }); + + final List items; + final int page; + final int limit; + final String searchTerm; + final bool isLoading; + final bool isLoadingMore; + final bool hasMore; + final String? errorMessage; + + MerchantPagingState copyWith({ + List? items, + int? page, + int? limit, + String? searchTerm, + bool? isLoading, + bool? isLoadingMore, + bool? hasMore, + String? errorMessage, + bool clearError = false, + }) { + return MerchantPagingState( + items: items ?? this.items, + page: page ?? this.page, + limit: limit ?? this.limit, + searchTerm: searchTerm ?? this.searchTerm, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + hasMore: hasMore ?? this.hasMore, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + ); + } +} + diff --git a/lib/presentation/home/merchant_paging_view_model.dart b/lib/presentation/home/merchant_paging_view_model.dart new file mode 100644 index 0000000..bede687 --- /dev/null +++ b/lib/presentation/home/merchant_paging_view_model.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart'; +import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; +import 'package:e_receipt_mobile/presentation/home/merchant_paging_state.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final merchantPagingViewModelProvider = + StateNotifierProvider.autoDispose< + MerchantPagingViewModel, + MerchantPagingState + >((ref) { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final limit = ref.watch(merchantPageSizeProvider); + final viewModel = MerchantPagingViewModel( + repository: ref.watch(merchantRepositoryProvider), + token: sessionUser.token, + limit: limit, + searchTerm: '', + ); + ref.onDispose(viewModel.dispose); + viewModel.loadInitial(); + return viewModel; + }); + +class MerchantPagingViewModel extends StateNotifier { + MerchantPagingViewModel({ + required MerchantRepository repository, + required String token, + required int limit, + required String searchTerm, + }) : _repository = repository, + _token = token, + super(MerchantPagingState(limit: limit, searchTerm: searchTerm)); + + final MerchantRepository _repository; + final String _token; + + Timer? _searchDebounce; + + void dispose() { + _searchDebounce?.cancel(); + } + + Future loadInitial() async { + await refresh(searchTerm: state.searchTerm); + } + + Future refresh({String? searchTerm}) async { + final nextSearch = searchTerm ?? state.searchTerm; + state = state.copyWith( + isLoading: true, + isLoadingMore: false, + page: 1, + items: const [], + hasMore: true, + searchTerm: nextSearch, + clearError: true, + ); + + try { + final results = await _repository.getMerchants( + token: _token, + page: 1, + limit: state.limit, + searchTerm: nextSearch, + ); + 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: ', ''), + ); + } + } + + void setSearchTerm(String value) { + final next = value; + if (next == state.searchTerm) { + return; + } + + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 350), () { + refresh(searchTerm: next); + }); + } + + 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.getMerchants( + token: _token, + page: nextPage, + limit: state.limit, + searchTerm: state.searchTerm, + ); + + final merged = [...state.items, ...results]; + state = state.copyWith( + isLoadingMore: false, + items: merged, + page: nextPage, + hasMore: results.length >= state.limit, + ); + } catch (error) { + state = state.copyWith( + isLoadingMore: false, + errorMessage: error.toString().replaceFirst('Exception: ', ''), + ); + } + } +} diff --git a/lib/presentation/home/widgets/home_drawer.dart b/lib/presentation/home/widgets/home_drawer.dart new file mode 100644 index 0000000..219f865 --- /dev/null +++ b/lib/presentation/home/widgets/home_drawer.dart @@ -0,0 +1,173 @@ +import 'package:e_receipt_mobile/core/theme/app_colors.dart'; +import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:flutter/material.dart'; + +class HomeDrawer extends StatelessWidget { + const HomeDrawer({ + required this.user, + required this.onProfile, + required this.onReports, + required this.onSettings, + required this.onHelp, + required this.onLogout, + super.key, + }); + + final LoginUser user; + final VoidCallback onProfile; + final VoidCallback onReports; + final VoidCallback onSettings; + final VoidCallback onHelp; + final VoidCallback onLogout; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Drawer( + child: SafeArea( + child: Column( + children: [ + _DrawerHeader(user: user), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + _DrawerItem( + icon: Icons.person_outline, + label: 'Profile', + onTap: onProfile, + ), + _DrawerItem( + icon: Icons.analytics_outlined, + label: 'Reports', + onTap: onReports, + ), + _DrawerItem( + icon: Icons.settings_outlined, + label: 'Settings', + onTap: onSettings, + ), + _DrawerItem( + icon: Icons.help_outline, + label: 'Help', + onTap: onHelp, + ), + ], + ), + ), + Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withOpacity(0.6), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: _DrawerItem( + icon: Icons.logout, + label: 'Logout', + isDestructive: true, + onTap: onLogout, + ), + ), + ], + ), + ), + ); + } +} + +class _DrawerHeader extends StatelessWidget { + const _DrawerHeader({required this.user}); + + final LoginUser user; + + @override + Widget build(BuildContext context) { + final initials = (user.username.isNotEmpty ? user.username[0] : 'U') + .toUpperCase(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, Color(0xFF1E5FD6)], + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 22, + backgroundColor: Colors.white, + child: Text( + initials, + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + const SizedBox(height: 2), + Text( + 'Role: ${user.role}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _DrawerItem extends StatelessWidget { + const _DrawerItem({ + required this.icon, + required this.label, + required this.onTap, + this.isDestructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool isDestructive; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = isDestructive + ? colorScheme.error + : colorScheme.onSurface; + + return ListTile( + leading: Icon(icon, color: foreground), + title: Text( + label, + style: TextStyle(fontWeight: FontWeight.w600, color: foreground), + ), + onTap: onTap, + ); + } +} diff --git a/lib/presentation/home/widgets/merchant_header.dart b/lib/presentation/home/widgets/merchant_header.dart new file mode 100644 index 0000000..8009d62 --- /dev/null +++ b/lib/presentation/home/widgets/merchant_header.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +class MerchantHeader extends StatelessWidget { + const MerchantHeader({ + required this.loadedCount, + required this.query, + required this.onQueryChanged, + required this.onClear, + super.key, + }); + + final int loadedCount; + final String query; + final ValueChanged onQueryChanged; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Merchants', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + 'Select a merchant to continue', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Chip( + label: Text('$loadedCount'), + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surfaceContainerHigh, + ), + ], + ), + const SizedBox(height: 12), + SearchBar( + leading: const Icon(Icons.search), + hintText: 'Search merchants', + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 14), + ), + backgroundColor: WidgetStatePropertyAll( + colorScheme.surfaceContainerHigh, + ), + elevation: const WidgetStatePropertyAll(0), + constraints: const BoxConstraints(minHeight: 52), + trailing: [ + if (query.isNotEmpty) + IconButton( + tooltip: 'Clear', + onPressed: onClear, + icon: const Icon(Icons.close), + ), + ], + onChanged: onQueryChanged, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/home/widgets/merchant_list_view.dart b/lib/presentation/home/widgets/merchant_list_view.dart new file mode 100644 index 0000000..38f5000 --- /dev/null +++ b/lib/presentation/home/widgets/merchant_list_view.dart @@ -0,0 +1,233 @@ +import 'package:e_receipt_mobile/domain/entities/merchant.dart'; +import 'package:flutter/material.dart'; + +class MerchantListView extends StatelessWidget { + const MerchantListView({ + required this.merchants, + required this.onRefresh, + required this.onMerchantTap, + required this.hasMore, + required this.isLoadingMore, + this.onLoadMore, + this.onEndReached, + this.endReachedThreshold = 240, + super.key, + }); + + final List merchants; + final Future Function() onRefresh; + final void Function(Merchant merchant) onMerchantTap; + final bool hasMore; + final bool isLoadingMore; + final VoidCallback? onLoadMore; + final VoidCallback? onEndReached; + final double endReachedThreshold; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final colorScheme = Theme.of(context).colorScheme; + + final list = merchants.isEmpty + ? ListView( + padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Icon( + Icons.storefront_outlined, + size: 56, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + 'No merchants found', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + 'Try a different search term or pull down to refresh.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : ListView.separated( + padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: merchants.length + (hasMore ? 1 : 0), + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index >= merchants.length) { + return _PaginationFooter( + isLoadingMore: isLoadingMore, + onLoadMore: onLoadMore, + ); + } + + final merchant = merchants[index]; + final name = merchant.name?.trim().isEmpty ?? true + ? '-' + : merchant.name!.trim(); + final address = (merchant.address?.trim().isEmpty ?? true) + ? 'No address' + : merchant.address!.trim(); + final enabled = merchant.id != null; + + 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: enabled ? () => onMerchantTap(merchant) : null, + 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: enabled + ? colorScheme.primary.withOpacity(0.14) + : colorScheme.surfaceContainerHigh, + ), + child: Icon( + Icons.storefront_outlined, + color: enabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + color: enabled + ? null + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant + .withOpacity(0.8), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withOpacity(0.4), + ), + ], + ), + ), + ), + ); + }, + ); + + final wrapped = onEndReached == null || merchants.isEmpty + ? list + : NotificationListener( + onNotification: (notification) { + if (!hasMore || 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 - endReachedThreshold) { + onEndReached?.call(); + } + return false; + }, + child: list, + ); + + return RefreshIndicator.adaptive(onRefresh: onRefresh, child: wrapped); + } +} + +class _PaginationFooter extends StatelessWidget { + const _PaginationFooter({ + 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'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/login/login_form_providers.dart b/lib/presentation/login/login_form_providers.dart new file mode 100644 index 0000000..49af0f9 --- /dev/null +++ b/lib/presentation/login/login_form_providers.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final loginFormKeyProvider = Provider.autoDispose>((ref) { + return GlobalKey(); +}); + +final loginUsernameControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final loginPasswordControllerProvider = + Provider.autoDispose((ref) { + final controller = TextEditingController(); + ref.onDispose(controller.dispose); + return controller; + }); + +final loginUsernameFocusNodeProvider = Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final loginPasswordFocusNodeProvider = Provider.autoDispose((ref) { + final focusNode = FocusNode(); + ref.onDispose(focusNode.dispose); + return focusNode; +}); + +final loginObscurePasswordProvider = StateProvider.autoDispose( + (ref) => true, +); + +final loginAttemptedSubmitProvider = StateProvider.autoDispose( + (ref) => false, +); diff --git a/lib/presentation/login/login_page.dart b/lib/presentation/login/login_page.dart index e44653b..4157782 100644 --- a/lib/presentation/login/login_page.dart +++ b/lib/presentation/login/login_page.dart @@ -1,142 +1,288 @@ -import 'package:e_receipt_mobile/presentation/components/rounded_input.dart'; -import 'package:e_receipt_mobile/presentation/home/home_screen.dart'; +import 'package:e_receipt_mobile/presentation/login/login_form_providers.dart'; +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:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -class LoginPage extends ConsumerStatefulWidget { +class LoginPage extends ConsumerWidget { const LoginPage({super.key}); - @override - ConsumerState createState() => _LoginPageState(); -} + void _submit({ + required WidgetRef ref, + required LoginState state, + required GlobalKey formKey, + required TextEditingController usernameController, + required TextEditingController passwordController, + }) { + if (state.isLoading) { + return; + } -class _LoginPageState extends ConsumerState { - final _formKey = GlobalKey(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); + ref.read(loginViewModelProvider.notifier).clearMessages(); + FocusManager.instance.primaryFocus?.unfocus(); + ref.read(loginAttemptedSubmitProvider.notifier).state = true; - @override - void dispose() { - _usernameController.dispose(); - _passwordController.dispose(); - super.dispose(); + if (!(formKey.currentState?.validate() ?? false)) { + return; + } + + TextInput.finishAutofillContext(); + ref.read(loginViewModelProvider.notifier).login( + username: usernameController.text, + password: passwordController.text, + ); } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(loginViewModelProvider); + final colorScheme = Theme.of(context).colorScheme; - ref.listen(loginViewModelProvider, (previous, next) { - if (!mounted) { - return; - } - - if (next.errorMessage != null && - previous?.errorMessage != next.errorMessage) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(next.errorMessage!), - backgroundColor: Colors.red, - ), - ); - } - - if (next.successMessage != null && - next.user != null && - previous?.successMessage != next.successMessage) { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => HomeScreen(user: next.user!), - ), - ); - } - }); + final formKey = ref.watch(loginFormKeyProvider); + final usernameController = ref.watch(loginUsernameControllerProvider); + final passwordController = ref.watch(loginPasswordControllerProvider); + final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider); + final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider); + final obscurePassword = ref.watch(loginObscurePasswordProvider); + final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider); return Scaffold( - backgroundColor: Colors.white, - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(32), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Card( - elevation: 8, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'E-Receipt', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall, + 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), ), - const SizedBox(height: 20), - //Username - roundedInput( - controller: _usernameController, - hint: "Username", - icon: Icons.person, - validator: (value) { - if ((value ?? '').trim().isEmpty) { - return 'Username is required'; - } - return null; - }, - ), - const SizedBox(height: 12), - //Password - roundedInput( - controller: _passwordController, - hint: "Password", - icon: Icons.lock, - isPassword: true, - validator: (value) { - if ((value ?? '').isEmpty) { - return 'Password is required'; - } - if (value!.length < 6) { - return 'Password must be at least 6 characters'; - } - return null; - }, - ), - const SizedBox(height: 20), - FilledButton( - onPressed: state.isLoading - ? null - : () { - ref - .read(loginViewModelProvider.notifier) - .clearMessages(); - if (_formKey.currentState?.validate() ?? - false) { - ref - .read(loginViewModelProvider.notifier) - .login( - username: _usernameController.text, - password: _passwordController.text, - ); - } - }, - child: state.isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 18), + child: AutofillGroup( + 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: Icon( + Icons.receipt_long, + color: colorScheme.onPrimary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'E-Receipt', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + 'Sign in to continue', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + LoginErrorBanner(message: state.errorMessage), + if (state.errorMessage != null) + const SizedBox(height: 14), + TextFormField( + controller: usernameController, + focusNode: usernameFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.next, + autofillHints: const [AutofillHints.username], + onFieldSubmitted: (_) => + passwordFocusNode.requestFocus(), + validator: (value) { + if ((value ?? '').trim().isEmpty) { + return 'Username is required'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Username', + hintText: 'Enter your username', + prefixIcon: const Icon(Icons.person_outline), + filled: true, + fillColor: colorScheme.surfaceContainerHighest + .withOpacity( + Theme.of(context).brightness == + Brightness.dark + ? 0.55 + : 0.9, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + ), ), - ) - : const Text('Login'), + ), + const SizedBox(height: 12), + TextFormField( + controller: passwordController, + focusNode: passwordFocusNode, + enabled: !state.isLoading, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.password], + obscureText: obscurePassword, + onFieldSubmitted: (_) => _submit( + ref: ref, + state: state, + formKey: formKey, + usernameController: usernameController, + passwordController: passwordController, + ), + validator: (value) { + if ((value ?? '').isEmpty) { + return 'Password is required'; + } + if (value!.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + decoration: InputDecoration( + labelText: 'Password', + hintText: '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: obscurePassword + ? 'Show password' + : 'Hide password', + onPressed: state.isLoading + ? null + : () => ref + .read( + loginObscurePasswordProvider + .notifier, + ) + .state = + !obscurePassword, + icon: Icon( + obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: state.isLoading + ? null + : () => _submit( + ref: ref, + state: state, + formKey: formKey, + usernameController: usernameController, + passwordController: passwordController, + ), + icon: state.isLoading + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.login), + label: Padding( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + child: Text( + state.isLoading + ? 'Signing in...' + : 'Sign in', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 12), + Text( + "By continuing, you agree to your organization's policies.", + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), ), - ], + ), ), ), ), @@ -147,3 +293,4 @@ class _LoginPageState extends ConsumerState { ); } } + diff --git a/lib/presentation/login/login_state.dart b/lib/presentation/login/login_state.dart index 75924e0..69281e3 100644 --- a/lib/presentation/login/login_state.dart +++ b/lib/presentation/login/login_state.dart @@ -1,5 +1,7 @@ import 'package:e_receipt_mobile/domain/entities/login_user.dart'; +import 'package:flutter/foundation.dart'; +@immutable class LoginState { const LoginState({ this.isLoading = false, @@ -13,15 +15,14 @@ class LoginState { final String? successMessage; final LoginUser? user; - static const Object _unset = Object(); - LoginState copyWith({ bool? isLoading, String? errorMessage, String? successMessage, - Object? user = _unset, + LoginUser? user, bool clearError = false, bool clearSuccess = false, + bool clearUser = false, }) { return LoginState( isLoading: isLoading ?? this.isLoading, @@ -29,7 +30,12 @@ class LoginState { successMessage: clearSuccess ? null : successMessage ?? this.successMessage, - user: identical(user, _unset) ? this.user : user as LoginUser?, + user: clearUser ? null : user ?? this.user, ); } + + @override + String toString() { + return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user)'; + } } diff --git a/lib/presentation/login/login_view_model.dart b/lib/presentation/login/login_view_model.dart index f3049b5..963b5a1 100644 --- a/lib/presentation/login/login_view_model.dart +++ b/lib/presentation/login/login_view_model.dart @@ -1,4 +1,5 @@ import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/core/network/api_key_http_client.dart'; 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'; @@ -9,18 +10,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; final rawHttpClientProvider = Provider((ref) { - final client = LoggingHttpClient(); + final client = ApiKeyHttpClient( + innerClient: LoggingHttpClient(), + apiSecret: AppConfig.apiSecret, + ); ref.onDispose(client.close); return client; }); final authenticatedHttpClientProvider = Provider((ref) { final client = AuthHttpClient( - innerClient: LoggingHttpClient(), + innerClient: ApiKeyHttpClient( + innerClient: LoggingHttpClient(), + apiSecret: AppConfig.apiSecret, + ), accessTokenProvider: () => ref.read(sessionControllerProvider)?.token, - shouldRefreshOnUnauthorized: (uri) => - !uri.path.endsWith('/receipt/auth/login') && - !uri.path.endsWith('/receipt/auth/refresh-token'), + shouldRefreshOnUnauthorized: (uri) { + final path = uri.path; + return !(path.endsWith('/auth/login') || + path.endsWith('/auth/refresh-token') || + path.endsWith('/receipt/auth/login') || + path.endsWith('/receipt/auth/refresh-token')); + }, refreshAccessToken: () async { final sessionUser = ref.read(sessionControllerProvider); if (sessionUser == null) { @@ -28,11 +39,13 @@ final authenticatedHttpClientProvider = Provider((ref) { } try { - final refreshedUser = await ref.read(authRepositoryProvider).refreshSession( - username: sessionUser.username, - refreshToken: sessionUser.refreshToken, - role: sessionUser.role, - ); + final refreshedUser = await ref + .read(authRepositoryProvider) + .refreshSession( + username: sessionUser.username, + refreshToken: sessionUser.refreshToken, + role: sessionUser.role, + ); ref.read(sessionControllerProvider.notifier).setUser(refreshedUser); return refreshedUser.token; } catch (_) { @@ -68,7 +81,10 @@ class LoginViewModel extends StateNotifier { final AuthRepository _authRepository; final SessionController _sessionController; - Future login({required String username, required String password}) async { + Future login({ + required String username, + required String password, + }) async { final trimmedUsername = username.trim(); if (trimmedUsername.isEmpty || password.isEmpty) { state = state.copyWith( @@ -83,7 +99,7 @@ class LoginViewModel extends StateNotifier { isLoading: true, clearError: true, clearSuccess: true, - user: null, + clearUser: true, ); _sessionController.clearUser(); diff --git a/lib/presentation/login/widgets/login_background.dart b/lib/presentation/login/widgets/login_background.dart new file mode 100644 index 0000000..7df812c --- /dev/null +++ b/lib/presentation/login/widgets/login_background.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class LoginBackground extends StatelessWidget { + const LoginBackground({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary.withOpacity(0.18), + colorScheme.secondary.withOpacity(0.10), + colorScheme.surface, + ], + stops: const [0.0, 0.45, 1.0], + ), + ), + ), + ), + Positioned( + top: -120, + right: -120, + child: _GlowBlob(color: colorScheme.primary.withOpacity(0.20)), + ), + Positioned( + bottom: -150, + left: -150, + child: _GlowBlob(color: colorScheme.tertiary.withOpacity(0.18)), + ), + child, + ], + ); + } +} + +class _GlowBlob extends StatelessWidget { + const _GlowBlob({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + height: 280, + width: 280, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [ + BoxShadow(color: color, blurRadius: 80, spreadRadius: 40), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/login/widgets/login_error_banner.dart b/lib/presentation/login/widgets/login_error_banner.dart new file mode 100644 index 0000000..8ca00df --- /dev/null +++ b/lib/presentation/login/widgets/login_error_banner.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class LoginErrorBanner extends StatelessWidget { + const LoginErrorBanner({required this.message, super.key}); + + final String? message; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: message == null + ? const SizedBox.shrink() + : Container( + key: ValueKey(message), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withOpacity(0.9), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.error.withOpacity(0.25)), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/terminal/receipt_view_model.dart b/lib/presentation/terminal/receipt_view_model.dart new file mode 100644 index 0000000..3d958c1 --- /dev/null +++ b/lib/presentation/terminal/receipt_view_model.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/data/repositories/api_receipt_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/receipt_content.dart'; +import 'package:e_receipt_mobile/domain/repositories/receipt_repository.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/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TransactionReceiptQuery { + const TransactionReceiptQuery({ + required this.transactionId, + required this.copyFor, + }); + + final String transactionId; + final String copyFor; + + @override + bool operator ==(Object other) { + return other is TransactionReceiptQuery && + other.transactionId == transactionId && + other.copyFor == copyFor; + } + + @override + int get hashCode => Object.hash(transactionId, copyFor); +} + +sealed class ReceiptViewData { + const ReceiptViewData(); +} + +class ReceiptPdfViewData extends ReceiptViewData { + const ReceiptPdfViewData(this.file); + + final File file; +} + +class ReceiptHtmlViewData extends ReceiptViewData { + const ReceiptHtmlViewData(this.html); + + final String html; +} + +final receiptRepositoryProvider = Provider((ref) { + return ApiReceiptRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(authenticatedHttpClientProvider), + ); +}); + +final transactionReceiptViewDataProvider = + FutureProvider.family(( + ref, + query, + ) async { + try { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + final content = await ref + .watch(receiptRepositoryProvider) + .getTransactionReceipt( + token: sessionUser.token, + transactionId: query.transactionId, + copyFor: query.copyFor, + ); + + switch (content) { + case ReceiptPdfContent(): + final dir = await Directory.systemTemp.createTemp( + 'e_receipt_mobile', + ); + final file = File( + '${dir.path}/receipt_${query.transactionId}_${query.copyFor}.pdf', + ); + await file.writeAsBytes(content.bytes, flush: true); + return ReceiptPdfViewData(file); + case ReceiptHtmlContent(): + return ReceiptHtmlViewData(content.html); + } + } catch (e, st) { + debugPrint('[RECEIPT][ERROR] ${Error.safeToString(e)}\n$st'); + throw Exception(Error.safeToString(e)); + } + }); diff --git a/lib/presentation/terminal/terminal_next_screen.dart b/lib/presentation/terminal/terminal_next_screen.dart index d6c1a06..27b2a1b 100644 --- a/lib/presentation/terminal/terminal_next_screen.dart +++ b/lib/presentation/terminal/terminal_next_screen.dart @@ -26,141 +26,542 @@ class _TerminalNextScreenState extends ConsumerState { Widget build(BuildContext context) { final serial = widget.terminal.serial?.trim() ?? ''; final hasSerial = serial.isNotEmpty; + final query = TransactionQuery(serial: serial, range: _selectedRange); final transactionsAsync = hasSerial - ? ref.watch( - transactionListProvider( - TransactionQuery(serial: serial, range: _selectedRange), - ), - ) + ? ref.watch(transactionListProvider(query)) : null; + final colorScheme = Theme.of(context).colorScheme; + final terminalName = + _sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-'; return Scaffold( - appBar: AppBar(title: const Text('Terminal Transactions')), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Merchant: ${widget.merchantName}', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Terminal: ${widget.terminal.name ?? widget.terminal.tid ?? '-'}', - ), - const SizedBox(height: 4), - Text('TID: ${widget.terminal.tid ?? '-'}'), - const SizedBox(height: 4), - Text( - 'MIDs: ${(widget.terminal.mids == null || widget.terminal.mids!.isEmpty) ? '-' : widget.terminal.mids!.join(', ')}', - ), - const SizedBox(height: 4), - Text('Serial: ${widget.terminal.serial ?? '-'}'), - const SizedBox(height: 4), - Text('Address: ${widget.terminal.address ?? '-'}'), - const SizedBox(height: 16), - Row( - children: [ - Text( - 'Transactions', - style: Theme.of(context).textTheme.titleMedium, + appBar: AppBar(title: Text(terminalName)), + body: SafeArea( + child: RefreshIndicator.adaptive( + onRefresh: () async { + if (!hasSerial) { + return; + } + await ref.refresh(transactionListProvider(query).future); + }, + 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), + ), + ], + ), + ), + ), ), - const Spacer(), - const Text('Range: '), - const SizedBox(width: 8), - DropdownButton( - value: _selectedRange, - items: _ranges - .map( - (range) => DropdownMenuItem( - value: range, - child: Text(range), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + 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, ), - ) - .toList(), - onChanged: (value) { - if (value == null || value == _selectedRange) { - return; + ), + ], + ), + ), + ), + 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, + ), + ), + ], + ), + ), + ), + ), + ]; } - setState(() { - _selectedRange = value; - }); + + 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, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ]; }, ), - ], + ], + ), + ), + ), + ); + } + + 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' || + normalized == 'SUCCESS' || + normalized == 'PAY_SUCCESS') { + return 'SUCCESS'; + } + if (normalized == 'E' || normalized == 'FAILED') { + return 'FAILED'; + } + return normalized.isEmpty ? '-' : normalized; + } + + 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 _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(height: 8), - Expanded( - child: !hasSerial - ? const Center( - child: Text('Serial is required to load transactions'), - ) - : transactionsAsync!.when( - loading: () => - const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text('Failed to load transactions: $error'), - ), - data: (transactions) { - return RefreshIndicator( - onRefresh: () => ref.refresh( - transactionListProvider( - TransactionQuery( - serial: serial, - range: _selectedRange, - ), - ).future, - ), - child: transactions.isEmpty - ? ListView( - physics: - const AlwaysScrollableScrollPhysics(), - children: const [ - SizedBox(height: 120), - Center(child: Text('No transactions found')), - ], - ) - : ListView.separated( - physics: - const AlwaysScrollableScrollPhysics(), - itemCount: transactions.length, - separatorBuilder: (_, __) => - const SizedBox(height: 8), - itemBuilder: (context, index) { - final item = transactions[index]; - return Card( - child: ListTile( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - TransactionReceiptScreen( - merchantName: - widget.merchantName, - terminal: widget.terminal, - transaction: item, - ), - ), - ); - }, - title: Text( - 'Amount: ${item.amount?.toString() ?? '-'}', - ), - subtitle: Text( - 'Status: ${item.status ?? '-'} | Type: ${item.type ?? '-'} | RRN: ${item.rrn ?? '-'}\nTap to view receipt detail', - ), - trailing: - const Icon(Icons.receipt_long_outlined), - ), - ); - }, - ), - ); - }, - ), + ), + 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, ), ), ); diff --git a/lib/presentation/terminal/terminal_selection_screen.dart b/lib/presentation/terminal/terminal_selection_screen.dart index 7fbdd43..d504769 100644 --- a/lib/presentation/terminal/terminal_selection_screen.dart +++ b/lib/presentation/terminal/terminal_selection_screen.dart @@ -1,10 +1,12 @@ import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; +import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_header.dart'; +import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_list_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -class TerminalSelectionScreen extends ConsumerStatefulWidget { +class TerminalSelectionScreen extends ConsumerWidget { const TerminalSelectionScreen({ required this.merchantId, required this.merchantName, @@ -15,58 +17,92 @@ class TerminalSelectionScreen extends ConsumerStatefulWidget { final String merchantName; @override - ConsumerState createState() => - _TerminalSelectionScreenState(); -} - -class _TerminalSelectionScreenState - extends ConsumerState { - @override - Widget build(BuildContext context) { - final terminalsAsync = ref.watch(terminalListProvider(widget.merchantId)); + Widget build(BuildContext context, WidgetRef ref) { + final terminalsAsync = ref.watch(terminalListProvider(merchantId)); + final colorScheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( - title: Text(widget.merchantName), + title: Text(merchantName), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: terminalsAsync.isLoading + ? null + : () => ref.invalidate(terminalListProvider(merchantId)), + icon: const Icon(Icons.refresh), + ), + ], ), - body: terminalsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Center(child: Text('Failed to load terminals: $error')), - data: (terminals) { - return RefreshIndicator( - onRefresh: () => - ref.refresh(terminalListProvider(widget.merchantId).future), - child: terminals.isEmpty - ? ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - children: const [ - SizedBox(height: 120), - Center(child: Text('No terminals found')), - ], - ) - : ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), - itemCount: terminals.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final terminal = terminals[index]; - return Card( - child: ListTile( - onTap: () => _openNextScreen(context, terminal), - leading: const Icon(Icons.point_of_sale), - trailing: const Icon(Icons.chevron_right), - title: Text(terminal.name ?? terminal.tid ?? '-'), - subtitle: Text( - 'TID: ${terminal.tid ?? '-'} | MIDs: ${(terminal.mids == null || terminal.mids!.isEmpty) ? '-' : terminal.mids!.join(', ')}', - ), - ), + body: SafeArea( + bottom: true, + child: terminalsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 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 terminals', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + '$error', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: () => + ref.invalidate(terminalListProvider(merchantId)), + child: const Text('Try again'), + ), + ], + ), + ), + ), + data: (terminals) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TerminalHeader( + merchantName: merchantName, + totalCount: terminals.length, + ), + Expanded( + child: TerminalListView( + terminals: terminals, + emptyMessage: 'No terminals found', + onRefresh: () async { + await ref.refresh( + terminalListProvider(merchantId).future, ); }, + onTerminalTap: (terminal) => + _openNextScreen(context, terminal), ), - ); - }, + ), + ], + ); + }, + ), ), ); } @@ -74,10 +110,8 @@ class _TerminalSelectionScreenState void _openNextScreen(BuildContext context, Terminal terminal) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => TerminalNextScreen( - merchantName: widget.merchantName, - terminal: terminal, - ), + builder: (_) => + TerminalNextScreen(merchantName: merchantName, terminal: terminal), ), ); } diff --git a/lib/presentation/terminal/transaction_receipt_screen.dart b/lib/presentation/terminal/transaction_receipt_screen.dart index a74657c..1b965dd 100644 --- a/lib/presentation/terminal/transaction_receipt_screen.dart +++ b/lib/presentation/terminal/transaction_receipt_screen.dart @@ -1,8 +1,15 @@ +import 'dart:io'; + 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/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:webview_flutter/webview_flutter.dart'; -class TransactionReceiptScreen extends StatelessWidget { +class TransactionReceiptScreen extends ConsumerWidget { const TransactionReceiptScreen({ required this.merchantName, required this.terminal, @@ -15,130 +22,320 @@ class TransactionReceiptScreen extends StatelessWidget { final TransactionRecord transaction; @override - Widget build(BuildContext context) { - final raw = transaction.raw ?? const {}; - final amount = _first(raw, const ['DE4']) ?? transaction.amount?.toString(); - final currency = _first(raw, const ['DE49']) ?? 'MMK'; - final status = _statusLabel( - _first(raw, const ['description', 'DE39', 'status']) ?? transaction.status, + Widget build(BuildContext context, WidgetRef ref) { + final transactionId = transaction.id?.trim(); + if (transactionId == null || transactionId.isEmpty) { + return Scaffold( + appBar: AppBar(title: const Text('Receipt')), + body: const SafeArea( + child: Center(child: Text('Missing transaction id')), + ), + ); + } + + final pdfAsync = ref.watch( + transactionReceiptViewDataProvider( + TransactionReceiptQuery( + transactionId: transactionId, + copyFor: 'Merchant', + ), + ), ); return Scaffold( - appBar: AppBar(title: const Text('Receipt Detail')), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: DefaultTextStyle( - style: Theme.of(context).textTheme.bodyMedium!, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Text( - 'E-Receipt', - style: Theme.of(context).textTheme.titleLarge, - ), - ), - const SizedBox(height: 4), - Center(child: Text(merchantName)), - const SizedBox(height: 12), - const Text('--------------------------------'), - _line('Terminal', _first(raw, const ['DE41']) ?? terminal.tid), - _line('Merchant ID', _first(raw, const ['DE42'])), - _line('Serial', terminal.serial), - _line( - 'Invoice', - _first(raw, const ['invoice_number', 'invoiceNo']), - ), - _line('Date/Time', _first(raw, const ['CREATED_AT', 'de7_date'])), - _line('Trace No', _first(raw, const ['DE11'])), - _line('RRN', _first(raw, const ['DE37']) ?? transaction.rrn), - _line('Auth Code', _first(raw, const ['DE38'])), - _line('Type', _first(raw, const ['DE3']) ?? transaction.type), - const Text('--------------------------------'), - const SizedBox(height: 8), - Text( - 'AMOUNT', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text( - '${amount ?? '-'} $currency', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text( - status, - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.w700, - color: status == 'SUCCESS' - ? Colors.green.shade700 - : Colors.red.shade700, - ), - ), - const SizedBox(height: 12), - const Text('--------------------------------'), - Center( - child: Text( - 'Thank you', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], + appBar: AppBar( + title: const Text('Receipt'), + actions: [ + IconButton( + tooltip: 'Reload', + onPressed: () => ref.invalidate( + transactionReceiptViewDataProvider( + TransactionReceiptQuery( + transactionId: transactionId, + copyFor: 'Merchant', ), ), ), + icon: const Icon(Icons.refresh), ), + ], + ), + body: SafeArea( + bottom: true, + child: pdfAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Padding( + padding: EdgeInsets.fromLTRB( + 24, + 24, + 24, + MediaQuery.viewPaddingOf(context).bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.picture_as_pdf_outlined, size: 56), + const SizedBox(height: 12), + Text( + 'Failed to load receipt PDF', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + Error.safeToString(error), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 14), + FilledButton.tonal( + onPressed: () => ref.invalidate( + transactionReceiptViewDataProvider( + TransactionReceiptQuery( + transactionId: transactionId, + copyFor: 'Merchant', + ), + ), + ), + child: const Text('Try again'), + ), + ], + ), + ), + ), + 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)}'), + ), + ); + }, + onPageError: (page, error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'PDF page $page error: ${Error.safeToString(error)}', + ), + ), + ); + }, + ), + ), + ReceiptHtmlViewData() => _ReceiptViewport( + child: _ReceiptHtmlView(html: viewData.html), + ), + }, ), ), ); } +} + +class _ReceiptViewport extends StatelessWidget { + const _ReceiptViewport({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final viewPadding = MediaQuery.viewPaddingOf(context); + 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; - Widget _line(String label, String? value) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Expanded(child: Text(label)), - const SizedBox(width: 8), - Expanded( - child: Text( - value == null || value.trim().isEmpty ? '-' : value, - textAlign: TextAlign.right, + 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(); + + return Align( + alignment: Alignment.center, + child: SizedBox( + width: targetWidth, + height: targetHeight, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withOpacity(0.7), + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(16), + ), + child: SizedBox.expand(child: child), + ), + ), ), - ), - ], + ); + }, ), ); } +} - 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; +class _ReceiptHtmlWebView extends StatefulWidget { + const _ReceiptHtmlWebView({required this.html}); + + final String html; + + @override + State<_ReceiptHtmlWebView> createState() => _ReceiptHtmlWebViewState(); +} + +class _ReceiptHtmlWebViewState extends State<_ReceiptHtmlWebView> { + late final WebViewController _controller; + + @override + void initState() { + super.initState(); + + _controller = + WebViewController() + ..setJavaScriptMode(JavaScriptMode.disabled) + ..setBackgroundColor(Colors.transparent) + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) => NavigationDecision.prevent, + ), + ) + ..loadHtmlString(_wrapHtml(widget.html)); } - String _statusLabel(String? status) { - final normalized = (status ?? '').trim().toUpperCase(); - if (normalized == 'A' || normalized == 'SUCCESS' || normalized == 'PAY_SUCCESS') { - return 'SUCCESS'; - } - if (normalized == 'E' || normalized == 'FAILED') { - return 'FAILED'; - } - return normalized.isEmpty ? '-' : normalized; + @override + Widget build(BuildContext context) { + return WebViewWidget(controller: _controller); } } + +class _ReceiptHtmlView extends StatelessWidget { + const _ReceiptHtmlView({required this.html}); + + final String html; + + @override + Widget build(BuildContext context) { + final platform = defaultTargetPlatform; + final supportsWebView = + platform == TargetPlatform.android || platform == TargetPlatform.iOS; + if (!supportsWebView) { + return _ReceiptHtmlFallbackText(html: html); + } + + return _ReceiptHtmlWebView(html: html); + } +} + +class _ReceiptHtmlFallbackText extends StatelessWidget { + const _ReceiptHtmlFallbackText({required this.html}); + + final String html; + + @override + Widget build(BuildContext context) { + final plainText = _htmlToPlainText(html); + return Padding( + padding: const EdgeInsets.all(16), + child: SelectionArea( + child: DefaultTextStyle( + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: 'monospace', + height: 1.35, + ) ?? + const TextStyle(fontFamily: 'monospace', height: 1.35), + child: Text(plainText), + ), + ), + ); + } +} + +String _wrapHtml(String html) { + // Keep backend HTML intact but ensure a sane viewport and remove default + // margins so it looks like the web "receipt iframe" container. + return ''' + + + + + + + + $html + +'''; +} + +String _htmlToPlainText(String html) { + var s = html; + + s = s.replaceAll( + RegExp( + r'<(script|style)[^>]*>.*?', + caseSensitive: false, + dotAll: true, + ), + '', + ); + s = s.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + s = s.replaceAll( + RegExp( + r'', + caseSensitive: false, + ), + '\n', + ); + s = s.replaceAll(RegExp(r']*>', caseSensitive: false), '• '); + s = s.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + + s = s.replaceAll(RegExp(r'<[^>]+>'), ''); + + s = s.replaceAll(' ', ' '); + s = s.replaceAll('&', '&'); + s = s.replaceAll('<', '<'); + s = s.replaceAll('>', '>'); + s = s.replaceAll('"', '"'); + s = s.replaceAll(''', "'"); + + s = s.replaceAllMapped(RegExp(r'&#(\d+);'), (m) { + final code = int.tryParse(m.group(1) ?? ''); + if (code == null) return ''; + return String.fromCharCode(code); + }); + s = s.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (m) { + final code = int.tryParse(m.group(1) ?? '', radix: 16); + if (code == null) return ''; + return String.fromCharCode(code); + }); + + s = s.replaceAll(RegExp(r'[ \t]+\n'), '\n'); + s = s.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + + return s.trim(); +} diff --git a/lib/presentation/terminal/widgets/terminal_header.dart b/lib/presentation/terminal/widgets/terminal_header.dart new file mode 100644 index 0000000..026a4d5 --- /dev/null +++ b/lib/presentation/terminal/widgets/terminal_header.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class TerminalHeader extends StatelessWidget { + const TerminalHeader({ + required this.merchantName, + required this.totalCount, + super.key, + }); + + final String merchantName; + final int totalCount; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Terminals', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + merchantName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Chip( + label: Text('$totalCount'), + side: BorderSide(color: colorScheme.outlineVariant), + backgroundColor: colorScheme.surfaceContainerHigh, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/terminal/widgets/terminal_list_view.dart b/lib/presentation/terminal/widgets/terminal_list_view.dart new file mode 100644 index 0000000..6070c89 --- /dev/null +++ b/lib/presentation/terminal/widgets/terminal_list_view.dart @@ -0,0 +1,205 @@ +import 'dart:ui'; + +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:flutter/material.dart'; + +class TerminalListView extends StatelessWidget { + const TerminalListView({ + required this.terminals, + required this.onRefresh, + required this.onTerminalTap, + required this.emptyMessage, + super.key, + }); + + final List terminals; + final Future Function() onRefresh; + final void Function(Terminal terminal) onTerminalTap; + final String emptyMessage; + + @override + Widget build(BuildContext context) { + final bottomInset = MediaQuery.viewPaddingOf(context).bottom; + final colorScheme = Theme.of(context).colorScheme; + + return RefreshIndicator.adaptive( + onRefresh: onRefresh, + child: terminals.isEmpty + ? ListView( + padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + Icon( + Icons.point_of_sale_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + emptyMessage, + 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, + ), + ), + ], + ) + : ListView.separated( + padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16), + physics: const AlwaysScrollableScrollPhysics(), + itemCount: terminals.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final terminal = terminals[index]; + final enabled = terminal.tid != null || terminal.id != null; + final title = + (_sanitizeMultiline(terminal.name)?.isNotEmpty ?? false) + ? _sanitizeMultiline(terminal.name)! + : (terminal.tid?.trim().isNotEmpty ?? false) + ? terminal.tid!.trim() + : '-'; + final status = (terminal.status ?? '').trim(); + final serial = (_sanitizeMultiline(terminal.serial) ?? '') + .trim(); + + 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: enabled ? () => onTerminalTap(terminal) : null, + 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: enabled + ? colorScheme.primary.withOpacity(0.14) + : colorScheme.surfaceContainerHigh, + ), + child: Icon( + Icons.point_of_sale_outlined, + color: enabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (status.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 999, + ), + color: + colorScheme.surfaceContainerHigh, + border: Border.all( + color: colorScheme.outlineVariant, + ), + ), + child: Text( + status.toUpperCase(), + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ), + ], + ), + if (serial.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Serial: $serial', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withOpacity(0.4), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +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; +} diff --git a/pubspec.lock b/pubspec.lock index c7c2c7c..60d8d40 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -70,6 +102,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_pdfview: + dependency: "direct main" + description: + name: flutter_pdfview + sha256: "5b80e89f3ba6e478d1e897543c9634508284ad73476807febc188378986b69ee" + url: "https://pub.dev" + source: hosted + version: "1.4.4" flutter_riverpod: dependency: "direct main" description: @@ -83,6 +123,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" http: dependency: "direct main" description: @@ -131,22 +187,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -155,6 +219,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -163,6 +243,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" riverpod: dependency: transitive description: @@ -228,10 +380,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: @@ -264,6 +416,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" + url: "https://pub.dev" + source: hosted + version: "4.10.15" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 + url: "https://pub.dev" + source: hosted + version: "3.24.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.10.7 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index a07ba84..5d316c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,9 @@ dependencies: cupertino_icons: ^1.0.8 flutter_riverpod: ^2.6.1 http: ^1.2.2 + flutter_pdfview: ^1.4.0 + path_provider: ^2.1.5 + webview_flutter: ^4.0.0 dev_dependencies: flutter_test: