final final part 1

This commit is contained in:
MooN 2026-02-20 14:13:57 +06:30
parent 6646955726
commit 8cc8d14ad6
8 changed files with 367 additions and 102 deletions

View File

@ -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';
}

View File

@ -19,10 +19,14 @@ class ApiTransactionRepository implements TransactionRepository {
Future<List<TransactionRecord>> 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,
);
}

View File

@ -4,5 +4,6 @@ abstract class TransactionRepository {
Future<List<TransactionRecord>> getTransactions({
required String token,
required String serial,
required String range,
});
}

View File

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

View File

@ -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<TerminalNextScreen> createState() => _TerminalNextScreenState();
}
class _TerminalNextScreenState extends ConsumerState<TerminalNextScreen> {
static const List<String> _ranges = <String>['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<String>(
value: _selectedRange,
items: _ranges
.map(
(range) => DropdownMenuItem<String>(
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<void>(
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),
),
);
},
),
);
},
),

View File

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

View File

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

View File

@ -14,14 +14,38 @@ final transactionRepositoryProvider = Provider<TransactionRepository>((ref) {
);
});
final transactionListProvider =
FutureProvider.family<List<TransactionRecord>, 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<List<TransactionRecord>, 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,
);
},
);