terminal and trans records api impl

This commit is contained in:
MooN 2026-02-15 00:27:18 +06:30
parent a6b1993c27
commit 6a04ad02a1
11 changed files with 588 additions and 28 deletions

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:e_receipt_mobile/domain/entities/merchant.dart'; 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/repositories/merchant_repository.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ApiMerchantRepository implements MerchantRepository { class ApiMerchantRepository implements MerchantRepository {
@ -34,12 +35,40 @@ class ApiMerchantRepository implements MerchantRepository {
throw Exception(_extractErrorMessage(response.body)); throw Exception(_extractErrorMessage(response.body));
} }
@override
Future<List<Terminal>> 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<Merchant> _parseMerchants(String body) { List<Merchant> _parseMerchants(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
final list = _extractList(decoded); final list = _extractList(decoded);
return list.map(_merchantFromItem).whereType<Merchant>().toList(); return list.map(_merchantFromItem).whereType<Merchant>().toList();
} }
List<Terminal> _parseTerminals(String body) {
final decoded = _tryDecode(body);
final list = _extractTerminalList(decoded);
return list.map(_terminalFromItem).whereType<Terminal>().toList();
}
dynamic _tryDecode(String body) { dynamic _tryDecode(String body) {
try { try {
return jsonDecode(body); return jsonDecode(body);
@ -76,6 +105,34 @@ class ApiMerchantRepository implements MerchantRepository {
return const <dynamic>[]; return const <dynamic>[];
} }
List<dynamic> _extractTerminalList(dynamic decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final directTerminals = decoded['terminals'];
if (directTerminals is List<dynamic>) {
return directTerminals;
}
final data =
decoded['data'] ??
decoded['result'] ??
decoded['payload'] ??
decoded['terminalData'];
if (data is List<dynamic>) {
return data;
}
if (data is Map<String, dynamic>) {
final nestedTerminals = data['terminals'];
if (nestedTerminals is List<dynamic>) {
return nestedTerminals;
}
}
}
return const <dynamic>[];
}
Merchant? _merchantFromItem(dynamic item) { Merchant? _merchantFromItem(dynamic item) {
if (item is! Map<String, dynamic>) { if (item is! Map<String, dynamic>) {
return null; return null;
@ -98,6 +155,33 @@ class ApiMerchantRepository implements MerchantRepository {
); );
} }
Terminal? _terminalFromItem(dynamic item) {
if (item is! Map<String, dynamic>) {
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<String, dynamic> data, List<String> keys) { String? _pickString(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) { for (final key in keys) {
final value = data[key]; final value = data[key];
@ -108,6 +192,58 @@ class ApiMerchantRepository implements MerchantRepository {
return null; return null;
} }
List<String>? _pickStringList(Map<String, dynamic> data, List<String> 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 <String>[value.toString().trim()];
}
}
return null;
}
int? _pickInt(Map<String, dynamic> data, List<String> 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<String, dynamic> data, List<String> 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) { String _extractErrorMessage(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {

View File

@ -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<List<TransactionRecord>> 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<TransactionRecord> _parseTransactions(String body) {
final decoded = _tryDecode(body);
final list = _extractTransactionList(decoded);
return list
.map((item) => _transactionFromItem(item))
.whereType<TransactionRecord>()
.toList();
}
dynamic _tryDecode(String body) {
try {
return jsonDecode(body);
} catch (_) {
return null;
}
}
List<dynamic> _extractTransactionList(dynamic decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final direct = decoded['transactions'];
if (direct is List<dynamic>) {
return direct;
}
final data = decoded['data'] ?? decoded['result'] ?? decoded['payload'];
if (data is List<dynamic>) {
return data;
}
if (data is Map<String, dynamic>) {
final nested = data['transactions'];
if (nested is List<dynamic>) {
return nested;
}
}
}
return const <dynamic>[];
}
TransactionRecord? _transactionFromItem(dynamic item) {
if (item is! Map<String, dynamic>) {
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<String, dynamic> data, List<String> 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<String, dynamic> data, List<String> 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<String, dynamic>) {
final message = decoded['message'] ?? decoded['error'] ?? decoded['detail'];
if (message != null) {
return message.toString();
}
}
return 'Failed to load transactions';
}
}

View File

@ -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<String>? mids;
final String? merchantId;
final String? status;
final int? totalTransactions;
final num? totalAmount;
final String? createdAt;
final String? updatedAt;
}

View File

@ -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<String, dynamic>? raw;
}

View File

@ -1,5 +1,11 @@
import 'package:e_receipt_mobile/domain/entities/merchant.dart'; import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart';
abstract class MerchantRepository { abstract class MerchantRepository {
Future<List<Merchant>> getMerchants({required String token}); Future<List<Merchant>> getMerchants({required String token});
Future<List<Terminal>> getTerminalsByMerchantId({
required String token,
required String merchantId,
});
} }

View File

@ -0,0 +1,8 @@
import 'package:e_receipt_mobile/domain/entities/transaction_record.dart';
abstract class TransactionRepository {
Future<List<TransactionRecord>> getTransactions({
required String token,
required String serial,
});
}

View File

@ -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/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_view_model.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/login/login_page.dart';
import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -41,11 +42,11 @@ class HomeScreen extends ConsumerWidget {
_showProfile(context); _showProfile(context);
}, },
), ),
ListTile( // ListTile(
leading: const Icon(Icons.storefront_outlined), // leading: const Icon(Icons.storefront_outlined),
title: const Text('Merchants'), // title: const Text('Merchants'),
onTap: () => Navigator.of(context).pop(), // onTap: () => Navigator.of(context).pop(),
), // ),
ListTile( ListTile(
leading: const Icon(Icons.analytics_outlined), leading: const Icon(Icons.analytics_outlined),
title: const Text('Reports'), title: const Text('Reports'),
@ -115,30 +116,20 @@ class HomeScreen extends ConsumerWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final merchant = merchants[index]; final merchant = merchants[index];
return Card( return Card(
elevation: 2, child: ListTile(
shape: RoundedRectangleBorder( onTap: merchant.id == null
borderRadius: BorderRadius.circular(12), ? null
: () => _openTerminalSelection(
context,
merchant.id!,
merchant.name ?? '-',
), ),
child: Padding( leading: const Icon(Icons.storefront_outlined),
padding: const EdgeInsets.all(12), trailing: const Icon(Icons.chevron_right),
child: Column( title: Text(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${index + 1}. ${merchant.name ?? '-'}', '${index + 1}. ${merchant.name ?? '-'}',
style: Theme.of(context)
.textTheme
.titleMedium,
),
const SizedBox(height: 6),
Text(
merchant.address ?? '-',
style: Theme.of(context)
.textTheme
.bodyMedium,
),
],
), ),
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<void>(
builder: (_) => TerminalSelectionScreen(
merchantId: merchantId,
merchantName: merchantName,
),
),
);
}
} }

View File

@ -1,6 +1,7 @@
import 'package:e_receipt_mobile/core/config/app_config.dart'; 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/data/repositories/api_merchant_repository.dart';
import 'package:e_receipt_mobile/domain/entities/merchant.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/domain/repositories/merchant_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
@ -24,3 +25,18 @@ final merchantListProvider = FutureProvider<List<Merchant>>((ref) async {
.watch(merchantRepositoryProvider) .watch(merchantRepositoryProvider)
.getMerchants(token: sessionUser.token); .getMerchants(token: sessionUser.token);
}); });
final terminalListProvider =
FutureProvider.family<List<Terminal>, 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,
);
});

View File

@ -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 ?? '-'}',
),
),
);
},
);
},
),
),
],
),
),
);
}
}

View File

@ -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<TerminalSelectionScreen> createState() =>
_TerminalSelectionScreenState();
}
class _TerminalSelectionScreenState
extends ConsumerState<TerminalSelectionScreen> {
@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<void>(
builder: (_) => TerminalNextScreen(
merchantName: widget.merchantName,
terminal: terminal,
),
),
);
}
}

View File

@ -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<TransactionRepository>((ref) {
return ApiTransactionRepository(
baseUrl: AppConfig.apiBaseUrl,
apiSecret: AppConfig.apiSecret,
client: ref.watch(authenticatedHttpClientProvider),
);
});
final transactionListProvider =
FutureProvider.family<List<TransactionRecord>, 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);
});