diff --git a/lib/data/repositories/api_merchant_repository.dart b/lib/data/repositories/api_merchant_repository.dart index 843609e..d5d6ef2 100644 --- a/lib/data/repositories/api_merchant_repository.dart +++ b/lib/data/repositories/api_merchant_repository.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:http/http.dart' as http; class ApiMerchantRepository implements MerchantRepository { @@ -34,12 +35,40 @@ class ApiMerchantRepository implements MerchantRepository { throw Exception(_extractErrorMessage(response.body)); } + @override + Future> getTerminalsByMerchantId({ + required String token, + required String merchantId, + }) async { + final uri = Uri.parse('$baseUrl/receipt/terminal/list/$merchantId'); + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + 'Authorization': 'Bearer ${token.trim()}', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseTerminals(response.body); + } + + throw Exception(_extractErrorMessage(response.body)); + } + List _parseMerchants(String body) { final decoded = _tryDecode(body); final list = _extractList(decoded); return list.map(_merchantFromItem).whereType().toList(); } + List _parseTerminals(String body) { + final decoded = _tryDecode(body); + final list = _extractTerminalList(decoded); + return list.map(_terminalFromItem).whereType().toList(); + } + dynamic _tryDecode(String body) { try { return jsonDecode(body); @@ -76,6 +105,34 @@ class ApiMerchantRepository implements MerchantRepository { return const []; } + List _extractTerminalList(dynamic decoded) { + if (decoded is List) { + return decoded; + } + if (decoded is Map) { + final directTerminals = decoded['terminals']; + if (directTerminals is List) { + return directTerminals; + } + + final data = + decoded['data'] ?? + decoded['result'] ?? + decoded['payload'] ?? + decoded['terminalData']; + if (data is List) { + return data; + } + if (data is Map) { + final nestedTerminals = data['terminals']; + if (nestedTerminals is List) { + return nestedTerminals; + } + } + } + return const []; + } + Merchant? _merchantFromItem(dynamic item) { if (item is! Map) { return null; @@ -98,6 +155,33 @@ class ApiMerchantRepository implements MerchantRepository { ); } + Terminal? _terminalFromItem(dynamic item) { + if (item is! Map) { + return null; + } + + return Terminal( + id: _pickString(item, const ['id', 'terminalId', 'terminal_id']), + serial: _pickString(item, const ['serial']), + address: _pickString(item, const ['address']), + address2: _pickString(item, const ['address2']), + address3: _pickString(item, const ['address3']), + name: _pickString(item, const ['name', 'terminalName', 'terminal_name']), + appId: _pickString(item, const ['appId', 'app_id']), + tid: _pickString(item, const ['tid', 'terminalNo', 'terminal_no']), + 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'], + ), + totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']), + createdAt: _pickString(item, const ['createdAt', 'created_at']), + updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), + ); + } + String? _pickString(Map data, List keys) { for (final key in keys) { final value = data[key]; @@ -108,6 +192,58 @@ class ApiMerchantRepository implements MerchantRepository { return null; } + List? _pickStringList(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is List) { + final results = value + .where((e) => e != null && e.toString().trim().isNotEmpty) + .map((e) => e.toString().trim()) + .toList(); + return results.isEmpty ? null : results; + } + if (value != null && value.toString().trim().isNotEmpty) { + return [value.toString().trim()]; + } + } + return null; + } + + int? _pickInt(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + if (value != null) { + final parsed = int.tryParse(value.toString().trim()); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + + num? _pickNum(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is num) { + return value; + } + if (value != null) { + final parsed = num.tryParse(value.toString().trim()); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + String _extractErrorMessage(String body) { final decoded = _tryDecode(body); if (decoded is Map) { diff --git a/lib/data/repositories/api_transaction_repository.dart b/lib/data/repositories/api_transaction_repository.dart new file mode 100644 index 0000000..1f78a89 --- /dev/null +++ b/lib/data/repositories/api_transaction_repository.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; +import 'package:http/http.dart' as http; + +class ApiTransactionRepository implements TransactionRepository { + ApiTransactionRepository({ + required this.baseUrl, + required this.apiSecret, + required http.Client client, + }) : _client = client; + + final String baseUrl; + final String apiSecret; + final http.Client _client; + + @override + Future> getTransactions({ + required String token, + required String serial, + }) async { + final uri = Uri.parse( + '$baseUrl/receipt/transaction', + ).replace(queryParameters: {'serial': serial}); + + final response = await _client.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiSecret, + 'Authorization': 'Bearer ${token.trim()}', + }, + ); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return _parseTransactions(response.body); + } + + throw Exception(_extractErrorMessage(response.body)); + } + + List _parseTransactions(String body) { + final decoded = _tryDecode(body); + final list = _extractTransactionList(decoded); + return list + .map((item) => _transactionFromItem(item)) + .whereType() + .toList(); + } + + dynamic _tryDecode(String body) { + try { + return jsonDecode(body); + } catch (_) { + return null; + } + } + + List _extractTransactionList(dynamic decoded) { + if (decoded is List) { + return decoded; + } + if (decoded is Map) { + final direct = decoded['transactions']; + if (direct is List) { + return direct; + } + + final data = decoded['data'] ?? decoded['result'] ?? decoded['payload']; + if (data is List) { + return data; + } + if (data is Map) { + final nested = data['transactions']; + if (nested is List) { + return nested; + } + } + } + return const []; + } + + TransactionRecord? _transactionFromItem(dynamic item) { + if (item is! Map) { + return null; + } + + 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']), + raw: item, + ); + } + + String? _pickString(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value != null && value.toString().trim().isNotEmpty) { + return value.toString().trim(); + } + } + return null; + } + + num? _pickNum(Map data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is num) { + return value; + } + if (value != null) { + final parsed = num.tryParse(value.toString().trim()); + if (parsed != null) { + return parsed; + } + } + } + return null; + } + + String _extractErrorMessage(String body) { + final decoded = _tryDecode(body); + if (decoded is Map) { + final message = decoded['message'] ?? decoded['error'] ?? decoded['detail']; + if (message != null) { + return message.toString(); + } + } + return 'Failed to load transactions'; + } +} diff --git a/lib/domain/entities/terminal.dart b/lib/domain/entities/terminal.dart new file mode 100644 index 0000000..246df94 --- /dev/null +++ b/lib/domain/entities/terminal.dart @@ -0,0 +1,35 @@ +class Terminal { + const Terminal({ + this.id, + this.serial, + this.address, + this.address2, + this.address3, + this.name, + this.appId, + this.tid, + this.mids, + this.merchantId, + this.status, + this.totalTransactions, + this.totalAmount, + this.createdAt, + this.updatedAt, + }); + + final String? id; + final String? serial; + final String? address; + final String? address2; + final String? address3; + final String? name; + final String? appId; + final String? tid; + final List? mids; + final String? merchantId; + final String? status; + final int? totalTransactions; + final num? totalAmount; + final String? createdAt; + final String? updatedAt; +} diff --git a/lib/domain/entities/transaction_record.dart b/lib/domain/entities/transaction_record.dart new file mode 100644 index 0000000..91f4b12 --- /dev/null +++ b/lib/domain/entities/transaction_record.dart @@ -0,0 +1,19 @@ +class TransactionRecord { + const TransactionRecord({ + this.id, + this.rrn, + this.amount, + this.status, + this.type, + this.createdAt, + this.raw, + }); + + final String? id; + final String? rrn; + final num? amount; + final String? status; + final String? type; + final String? createdAt; + final Map? raw; +} diff --git a/lib/domain/repositories/merchant_repository.dart b/lib/domain/repositories/merchant_repository.dart index ef08496..ab40216 100644 --- a/lib/domain/repositories/merchant_repository.dart +++ b/lib/domain/repositories/merchant_repository.dart @@ -1,5 +1,11 @@ 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> getTerminalsByMerchantId({ + required String token, + required String merchantId, + }); } diff --git a/lib/domain/repositories/transaction_repository.dart b/lib/domain/repositories/transaction_repository.dart new file mode 100644 index 0000000..0ed9802 --- /dev/null +++ b/lib/domain/repositories/transaction_repository.dart @@ -0,0 +1,8 @@ +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; + +abstract class TransactionRepository { + Future> getTransactions({ + required String token, + required String serial, + }); +} diff --git a/lib/presentation/home/home_screen.dart b/lib/presentation/home/home_screen.dart index 4bb0d53..1be3c33 100644 --- a/lib/presentation/home/home_screen.dart +++ b/lib/presentation/home/home_screen.dart @@ -2,6 +2,7 @@ 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/terminal/terminal_selection_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -41,11 +42,11 @@ class HomeScreen extends ConsumerWidget { _showProfile(context); }, ), - ListTile( - leading: const Icon(Icons.storefront_outlined), - title: const Text('Merchants'), - onTap: () => Navigator.of(context).pop(), - ), + // ListTile( + // leading: const Icon(Icons.storefront_outlined), + // title: const Text('Merchants'), + // onTap: () => Navigator.of(context).pop(), + // ), ListTile( leading: const Icon(Icons.analytics_outlined), title: const Text('Reports'), @@ -115,30 +116,20 @@ class HomeScreen extends ConsumerWidget { itemBuilder: (context, index) { final merchant = merchants[index]; return Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${index + 1}. ${merchant.name ?? '-'}', - style: Theme.of(context) - .textTheme - .titleMedium, - ), - const SizedBox(height: 6), - Text( - merchant.address ?? '-', - style: Theme.of(context) - .textTheme - .bodyMedium, - ), - ], + 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 ?? '-'), ), ); }, @@ -183,4 +174,19 @@ class HomeScreen extends ConsumerWidget { }, ); } + + void _openTerminalSelection( + BuildContext context, + String merchantId, + String merchantName, + ) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TerminalSelectionScreen( + merchantId: merchantId, + merchantName: merchantName, + ), + ), + ); + } } diff --git a/lib/presentation/home/home_view_model.dart b/lib/presentation/home/home_view_model.dart index 2cddc1a..6f7569b 100644 --- a/lib/presentation/home/home_view_model.dart +++ b/lib/presentation/home/home_view_model.dart @@ -1,6 +1,7 @@ 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'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; @@ -24,3 +25,18 @@ final merchantListProvider = FutureProvider>((ref) async { .watch(merchantRepositoryProvider) .getMerchants(token: sessionUser.token); }); + +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/terminal/terminal_next_screen.dart b/lib/presentation/terminal/terminal_next_screen.dart new file mode 100644 index 0000000..c5b29ac --- /dev/null +++ b/lib/presentation/terminal/terminal_next_screen.dart @@ -0,0 +1,97 @@ +import 'package:e_receipt_mobile/domain/entities/terminal.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 { + const TerminalNextScreen({ + required this.merchantName, + required this.terminal, + super.key, + }); + + final String merchantName; + final Terminal terminal; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serial = terminal.serial?.trim() ?? ''; + final hasSerial = serial.isNotEmpty; + final transactionsAsync = hasSerial + ? ref.watch(transactionListProvider(serial)) + : null; + + return Scaffold( + appBar: AppBar(title: const Text('Terminal Transactions')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Merchant: $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(', ')}', + ), + const SizedBox(height: 4), + Text('Serial: ${terminal.serial ?? '-'}'), + const SizedBox(height: 4), + Text('Address: ${terminal.address ?? '-'}'), + const SizedBox(height: 16), + Text( + 'Transactions', + style: Theme.of(context).textTheme.titleMedium, + ), + 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) { + 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 ?? '-'}', + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/terminal/terminal_selection_screen.dart b/lib/presentation/terminal/terminal_selection_screen.dart new file mode 100644 index 0000000..a7dfecc --- /dev/null +++ b/lib/presentation/terminal/terminal_selection_screen.dart @@ -0,0 +1,74 @@ +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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class TerminalSelectionScreen extends ConsumerStatefulWidget { + const TerminalSelectionScreen({ + required this.merchantId, + required this.merchantName, + super.key, + }); + + final String merchantId; + final String merchantName; + + @override + ConsumerState createState() => + _TerminalSelectionScreenState(); +} + +class _TerminalSelectionScreenState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final terminalsAsync = ref.watch(terminalListProvider(widget.merchantId)); + + return Scaffold( + appBar: AppBar( + title: Text('Select Terminals - ${widget.merchantName}'), + ), + body: terminalsAsync.when( + 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(', ')}', + ), + ), + ); + }, + ); + }, + ), + ); + } + + void _openNextScreen(BuildContext context, Terminal terminal) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TerminalNextScreen( + merchantName: widget.merchantName, + terminal: terminal, + ), + ), + ); + } +} diff --git a/lib/presentation/terminal/transaction_view_model.dart b/lib/presentation/terminal/transaction_view_model.dart new file mode 100644 index 0000000..46cff6d --- /dev/null +++ b/lib/presentation/terminal/transaction_view_model.dart @@ -0,0 +1,27 @@ +import 'package:e_receipt_mobile/core/config/app_config.dart'; +import 'package:e_receipt_mobile/data/repositories/api_transaction_repository.dart'; +import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; +import 'package:e_receipt_mobile/domain/repositories/transaction_repository.dart'; +import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; +import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final transactionRepositoryProvider = Provider((ref) { + return ApiTransactionRepository( + baseUrl: AppConfig.apiBaseUrl, + apiSecret: AppConfig.apiSecret, + client: ref.watch(authenticatedHttpClientProvider), + ); +}); + +final transactionListProvider = + FutureProvider.family, String>((ref, serial) async { + final sessionUser = ref.watch(sessionControllerProvider); + if (sessionUser == null) { + throw Exception('No active session'); + } + + return ref + .watch(transactionRepositoryProvider) + .getTransactions(token: sessionUser.token, serial: serial); + });