diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 3646f8d..437ee30 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -2,5 +2,6 @@ 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 apiSecret = 'y812J21lhha11OS'; } diff --git a/lib/data/repositories/api_transaction_repository.dart b/lib/data/repositories/api_transaction_repository.dart index 1f78a89..73ea711 100644 --- a/lib/data/repositories/api_transaction_repository.dart +++ b/lib/data/repositories/api_transaction_repository.dart @@ -19,10 +19,14 @@ class ApiTransactionRepository implements TransactionRepository { Future> getTransactions({ required String token, required String serial, + required String range, }) async { - final uri = Uri.parse( - '$baseUrl/receipt/transaction', - ).replace(queryParameters: {'serial': serial}); + final uri = Uri.parse('$baseUrl/receipt/transaction').replace( + queryParameters: { + 'serial': serial, + 'range': range, + }, + ); final response = await _client.get( uri, @@ -88,11 +92,11 @@ class ApiTransactionRepository implements TransactionRepository { return TransactionRecord( id: _pickString(item, const ['id', 'transactionId', 'transaction_id']), - rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no']), - amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount']), - status: _pickString(item, const ['status']), - type: _pickString(item, const ['type', 'transactionType']), - createdAt: _pickString(item, const ['createdAt', 'created_at']), + rrn: _pickString(item, const ['rrn', 'referenceNo', 'reference_no', 'DE37']), + amount: _pickNum(item, const ['amount', 'totalAmount', 'total_amount', 'DE4']), + status: _pickString(item, const ['status', 'description', 'DE39']), + type: _pickString(item, const ['type', 'transactionType', 'DE3']), + createdAt: _pickString(item, const ['createdAt', 'created_at', 'CREATED_AT']), raw: item, ); } diff --git a/lib/domain/repositories/transaction_repository.dart b/lib/domain/repositories/transaction_repository.dart index 0ed9802..bbc6d66 100644 --- a/lib/domain/repositories/transaction_repository.dart +++ b/lib/domain/repositories/transaction_repository.dart @@ -4,5 +4,6 @@ abstract class TransactionRepository { Future> getTransactions({ required String token, required String serial, + required String range, }); } diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 848da0d..52af276 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -184,33 +184,43 @@ class HomeScreen extends ConsumerWidget { ), const SizedBox(height: 8), Expanded( - child: merchants.isEmpty - ? const Center(child: Text('No merchants found')) - : ListView.separated( - 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 ?? '-'}', + 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 ?? '-'), ), - subtitle: Text(merchant.address ?? '-'), - ), - ); - }, - ), + ); + }, + ), + ), ), ], ); diff --git a/lib/presentation/terminal/terminal_next_screen.dart b/lib/presentation/terminal/terminal_next_screen.dart index c5b29ac..d6c1a06 100644 --- a/lib/presentation/terminal/terminal_next_screen.dart +++ b/lib/presentation/terminal/terminal_next_screen.dart @@ -1,9 +1,10 @@ import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/presentation/terminal/transaction_receipt_screen.dart'; import 'package:e_receipt_mobile/presentation/terminal/transaction_view_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -class TerminalNextScreen extends ConsumerWidget { +class TerminalNextScreen extends ConsumerStatefulWidget { const TerminalNextScreen({ required this.merchantName, required this.terminal, @@ -14,11 +15,23 @@ class TerminalNextScreen extends ConsumerWidget { final Terminal terminal; @override - Widget build(BuildContext context, WidgetRef ref) { - final serial = terminal.serial?.trim() ?? ''; + ConsumerState createState() => _TerminalNextScreenState(); +} + +class _TerminalNextScreenState extends ConsumerState { + static const List _ranges = ['1d', '1w', '1m']; + String _selectedRange = '1d'; + + @override + Widget build(BuildContext context) { + final serial = widget.terminal.serial?.trim() ?? ''; final hasSerial = serial.isNotEmpty; final transactionsAsync = hasSerial - ? ref.watch(transactionListProvider(serial)) + ? ref.watch( + transactionListProvider( + TransactionQuery(serial: serial, range: _selectedRange), + ), + ) : null; return Scaffold( @@ -29,25 +42,53 @@ class TerminalNextScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Merchant: $merchantName', + 'Merchant: ${widget.merchantName}', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), - Text('Terminal: ${terminal.name ?? terminal.tid ?? '-'}'), - const SizedBox(height: 4), - Text('TID: ${terminal.tid ?? '-'}'), - const SizedBox(height: 4), Text( - 'MIDs: ${(terminal.mids == null || terminal.mids!.isEmpty) ? '-' : terminal.mids!.join(', ')}', + 'Terminal: ${widget.terminal.name ?? widget.terminal.tid ?? '-'}', ), const SizedBox(height: 4), - Text('Serial: ${terminal.serial ?? '-'}'), + Text('TID: ${widget.terminal.tid ?? '-'}'), const SizedBox(height: 4), - Text('Address: ${terminal.address ?? '-'}'), - const SizedBox(height: 16), Text( - 'Transactions', - style: Theme.of(context).textTheme.titleMedium, + '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, + ), + const Spacer(), + const Text('Range: '), + const SizedBox(width: 8), + DropdownButton( + value: _selectedRange, + items: _ranges + .map( + (range) => DropdownMenuItem( + value: range, + child: Text(range), + ), + ) + .toList(), + onChanged: (value) { + if (value == null || value == _selectedRange) { + return; + } + setState(() { + _selectedRange = value; + }); + }, + ), + ], ), const SizedBox(height: 8), Expanded( @@ -62,29 +103,59 @@ class TerminalNextScreen extends ConsumerWidget { child: Text('Failed to load transactions: $error'), ), data: (transactions) { - if (transactions.isEmpty) { - return const Center( - child: Text('No transactions found'), - ); - } - - return ListView.separated( - itemCount: transactions.length, - separatorBuilder: (_, __) => - const SizedBox(height: 8), - itemBuilder: (context, index) { - final item = transactions[index]; - return Card( - child: ListTile( - title: Text( - 'Amount: ${item.amount?.toString() ?? '-'}', - ), - subtitle: Text( - 'Status: ${item.status ?? '-'} | RRN: ${item.rrn ?? '-'}', - ), + 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), + ), + ); + }, + ), ); }, ), diff --git a/lib/presentation/terminal/terminal_selection_screen.dart b/lib/presentation/terminal/terminal_selection_screen.dart index 01f572d..7fbdd43 100644 --- a/lib/presentation/terminal/terminal_selection_screen.dart +++ b/lib/presentation/terminal/terminal_selection_screen.dart @@ -33,28 +33,38 @@ class _TerminalSelectionScreenState loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center(child: Text('Failed to load terminals: $error')), data: (terminals) { - if (terminals.isEmpty) { - return const Center(child: Text('No terminals found')); - } - - return ListView.separated( - 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(', ')}', + 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(', ')}', + ), + ), + ); + }, ), - ), - ); - }, ); }, ), diff --git a/lib/presentation/terminal/transaction_receipt_screen.dart b/lib/presentation/terminal/transaction_receipt_screen.dart new file mode 100644 index 0000000..a74657c --- /dev/null +++ b/lib/presentation/terminal/transaction_receipt_screen.dart @@ -0,0 +1,144 @@ +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:flutter/material.dart'; + +class TransactionReceiptScreen extends StatelessWidget { + const TransactionReceiptScreen({ + required this.merchantName, + required this.terminal, + required this.transaction, + super.key, + }); + + final String merchantName; + final Terminal terminal; + 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, + ); + + 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, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + 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, + ), + ), + ], + ), + ); + } + + 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; + } +} diff --git a/lib/presentation/terminal/transaction_view_model.dart b/lib/presentation/terminal/transaction_view_model.dart index 46cff6d..0b0bdb5 100644 --- a/lib/presentation/terminal/transaction_view_model.dart +++ b/lib/presentation/terminal/transaction_view_model.dart @@ -14,14 +14,38 @@ final transactionRepositoryProvider = Provider((ref) { ); }); -final transactionListProvider = - FutureProvider.family, String>((ref, serial) async { - final sessionUser = ref.watch(sessionControllerProvider); - if (sessionUser == null) { - throw Exception('No active session'); - } +class TransactionQuery { + const TransactionQuery({required this.serial, required this.range}); - return ref - .watch(transactionRepositoryProvider) - .getTransactions(token: sessionUser.token, serial: serial); - }); + final String serial; + final String range; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is TransactionQuery && + other.serial == serial && + other.range == range; + } + + @override + int get hashCode => Object.hash(serial, range); +} + +final transactionListProvider = + FutureProvider.family, TransactionQuery>( + (ref, query) async { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + return ref.watch(transactionRepositoryProvider).getTransactions( + token: sessionUser.token, + serial: query.serial, + range: query.range, + ); + }, + );