updated the look and feel of the home page

This commit is contained in:
2026-05-28 11:47:38 +02:00
parent 6fdd3183fb
commit 88aad6ec3e
12 changed files with 1225 additions and 735 deletions

View File

@@ -200,6 +200,12 @@ class _MyAppState extends State<MyApp> {
path: '/receipt',
builder: (context, state) => const ReceiptScreen(),
),
GoRoute(
path: '/receipt/:transactionId',
builder: (context, state) => ReceiptScreen(
transactionId: state.pathParameters['transactionId'],
),
),
GoRoute(
path: '/integration',
builder: (context, state) => const IntegrationScreen(),

View File

@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:qpay/screens/history/history_controller.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:qpay/widgets/transaction_item_widget.dart';
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key});
@@ -54,6 +55,13 @@ class _HistoryScreenState extends State<HistoryScreen>
});
}
/// Handles the repeat action for a transaction.
Future<void> _repeatTransaction(Map<String, dynamic> item) async {
final receiptController = ReceiptController(context);
final transaction = txn.TransactionModel.fromJson(item);
await receiptController.repeatTransaction(transaction);
}
@override
void dispose() {
controller.dispose();
@@ -198,7 +206,7 @@ class _HistoryScreenState extends State<HistoryScreen>
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).colorScheme.primary.withValues(alpha: 0.2),
),
),
suffixIcon: _searchController.text.isNotEmpty
@@ -247,72 +255,15 @@ class _HistoryScreenState extends State<HistoryScreen>
return SlideTransition(
position: animation,
child: Skeletonizer(
child: TransactionItemWidget(
transaction: e,
enabled: controller.model.isLoading,
child: InkWell(
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: () {
transactionController.updateReceiptData(e);
context.push("/receipt");
onRepeat: () async {
await _repeatTransaction(e);
if (context.mounted) {
context.push('/make-payment');
}
},
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage("assets/${e['paymentProcessorImage']}"),
),
),
title: Row(
children: [
Text(
e['billName'],
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(width: 10),
Icon(
e['integrationStatus'] == 'SUCCESS'
? Icons.check_circle
: e['integrationStatus'] == 'PENDING'
? Icons.pending
: Icons.cancel,
size: 16,
color: e['integrationStatus'] == 'SUCCESS'
? Colors.green
: e['integrationStatus'] == 'PENDING'
? Colors.orange
: Colors.red,
),
],
),
subtitle: Text(
e['createdAt'] != null
? timeago.format(DateTime.parse(e['createdAt']))
: '',
style: TextStyle(fontSize: 12),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
e['amount'].toStringAsFixed(2),
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
Text(
e['totalAmount'].toStringAsFixed(2),
style: TextStyle(fontSize: 12),
),
],
),
),
),
),
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -47,8 +47,10 @@ class IntegrationController extends ChangeNotifier {
dynamic response = workflowResponse['body'];
String? transactionId;
if (response['status'] == 'SUCCESS') {
model.status = response['status'];
transactionId = response['id'];
transactionController.updateReceiptData(response);
await gatewayController.poll(
@@ -61,7 +63,13 @@ class IntegrationController extends ChangeNotifier {
if (!_disposed) notifyListeners();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed && context.mounted) context.go('/receipt');
if (!_disposed && context.mounted) {
if (transactionId != null) {
context.go('/receipt/$transactionId');
} else {
context.go('/receipt');
}
}
});
return ApiResponse.success(Map<String, dynamic>.from(response));

View File

@@ -49,8 +49,13 @@ class _IntegrationScreenState extends State<IntegrationScreen> {
listenable: controller,
builder: (context, child) {
if (controller.model.status == 'SUCCESS') {
final transactionId = controller.model.transaction?['id'];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (transactionId != null) {
context.go('/receipt/$transactionId');
} else {
context.go('/receipt');
}
});
}

View File

@@ -7,9 +7,10 @@ import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/pay/pay_controller.dart' as pc;
import 'package:qpay/screens/transaction_controller.dart' as tc;
import 'package:qpay/screens/home/home_controller.dart' as hc;
import 'package:qpay/screens/transactions/transaction_model.dart';
class ReceiptModel {
Map<String, dynamic>? receiptData;
TransactionModel? transaction;
bool isLoading = false;
String? errorMessage;
String status = '';
@@ -35,7 +36,7 @@ class ReceiptController extends ChangeNotifier {
notifyListeners();
}
Future<ApiResponse<Map<String, dynamic>>> doIntegration(String id) async {
Future<ApiResponse<TransactionModel>> doIntegration(String id) async {
try {
model.isLoading = true;
model.status = '';
@@ -51,9 +52,13 @@ class ReceiptController extends ChangeNotifier {
if (response['status'] == 'SUCCESS') {
model.status = response['status'];
transactionController.updateReceiptData(response);
transactionController.updateReceiptData(
Map<String, dynamic>.from(response),
);
notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(response));
return ApiResponse.success(
TransactionModel.fromJson(Map<String, dynamic>.from(response)),
);
} else {
model.status = response['status'];
model.errorMessage = response['errorMessage'];
@@ -70,10 +75,10 @@ class ReceiptController extends ChangeNotifier {
}
}
Future<void> repeatTransaction(BuildContext context) async {
Future<void> repeatTransaction(TransactionModel transaction) async {
setLoading(true);
// fetch provider & update tran controller
String providerId = model.receiptData?['billClientId'] ?? '';
String providerId = transaction.billClientId ?? '';
Map<String, dynamic> provider = await http.get(
'/public/providers/client/$providerId',
);
@@ -84,16 +89,16 @@ class ReceiptController extends ChangeNotifier {
// update tran controller formdata with credit account, email, name, phone & providerLabel
transactionController.model.formData = transactionController.model.formData
.copyWith(
creditAccount: model.receiptData?['creditAccount'] ?? '',
creditPhone: model.receiptData?['creditPhone'] ?? '',
creditName: model.receiptData?['creditName'] ?? '',
creditEmail: model.receiptData?['creditEmail'] ?? '',
creditAccount: transaction.creditAccount ?? '',
creditPhone: transaction.creditPhone ?? '',
creditName: transaction.creditName ?? '',
creditEmail: transaction.creditEmail ?? '',
);
// fetch product and update tran controller formdata
String providerUid =
transactionController.model.selectedProvider?.uid ?? '';
String productUid = model.receiptData?['productUid'] ?? '';
String productUid = transaction.productUid ?? '';
if (productUid.isNotEmpty) {
Map<String, dynamic> product = await http.get(
@@ -105,27 +110,26 @@ class ReceiptController extends ChangeNotifier {
}
// update credit amount, notification phone number, payment processor
transactionController
.model
.formData = transactionController.model.formData.copyWith(
amount: model.receiptData?['amount'].toStringAsFixed(2) ?? '',
debitPhone: model.receiptData?['debitPhone'] ?? '',
paymentProcessorLabel: model.receiptData?['paymentProcessorLabel'] ?? '',
paymentProcessorName: model.receiptData?['paymentProcessorName'] ?? '',
transactionController.model.formData = transactionController.model.formData
.copyWith(
amount: transaction.amount.toStringAsFixed(2),
debitPhone: transaction.debitPhone,
paymentProcessorLabel: transaction.paymentProcessorLabel,
paymentProcessorName: transaction.paymentProcessorName,
id: null,
);
setLoading(false);
context.push('/make-payment');
}
Future<ApiResponse<Map<String, dynamic>>> getReceiptData(String id) async {
Future<ApiResponse<TransactionModel>> getReceiptData(String id) async {
setLoading(true);
try {
Map<String, dynamic> response = await http.get('/public/transaction/$id');
model.receiptData = response;
final transaction = TransactionModel.fromJson(response);
model.transaction = transaction;
setLoading(false);
return ApiResponse.success(response);
return ApiResponse.success(transaction);
} catch (e) {
setLoading(false);
return ApiResponse.failure(
@@ -134,8 +138,8 @@ class ReceiptController extends ChangeNotifier {
}
}
void updateReceiptData(Map<String, dynamic> data) {
model.receiptData = data;
void updateReceiptTransaction(TransactionModel data) {
model.transaction = data;
notifyListeners();
}
}

View File

@@ -3,15 +3,20 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/transaction_controller.dart'
show TransactionController;
import 'package:go_router/go_router.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transactions/transaction_model.dart';
import 'package:share_plus/share_plus.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:widgets_to_image/widgets_to_image.dart';
import 'package:decimal/decimal.dart';
class ReceiptScreen extends StatefulWidget {
const ReceiptScreen({super.key});
final String? transactionId;
const ReceiptScreen({super.key, this.transactionId});
@override
State<ReceiptScreen> createState() => _ReceiptScreenState();
@@ -36,6 +41,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
late Animation<double> _contentFadeAnimation;
int _selectedTabIndex = 0;
TransactionModel? _transaction;
late ReceiptController receiptController;
final _formKey = GlobalKey<FormState>();
@@ -52,9 +58,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
receiptController = ReceiptController(context);
gatewayController = GatewayController(context);
receiptController.getReceiptData(
transactionController.model.receiptData?["id"],
);
_fetchTransaction();
// Header animation
_headerAnimController = AnimationController(
@@ -114,6 +118,22 @@ class _ReceiptScreenState extends State<ReceiptScreen>
});
}
Future<void> _fetchTransaction() async {
final id =
widget.transactionId ??
transactionController.model.receiptData?["id"] ??
receiptController.model.transaction?.id;
if (id == null) return;
final response = await receiptController.getReceiptData(id);
if (response.isSuccess && response.data != null) {
setState(() {
_transaction = response.data;
});
}
}
Future<void> _captureImage() async {
final image = await controller.capture();
if (image != null) {
@@ -129,25 +149,21 @@ class _ReceiptScreenState extends State<ReceiptScreen>
_retry() async {
receiptController.setLoading(true);
await receiptController.doIntegration(
receiptController.model.receiptData?["id"],
);
final id = _transaction?.id ?? '';
await receiptController.doIntegration(id);
if (receiptController.model.status == "SUCCESS") {
receiptController.getReceiptData(
receiptController.model.receiptData?["id"],
);
await _fetchTransaction();
}
receiptController.setLoading(false);
}
_check() async {
receiptController.setLoading(true);
await gatewayController.poll(receiptController.model.receiptData?["id"]);
final id = _transaction?.id ?? '';
await gatewayController.poll(id);
if (gatewayController.model.status == "SUCCESS") {
receiptController.getReceiptData(
transactionController.model.receiptData?["id"],
);
if (transactionController.model.receiptData?["pollingStatus"]) {
await _fetchTransaction();
if (_transaction?.pollingStatus == "SUCCESS") {
receiptController.setLoading(false);
return;
}
@@ -193,22 +209,10 @@ class _ReceiptScreenState extends State<ReceiptScreen>
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
leading: context.canPop()
? Padding(
padding: const EdgeInsets.only(left: 8),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.08,
),
borderRadius: BorderRadius.circular(14),
),
child: IconButton(
? IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_rounded),
color: theme.colorScheme.primary,
),
),
)
: const SizedBox.shrink(),
title: Text(
@@ -359,13 +363,11 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Receipt Header Card
// ──────────────────────────────────────────────────────────────
Widget _buildReceiptHeader(ThemeData theme, bool isDark) {
final status =
transactionController.model.receiptData?["integrationStatus"];
final amount = receiptController.model.receiptData?["amount"];
final currency =
receiptController.model.receiptData?["debitCurrency"] ?? "";
final billName = receiptController.model.receiptData?["billName"] ?? "";
final createdAt = receiptController.model.receiptData?["createdAt"];
final status = _transaction?.integrationStatus;
final amount = _transaction?.amount;
final currency = _transaction?.debitCurrency ?? "";
final billName = _transaction?.billName ?? "";
final createdAt = _transaction?.createdAt;
final primaryColor = theme.colorScheme.primary;
@@ -458,7 +460,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Date
if (createdAt != null)
Text(
DateFormat.yMMMd().add_jm().format(DateTime.parse(createdAt)),
DateFormat.yMMMd().add_jm().format(createdAt),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w400,
@@ -502,9 +504,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Action Buttons Row
// ──────────────────────────────────────────────────────────────
Widget _buildActionButtons(ThemeData theme) {
final integrationStatus =
receiptController.model.receiptData?["integrationStatus"];
final paymentStatus = receiptController.model.receiptData?["paymentStatus"];
final integrationStatus = _transaction?.integrationStatus;
final paymentStatus = _transaction?.paymentStatus;
return Wrap(
spacing: 12,
@@ -516,7 +517,14 @@ class _ReceiptScreenState extends State<ReceiptScreen>
label: "Repeat",
color: theme.colorScheme.primary,
isLoading: receiptController.model.isLoading,
onPressed: () => receiptController.repeatTransaction(context),
onPressed: () async {
await receiptController.repeatTransaction(
receiptController.model.transaction!,
);
if (mounted) {
context.push('/make-payment');
}
},
),
_modernActionButton(
icon: Icons.ios_share_rounded,
@@ -534,8 +542,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
onPressed: _retry,
),
],
if (receiptController.model.receiptData?["pollingStatus"] !=
"SUCCESS") ...[
if (_transaction?.pollingStatus != "SUCCESS") ...[
_modernActionButton(
icon: Icons.search_rounded,
label: "Check",
@@ -705,8 +712,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Provider Details Tab
// ──────────────────────────────────────────────────────────────
Widget _buildProviderDetailsTab(ThemeData theme, bool isDark) {
final receiptData = receiptController.model.receiptData;
final additionalData = receiptData?["additionalData"];
final receiptData = receiptController.model.transaction;
final additionalData = receiptData?.additionalData;
final items = <_DetailItem>[];
@@ -715,14 +722,13 @@ class _ReceiptScreenState extends State<ReceiptScreen>
_DetailItem(
icon: Icons.circle_outlined,
label: "Status",
value:
transactionController.model.receiptData?["integrationStatus"] ?? "",
value: _transaction?.integrationStatus ?? "",
),
);
// Error message
final errorMsg = transactionController.model.receiptData?["errorMessage"];
if (errorMsg != null && errorMsg != "") {
final errorMsg = _transaction?.errorMessage;
if (errorMsg != null && errorMsg.isNotEmpty) {
items.add(
_DetailItem(
icon: Icons.warning_amber_rounded,
@@ -751,37 +757,37 @@ class _ReceiptScreenState extends State<ReceiptScreen>
_DetailItem(
icon: Icons.tag_rounded,
label: "Debit Reference",
value: transactionController.model.receiptData?["debitRef"] ?? "",
value: _transaction?.debitRef ?? "",
),
);
items.add(
_DetailItem(
icon: Icons.phone_iphone_rounded,
label: "From",
value: receiptData?["debitPhone"] ?? "",
value: receiptData?.debitPhone ?? "",
),
);
items.add(
_DetailItem(
icon: Icons.account_balance_wallet_rounded,
label: "To",
value: receiptData?["creditAccount"] ?? "",
value: receiptData?.creditAccount ?? "",
),
);
items.add(
_DetailItem(
icon: Icons.memory_rounded,
label: "Processor",
value: receiptData?["paymentProcessorName"] ?? "",
value: receiptData?.paymentProcessorName ?? "",
),
);
if (receiptData?["billProductName"] != null) {
if (receiptData?.billProductName != null) {
items.add(
_DetailItem(
icon: Icons.category_rounded,
label: "Bill Product",
value: receiptData?["billProductName"] ?? "",
value: receiptData?.billProductName ?? "",
),
);
}
@@ -822,12 +828,12 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Transaction Details Tab
// ──────────────────────────────────────────────────────────────
Widget _buildTransactionDetailsTab(ThemeData theme, bool isDark) {
final receiptData = transactionController.model.receiptData;
final amount = receiptData?["amount"] ?? 0;
final charge = receiptData?["charge"] ?? 0;
final gatewayCharge = receiptData?["gatewayCharge"] ?? 0;
final tax = receiptData?["tax"] ?? 0;
final totalAmount = receiptData?["totalAmount"] ?? 0;
final t = _transaction;
final amount = t?.amount ?? Decimal.zero;
final charge = t?.charge ?? Decimal.zero;
final gatewayCharge = t?.gatewayCharge ?? Decimal.zero;
final tax = t?.tax ?? Decimal.zero;
final totalAmount = t?.totalAmount ?? Decimal.zero;
final items = <_DetailItem>[
_DetailItem(
@@ -842,7 +848,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
];
if (charge != 0) {
if (charge != Decimal.zero) {
items.add(
_DetailItem(
icon: Icons.percent_rounded,
@@ -851,7 +857,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
);
}
if (gatewayCharge != 0) {
if (gatewayCharge != Decimal.zero) {
items.add(
_DetailItem(
icon: Icons.account_balance_rounded,
@@ -860,7 +866,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
);
}
if (tax != 0) {
if (tax != Decimal.zero) {
items.add(
_DetailItem(
icon: Icons.receipt_rounded,

View File

@@ -0,0 +1,153 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:decimal/decimal.dart';
part 'transaction_model.g.dart';
/// JsonConverter for [Decimal] values — reads from [num] (int or double)
/// in JSON and writes back as [num].
class DecimalJsonConverter implements JsonConverter<Decimal, num> {
const DecimalJsonConverter();
@override
Decimal fromJson(num json) => Decimal.parse(json.toString());
@override
num toJson(Decimal object) => object.toDouble();
}
/// JsonConverter for [DateTime] values — reads from ISO8601 [String]
/// and writes back as ISO8601 [String].
class DateTimeJsonConverter implements JsonConverter<DateTime, String> {
const DateTimeJsonConverter();
@override
DateTime fromJson(String json) => DateTime.parse(json);
@override
String toJson(DateTime object) => object.toIso8601String();
}
/// JsonConverter for a list of key-value entries (additionalData).
class AdditionalDataJsonConverter
implements JsonConverter<List<Map<String, dynamic>>?, List<dynamic>?> {
const AdditionalDataJsonConverter();
@override
List<Map<String, dynamic>>? fromJson(List<dynamic>? json) {
return json
?.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
}
@override
List<dynamic>? toJson(List<Map<String, dynamic>>? object) {
return object?.map((e) => e as dynamic).toList();
}
}
@DecimalJsonConverter()
@DateTimeJsonConverter()
@AdditionalDataJsonConverter()
@JsonSerializable(explicitToJson: true)
class TransactionModel {
final String? id;
final DateTime? createdAt;
final String userId;
final String trace;
final Decimal amount;
final Decimal charge;
final Decimal gatewayCharge;
final Decimal tax;
final Decimal totalAmount;
final String reference;
final String paymentProcessorLabel;
final String integrationProcessorLabel;
final String confirmationProcessorLabel;
final String providerLabel;
final String debitPhone;
final String debitCurrency;
final String debitRef;
final String? creditPhone;
final String? creditAccount;
final String? creditRef;
final String? billClientId;
final String billName;
final String paymentProcessorName;
final String? providerImage;
final String? paymentProcessorImage;
final String confirmationStatus;
final String paymentStatus;
final String integrationStatus;
final String pollingStatus;
final String? orderId;
final String? orderTrace;
final String? orderTransactionTrace;
final String? erpSalesRef;
final String? erpSalesPaymentRef;
final int workflowId;
final String? type;
final String? authType;
final String? responseCode;
final String? status;
final String? errorMessage;
final String? billProductName;
final String? creditName;
final String? creditEmail;
final String? productUid;
final String? targetUrl;
final List<Map<String, dynamic>>? additionalData;
const TransactionModel({
this.id,
this.createdAt,
required this.userId,
required this.trace,
required this.amount,
required this.charge,
required this.gatewayCharge,
required this.tax,
required this.totalAmount,
required this.reference,
required this.paymentProcessorLabel,
required this.integrationProcessorLabel,
required this.confirmationProcessorLabel,
required this.providerLabel,
required this.debitPhone,
required this.debitCurrency,
required this.debitRef,
this.creditPhone,
this.creditAccount,
this.creditRef,
this.billClientId,
required this.billName,
required this.paymentProcessorName,
this.providerImage,
this.paymentProcessorImage,
required this.confirmationStatus,
required this.paymentStatus,
required this.integrationStatus,
required this.pollingStatus,
this.orderId,
this.orderTrace,
this.orderTransactionTrace,
this.erpSalesRef,
this.erpSalesPaymentRef,
required this.workflowId,
this.type,
this.authType,
this.responseCode,
this.status,
this.errorMessage,
this.billProductName,
this.creditName,
this.creditEmail,
this.productUid,
this.targetUrl,
this.additionalData,
});
factory TransactionModel.fromJson(Map<String, dynamic> json) =>
_$TransactionModelFromJson(json);
Map<String, dynamic> toJson() => _$TransactionModelToJson(this);
}

View File

@@ -0,0 +1,132 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'transaction_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
TransactionModel(
id: json['id'] as String?,
createdAt: _$JsonConverterFromJson<String, DateTime>(
json['createdAt'],
const DateTimeJsonConverter().fromJson,
),
userId: json['userId'] as String,
trace: json['trace'] as String,
amount: const DecimalJsonConverter().fromJson(json['amount'] as num),
charge: const DecimalJsonConverter().fromJson(json['charge'] as num),
gatewayCharge: const DecimalJsonConverter().fromJson(
json['gatewayCharge'] as num,
),
tax: const DecimalJsonConverter().fromJson(json['tax'] as num),
totalAmount: const DecimalJsonConverter().fromJson(
json['totalAmount'] as num,
),
reference: json['reference'] as String,
paymentProcessorLabel: json['paymentProcessorLabel'] as String,
integrationProcessorLabel: json['integrationProcessorLabel'] as String,
confirmationProcessorLabel: json['confirmationProcessorLabel'] as String,
providerLabel: json['providerLabel'] as String,
debitPhone: json['debitPhone'] as String,
debitCurrency: json['debitCurrency'] as String,
debitRef: json['debitRef'] as String,
creditPhone: json['creditPhone'] as String?,
creditAccount: json['creditAccount'] as String?,
creditRef: json['creditRef'] as String?,
billClientId: json['billClientId'] as String?,
billName: json['billName'] as String,
paymentProcessorName: json['paymentProcessorName'] as String,
providerImage: json['providerImage'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
confirmationStatus: json['confirmationStatus'] as String,
paymentStatus: json['paymentStatus'] as String,
integrationStatus: json['integrationStatus'] as String,
pollingStatus: json['pollingStatus'] as String,
orderId: json['orderId'] as String?,
orderTrace: json['orderTrace'] as String?,
orderTransactionTrace: json['orderTransactionTrace'] as String?,
erpSalesRef: json['erpSalesRef'] as String?,
erpSalesPaymentRef: json['erpSalesPaymentRef'] as String?,
workflowId: (json['workflowId'] as num).toInt(),
type: json['type'] as String?,
authType: json['authType'] as String?,
responseCode: json['responseCode'] as String?,
status: json['status'] as String?,
errorMessage: json['errorMessage'] as String?,
billProductName: json['billProductName'] as String?,
creditName: json['creditName'] as String?,
creditEmail: json['creditEmail'] as String?,
productUid: json['productUid'] as String?,
targetUrl: json['targetUrl'] as String?,
additionalData: const AdditionalDataJsonConverter().fromJson(
json['additionalData'] as List?,
),
);
Map<String, dynamic> _$TransactionModelToJson(
TransactionModel instance,
) => <String, dynamic>{
'id': instance.id,
'createdAt': _$JsonConverterToJson<String, DateTime>(
instance.createdAt,
const DateTimeJsonConverter().toJson,
),
'userId': instance.userId,
'trace': instance.trace,
'amount': const DecimalJsonConverter().toJson(instance.amount),
'charge': const DecimalJsonConverter().toJson(instance.charge),
'gatewayCharge': const DecimalJsonConverter().toJson(instance.gatewayCharge),
'tax': const DecimalJsonConverter().toJson(instance.tax),
'totalAmount': const DecimalJsonConverter().toJson(instance.totalAmount),
'reference': instance.reference,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'integrationProcessorLabel': instance.integrationProcessorLabel,
'confirmationProcessorLabel': instance.confirmationProcessorLabel,
'providerLabel': instance.providerLabel,
'debitPhone': instance.debitPhone,
'debitCurrency': instance.debitCurrency,
'debitRef': instance.debitRef,
'creditPhone': instance.creditPhone,
'creditAccount': instance.creditAccount,
'creditRef': instance.creditRef,
'billClientId': instance.billClientId,
'billName': instance.billName,
'paymentProcessorName': instance.paymentProcessorName,
'providerImage': instance.providerImage,
'paymentProcessorImage': instance.paymentProcessorImage,
'confirmationStatus': instance.confirmationStatus,
'paymentStatus': instance.paymentStatus,
'integrationStatus': instance.integrationStatus,
'pollingStatus': instance.pollingStatus,
'orderId': instance.orderId,
'orderTrace': instance.orderTrace,
'orderTransactionTrace': instance.orderTransactionTrace,
'erpSalesRef': instance.erpSalesRef,
'erpSalesPaymentRef': instance.erpSalesPaymentRef,
'workflowId': instance.workflowId,
'type': instance.type,
'authType': instance.authType,
'responseCode': instance.responseCode,
'status': instance.status,
'errorMessage': instance.errorMessage,
'billProductName': instance.billProductName,
'creditName': instance.creditName,
'creditEmail': instance.creditEmail,
'productUid': instance.productUid,
'targetUrl': instance.targetUrl,
'additionalData': const AdditionalDataJsonConverter().toJson(
instance.additionalData,
),
};
Value? _$JsonConverterFromJson<Json, Value>(
Object? json,
Value? Function(Json json) fromJson,
) => json == null ? null : fromJson(json as Json);
Json? _$JsonConverterToJson<Json, Value>(
Value? value,
Json? Function(Value value) toJson,
) => value == null ? null : toJson(value);

View File

@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:timeago/timeago.dart' as timeago;
/// A reusable transaction list item widget.
/// Used by both [HomeScreen] and [HistoryScreen].
class TransactionItemWidget extends StatefulWidget {
final Map<String, dynamic> transaction;
final bool enabled;
final Future<void> Function()? onRepeat;
const TransactionItemWidget({
super.key,
required this.transaction,
this.enabled = false,
this.onRepeat,
});
@override
State<TransactionItemWidget> createState() => _TransactionItemWidgetState();
}
class _TransactionItemWidgetState extends State<TransactionItemWidget> {
bool _isRepeating = false;
Future<void> _handleRepeat() async {
if (_isRepeating || widget.onRepeat == null) return;
setState(() => _isRepeating = true);
try {
await widget.onRepeat!();
} finally {
if (mounted) setState(() => _isRepeating = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final status = widget.transaction['integrationStatus'] as String?;
final isSuccess = status == 'SUCCESS';
final isPending = status == 'PENDING';
final statusIcon = isSuccess
? Icons.check_circle_rounded
: isPending
? Icons.pending_rounded
: Icons.cancel_rounded;
final statusColor = isSuccess
? const Color(0xFF10B981)
: isPending
? const Color(0xFFF59E0B)
: const Color(0xFFEF4444);
return Skeletonizer(
enabled: widget.enabled,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
context.push("/receipt/${widget.transaction['id']}");
},
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Main row: provider image, bill name & date, amount
Row(
children: [
// Provider image
Container(
width: 44,
height: 44,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(12),
),
child: Image(
image: AssetImage(
"assets/${widget.transaction['paymentProcessorImage'] ?? ''}",
),
errorBuilder: (_, __, ___) => Icon(
Icons.receipt_long_rounded,
size: 20,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: 14),
// Bill name & date
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
widget.transaction['billName'] ?? '',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? Colors.white
: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Icon(
statusIcon,
size: 16,
color: statusColor,
),
],
),
const SizedBox(height: 4),
Text(
widget.transaction['createdAt'] != null
? timeago.format(
DateTime.parse(
widget.transaction['createdAt'],
),
)
: '',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: isDark
? Colors.white54
: Colors.grey.shade500,
),
),
],
),
),
// Amount
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
(widget.transaction['amount'] as num?)
?.toStringAsFixed(2) ??
'',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
Text(
(widget.transaction['totalAmount'] as num?)
?.toStringAsFixed(2) ??
'',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: theme.colorScheme.primary.withValues(
alpha: 0.7,
),
),
),
],
),
],
),
// Quick actions row (only shown when onRepeat is provided)
if (widget.onRepeat != null) ...[
const SizedBox(height: 10),
Divider(
height: 1,
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: _handleRepeat,
icon: _isRepeating
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.primary,
),
)
: const Icon(Icons.repeat_rounded, size: 16),
label: Text(
_isRepeating ? 'Repeating...' : 'Repeat',
style: const TextStyle(fontSize: 12),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
),
),
),
),
);
}
}

View File

@@ -177,6 +177,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
decimal:
dependency: "direct main"
description:
name: decimal
sha256: fc706a5618b81e5b367b01dd62621def37abc096f2b46a9bd9068b64c1fa36d0
url: "https://pub.dev"
source: hosted
version: "3.2.4"
dio:
dependency: "direct main"
description:
@@ -680,6 +688,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
rational:
dependency: transitive
description:
name: rational
sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336
url: "https://pub.dev"
source: hosted
version: "2.2.3"
share_plus:
dependency: "direct main"
description:

View File

@@ -61,6 +61,7 @@ dependencies:
google_sign_in_web: ^1.1.0
flutter_web_plugins:
sdk: flutter
decimal: ^3.2.4
dev_dependencies:
flutter_test: