diff --git a/frontend/lib/core/database/app_database.dart b/frontend/lib/core/database/app_database.dart index fe5569d..e7ea97e 100644 --- a/frontend/lib/core/database/app_database.dart +++ b/frontend/lib/core/database/app_database.dart @@ -26,6 +26,26 @@ part 'app_database.g.dart'; GcashFeeConfig, SyncQueue, ]) +class DualFloatBalance { + final double gcashFloat; + final double cashDrawer; + final double totalFeesEarned; + final int totalTransactions; + final int cashInCount; + final int cashOutCount; + final int storePaymentCount; + + const DualFloatBalance({ + required this.gcashFloat, + required this.cashDrawer, + required this.totalFeesEarned, + required this.totalTransactions, + required this.cashInCount, + required this.cashOutCount, + required this.storePaymentCount, + }); +} + class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); @@ -472,7 +492,7 @@ class AppDatabase extends _$AppDatabase { } // -------------------------------------------------------------------------- - // GCASH LEDGER OPERATIONS + // GCASH LEDGER OPERATIONS & DUAL-FLOAT ENGINE // -------------------------------------------------------------------------- /// Watch GCash transaction history @@ -480,8 +500,18 @@ class AppDatabase extends _$AppDatabase { return (select(gcashLedger)..orderBy([(g) => OrderingTerm.desc(g.timestamp)])).watch(); } + /// Check if a GCash reference number has already been logged (anti-fraud/scam check) + Future isReferenceNumberUsed(String refNo) async { + final clean = refNo.replaceAll(RegExp(r'\s+'), '').trim(); + if (clean.isEmpty) return false; + final all = await select(gcashLedger).get(); + return all.any((t) => + t.referenceNumber != null && + t.referenceNumber!.replaceAll(RegExp(r'\s+'), '').trim().toLowerCase() == clean.toLowerCase()); + } + /// Record GCash cash-in / cash-out with convenience fee - Future recordGcash({ + Future recordGcash({ required String transactionType, required double amount, required double feeCharged, @@ -523,6 +553,204 @@ class AppDatabase extends _$AppDatabase { }, ); }); + + return gcashId; + } + + /// Alias for recordGcash to support flexible naming + Future recordGcashEntry({ + required String transactionType, + required double amount, + required double feeCharged, + String? referenceNumber, + String? customerName, + String? customerPhone, + String? notes, + }) => recordGcash( + transactionType: transactionType, + amount: amount, + feeCharged: feeCharged, + referenceNumber: referenceNumber, + customerName: customerName, + customerPhone: customerPhone, + notes: notes, + ); + + /// Watch customizable fee schedule brackets + Stream> watchFeeBrackets() { + return (select(gcashFeeConfig)..orderBy([(b) => OrderingTerm.asc(b.minAmount)])).watch(); + } + + /// Get current fee brackets + Future> getFeeBrackets() { + return (select(gcashFeeConfig)..orderBy([(b) => OrderingTerm.asc(b.minAmount)])).get(); + } + + /// Seed default Philippine sari-sari store fee schedule if empty + Future seedDefaultFeeBrackets() async { + final existing = await select(gcashFeeConfig).get(); + if (existing.isNotEmpty) return; + + final defaultBrackets = [ + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 1.0, + maxAmount: 500.0, + fixedFee: const Value(10.0), + percentageFee: const Value(0.0), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 501.0, + maxAmount: 1000.0, + fixedFee: const Value(15.0), + percentageFee: const Value(0.0), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 1001.0, + maxAmount: 1500.0, + fixedFee: const Value(20.0), + percentageFee: const Value(0.0), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 1501.0, + maxAmount: 2000.0, + fixedFee: const Value(25.0), + percentageFee: const Value(0.0), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 2001.0, + maxAmount: 3000.0, + fixedFee: const Value(35.0), + percentageFee: const Value(0.0), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 3001.0, + maxAmount: 5000.0, + fixedFee: const Value(50.0), + percentageFee: const Value(0.0), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 5001.0, + maxAmount: 10000.0, + fixedFee: const Value(0.0), + percentageFee: const Value(1.5), + ), + GcashFeeConfigCompanion.insert( + id: _uuid.v4(), + minAmount: 10001.0, + maxAmount: 50000.0, + fixedFee: const Value(0.0), + percentageFee: const Value(2.0), + ), + ]; + + for (final bracket in defaultBrackets) { + await into(gcashFeeConfig).insert(bracket); + } + } + + /// Upsert a fee bracket with cloud sync queueing + Future saveFeeBracket(GcashFeeConfigCompanion bracket) async { + await into(gcashFeeConfig).insertOnConflictUpdate(bracket); + await _enqueueSync( + entityTable: 'gcash_fee_config', + entityId: bracket.id.value, + operation: 'UPSERT', + payload: { + 'id': bracket.id.value, + 'minAmount': bracket.minAmount.value, + 'maxAmount': bracket.maxAmount.value, + 'fixedFee': bracket.fixedFee.present ? bracket.fixedFee.value : 0.0, + 'percentageFee': bracket.percentageFee.present ? bracket.percentageFee.value : 0.0, + }, + ); + } + + /// Delete a fee bracket with sync queueing + Future deleteFeeBracket(String id) async { + await (delete(gcashFeeConfig)..where((b) => b.id.equals(id))).go(); + await _enqueueSync( + entityTable: 'gcash_fee_config', + entityId: id, + operation: 'DELETE', + payload: {'id': id}, + ); + } + + /// Calculate convenience fee for a given amount using active brackets + Future calculateGcashFee(double amount) async { + final brackets = await getFeeBrackets(); + for (final b in brackets) { + if (amount >= b.minAmount && amount <= b.maxAmount) { + if (b.percentageFee > 0) { + return (amount * (b.percentageFee / 100.0)); + } + return b.fixedFee; + } + } + // Default fallback brackets if table is empty + if (amount <= 500) return 10.0; + if (amount <= 1000) return 15.0; + if (amount <= 1500) return 20.0; + if (amount <= 2000) return 25.0; + if (amount <= 3000) return 35.0; + if (amount <= 5000) return 50.0; + return (amount * 0.015); + } + + /// Watch real-time dual-float balances (GCash E-Wallet vs Physical Cash Drawer) + Stream watchDualFloatBalance({ + double baseCash = 5000.0, + double baseGcash = 10000.0, + }) { + return watchGcashTransactions().asyncMap((txList) async { + final cashSales = await (select(sales)..where((s) => s.paymentMethod.equals('CASH'))).get(); + + double gcash = baseGcash; + double cash = baseCash; + double fees = 0.0; + int cashIn = 0; + int cashOut = 0; + int storePay = 0; + + for (final tx in txList) { + fees += tx.feeCharged; + if (tx.transactionType == 'CASH_IN') { + cashIn++; + gcash -= tx.amount; // Store sent GCash + cash += (tx.amount + tx.feeCharged); // Store received cash + fee + } else if (tx.transactionType == 'CASH_OUT') { + cashOut++; + gcash += tx.amount; // Store received GCash + cash -= (tx.amount - tx.feeCharged); // Cash given to customer minus fee + } else if (tx.transactionType == 'STORE_PAYMENT') { + storePay++; + gcash += tx.amount; // Direct store sale paid to GCash + } else if (tx.transactionType == 'FLOAT_ADJUSTMENT') { + gcash += tx.amount; + } + } + + for (final s in cashSales) { + cash += s.totalAmount; + } + + return DualFloatBalance( + gcashFloat: gcash, + cashDrawer: cash, + totalFeesEarned: fees, + totalTransactions: txList.length, + cashInCount: cashIn, + cashOutCount: cashOut, + storePaymentCount: storePay, + ); + }); } // -------------------------------------------------------------------------- diff --git a/frontend/lib/features/gcash/gcash_receipt_ocr_service.dart b/frontend/lib/features/gcash/gcash_receipt_ocr_service.dart new file mode 100644 index 0000000..6ebb1ba --- /dev/null +++ b/frontend/lib/features/gcash/gcash_receipt_ocr_service.dart @@ -0,0 +1,92 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; + +class GcashOcrResult { + final String? referenceNumber; + final double? amount; + final String rawText; + final bool isSuccess; + + const GcashOcrResult({ + this.referenceNumber, + this.amount, + required this.rawText, + required this.isSuccess, + }); +} + +class GcashReceiptOcrService { + /// Primary regex for extracting 13-digit GCash reference number + /// Handles spaced formats: '1002 481 029412', '0021 482 910245', or solid '1002481029412' + static final RegExp refRegex = RegExp( + r'(?:Ref(?:\.|erence)?(?:\s*No\.?)?[:\s]*|Reference\s*ID[:\s]*)?([0-9]{4}[\s\-]?[0-9]{3,4}[\s\-]?[0-9]{5,6}|[0-9]{13})', + caseSensitive: false, + ); + + /// Secondary regex for extracting Peso amounts: 'PHP 500.00', 'P 1,000.00', 'Php500' + static final RegExp amountRegex = RegExp( + r'(?:PHP|Php|P|₱)\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{2})?)', + caseSensitive: false, + ); + + /// Parse text directly using regex slotting + static GcashOcrResult parseRawText(String text) { + String? foundRef; + double? foundAmount; + + // Search for 13-digit reference number + final refMatches = refRegex.allMatches(text); + for (final match in refMatches) { + final rawMatch = match.group(1) ?? match.group(0); + if (rawMatch != null) { + final digitsOnly = rawMatch.replaceAll(RegExp(r'[^0-9]'), ''); + if (digitsOnly.length == 13) { + foundRef = digitsOnly; + break; + } + } + } + + // Search for Amount + final amountMatch = amountRegex.firstMatch(text); + if (amountMatch != null) { + final amtStr = amountMatch.group(1)?.replaceAll(',', ''); + if (amtStr != null) { + foundAmount = double.tryParse(amtStr); + } + } + + return GcashOcrResult( + referenceNumber: foundRef, + amount: foundAmount, + rawText: text, + isSuccess: foundRef != null, + ); + } + + /// Process an image file from camera / gallery using Google ML Kit + static Future processReceiptImage(String imagePath) async { + // Fallback on platforms without Google ML Kit support (e.g. desktop/web during tests) + if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) { + return const GcashOcrResult( + rawText: 'MLKit OCR not supported on desktop/web platform. Use manual entry or mobile device.', + isSuccess: false, + ); + } + + try { + final inputImage = InputImage.fromFilePath(imagePath); + final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin); + final RecognizedText recognizedText = await textRecognizer.processImage(inputImage); + await textRecognizer.close(); + + return parseRawText(recognizedText.text); + } catch (e) { + return GcashOcrResult( + rawText: 'Error performing OCR: $e', + isSuccess: false, + ); + } + } +} diff --git a/frontend/lib/features/gcash/gcash_screen.dart b/frontend/lib/features/gcash/gcash_screen.dart index caa7687..a8eac26 100644 --- a/frontend/lib/features/gcash/gcash_screen.dart +++ b/frontend/lib/features/gcash/gcash_screen.dart @@ -1,186 +1,591 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:uuid/uuid.dart'; + import '../../core/constants/app_colors.dart'; +import '../../core/database/app_database.dart'; import '../../core/localization/language_provider.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/utils/feedback_helper.dart'; +import 'rate_card_pdf_service.dart'; +import 'gcash_receipt_ocr_service.dart'; -class GcashScreen extends ConsumerWidget { +class GcashScreen extends ConsumerStatefulWidget { const GcashScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _GcashScreenState(); +} + +class _GcashScreenState extends ConsumerState with SingleTickerProviderStateMixin { + late TabController _tabController; + static const _uuid = Uuid(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + // Seed default fee brackets if empty + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(databaseProvider).seedDefaultFeeBrackets(); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final tr = ref.watch(stringsProvider); + final db = ref.watch(databaseProvider); final isLight = Theme.of(context).brightness == Brightness.light; final borderColor = isLight ? AppColors.borderLight : AppColors.borderDark; - final feeBrackets = [ - {'range': '₱ 1 - ₱ 500', 'fee': '₱ 10.00'}, - {'range': '₱ 501 - ₱ 1,000', 'fee': '₱ 15.00'}, - {'range': '₱ 1,001 - ₱ 1,500', 'fee': '₱ 20.00'}, - {'range': '₱ 1,501 - ₱ 2,000', 'fee': '₱ 25.00'}, - {'range': '₱ 2,001 - ₱ 3,000', 'fee': '₱ 35.00'}, - {'range': '₱ 3,001 - ₱ 5,000', 'fee': '₱ 50.00'}, - ]; - return Scaffold( appBar: AppBar( title: Text( tr.gcashTitle, style: GoogleFonts.outfit(fontWeight: FontWeight.w700, fontSize: 20), ), + actions: [ + IconButton( + tooltip: tr.isTaglish ? 'I-print ang Rate Card (PDF)' : 'Print Rate Card (PDF)', + icon: const Icon(Icons.print_rounded), + onPressed: () => _showPrintRateCardDialog(context, db, tr), + ), + ], ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Soft UI Dual Balances (GCash vs Cash Box) - Row( - children: [ - Expanded( - child: _buildSoftBalanceCard( - context, - title: tr.ewalletFloat, - amount: '₱ 8,450.00', - icon: Icons.account_balance_wallet_outlined, - color: Colors.blue.shade600, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildSoftBalanceCard( - context, - title: tr.cashBoxDrawer, - amount: '₱ 4,120.00', - icon: Icons.payments_outlined, - color: AppColors.primary, - ), - ), - ], - ), - const SizedBox(height: 18), + body: StreamBuilder( + stream: db.watchDualFloatBalance(), + builder: (context, balanceSnapshot) { + final balance = balanceSnapshot.data ?? + const DualFloatBalance( + gcashFloat: 10000.0, + cashDrawer: 5000.0, + totalFeesEarned: 0.0, + totalTransactions: 0, + cashInCount: 0, + cashOutCount: 0, + storePaymentCount: 0, + ); - // Quick Action Buttons (Soft Tactile) - Row( + return Column( children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () { - _showSoftGcashModal(context, tr, isCashIn: true); - }, - icon: const Icon(Icons.arrow_upward_rounded, size: 18), - label: Text(tr.cashInAction, style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w700)), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue.shade600, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () { - _showSoftGcashModal(context, tr, isCashIn: false); - }, - icon: const Icon(Icons.arrow_downward_rounded, size: 18), - label: Text(tr.cashOutAction, style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w700)), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - ), - const SizedBox(height: 20), - - // Fee Scheduler Table (Soft UI Card) - Container( - padding: const EdgeInsets.all(18), - decoration: SoftDecorations.card(context, radius: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( children: [ - Text( - tr.feeSchedule, - style: GoogleFonts.outfit(fontWeight: FontWeight.w700, fontSize: 16), - ), - const Icon(Icons.receipt_long_rounded, color: Colors.blue, size: 22), - ], - ), - const SizedBox(height: 12), - Divider(color: borderColor, height: 1), - Table( - children: [ - TableRow( + // Dual Balances (GCash vs Cash Box Drawer) + Row( children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Text( - tr.isTaglish ? 'Halaga ng Transaksyon' : 'Transaction Range', - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - fontSize: 12, - color: isLight ? AppColors.textSecondaryLight : AppColors.textSecondaryDark, + Expanded( + child: _buildSoftBalanceCard( + context, + title: tr.ewalletFloat, + amount: '₱ ${balance.gcashFloat.toStringAsFixed(2)}', + subtitle: '${balance.cashInCount} Cash-In | ${balance.cashOutCount} Cash-Out', + icon: Icons.account_balance_wallet_outlined, + color: Colors.blue.shade600, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSoftBalanceCard( + context, + title: tr.cashBoxDrawer, + amount: '₱ ${balance.cashDrawer.toStringAsFixed(2)}', + subtitle: '+₱${balance.totalFeesEarned.toStringAsFixed(2)} Fee Profit', + icon: Icons.payments_outlined, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Quick Action Buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showTransactionModal(context, db, tr, isCashIn: true), + icon: const Icon(Icons.arrow_upward_rounded, size: 18), + label: Text( + tr.cashInAction, + style: GoogleFonts.outfit(fontSize: 13, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Text( - tr.isTaglish ? 'Patong / Tubo' : 'Fee / Profit', - textAlign: TextAlign.end, - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - fontSize: 12, - color: isLight ? AppColors.textSecondaryLight : AppColors.textSecondaryDark, + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showTransactionModal(context, db, tr, isCashIn: false), + icon: const Icon(Icons.arrow_downward_rounded, size: 18), + label: Text( + tr.cashOutAction, + style: GoogleFonts.outfit(fontSize: 13, fontWeight: FontWeight.w700), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ), ], ), - ...feeBrackets.map((f) { - return TableRow( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - f['range']!, - style: GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 13), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - f['fee']!, - textAlign: TextAlign.end, - style: GoogleFonts.outfit( - fontWeight: FontWeight.w800, - fontSize: 14, - color: AppColors.primary, - ), - ), - ), + const SizedBox(height: 12), + + // Sub-Tabs Header + Container( + decoration: BoxDecoration( + color: isLight ? Colors.grey.shade100 : Colors.grey.shade900, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + child: TabBar( + controller: _tabController, + indicatorColor: AppColors.primary, + labelColor: isLight ? AppColors.primary : Colors.white, + unselectedLabelColor: isLight ? AppColors.textSecondaryLight : AppColors.textSecondaryDark, + indicatorSize: TabBarIndicatorSize.tab, + tabs: [ + Tab(text: tr.isTaglish ? 'Kwaderno (Ledger)' : 'Ledger History'), + Tab(text: tr.feeSchedule), ], - ); - }), + ), + ), ], ), - ], - ), - ), - ], + ), + + // Sub-Tabs Content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildLedgerList(db, tr, isLight), + _buildFeeScheduleView(db, tr, isLight, borderColor), + ], + ), + ), + ], + ); + }, ), ); } - void _showSoftGcashModal(BuildContext context, AppStrings tr, {required bool isCashIn}) { + // -------------------------------------------------------------------------- + // LEDGER TRANSACTION HISTORY + // -------------------------------------------------------------------------- + + Widget _buildLedgerList(AppDatabase db, AppStrings tr, bool isLight) { + return StreamBuilder>( + stream: db.watchGcashTransactions(), + builder: (context, snapshot) { + final transactions = snapshot.data ?? []; + if (transactions.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history_toggle_off_rounded, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 10), + Text( + tr.isTaglish ? 'Wala pang naitalang GCash transaction.' : 'No GCash transactions recorded yet.', + style: GoogleFonts.inter(color: Colors.grey.shade600, fontSize: 13), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: transactions.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final tx = transactions[index]; + final isCashOut = tx.transactionType == 'CASH_OUT'; + final isStorePayment = tx.transactionType == 'STORE_PAYMENT'; + + Color typeColor = Colors.blue.shade600; + String typeLabel = 'Cash-In'; + IconData typeIcon = Icons.arrow_upward_rounded; + + if (isCashOut) { + typeColor = AppColors.primary; + typeLabel = 'Cash-Out'; + typeIcon = Icons.arrow_downward_rounded; + } else if (isStorePayment) { + typeColor = Colors.purple.shade600; + typeLabel = 'Store Sale'; + typeIcon = Icons.shopping_bag_outlined; + } + + return Container( + padding: const EdgeInsets.all(12), + decoration: SoftDecorations.card(context, radius: 12), + child: Row( + children: [ + CircleAvatar( + backgroundColor: typeColor.withAlpha(25), + radius: 20, + child: Icon(typeIcon, color: typeColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: typeColor.withAlpha(25), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + typeLabel, + style: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w700, color: typeColor), + ), + ), + const Spacer(), + Text( + '₱ ${tx.amount.toStringAsFixed(2)}', + style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w800), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + if (tx.customerName != null && tx.customerName!.isNotEmpty) + Text( + '${tx.customerName!} • ', + style: GoogleFonts.inter(fontSize: 11, fontWeight: FontWeight.w600), + ), + if (tx.customerPhone != null && tx.customerPhone!.isNotEmpty) + Text( + tx.customerPhone!, + style: GoogleFonts.inter(fontSize: 11, color: Colors.grey.shade600), + ), + const Spacer(), + if (tx.feeCharged > 0) + Text( + '+₱${tx.feeCharged.toStringAsFixed(2)} Fee', + style: GoogleFonts.outfit( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + if (tx.referenceNumber != null && tx.referenceNumber!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'Ref: ${tx.referenceNumber!}', + style: GoogleFonts.jetBrainsMono( + fontSize: 10, + color: isLight ? Colors.blue.shade900 : Colors.blue.shade300, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + // -------------------------------------------------------------------------- + // CUSTOMIZABLE FEE SCHEDULE BRACKETS VIEW + // -------------------------------------------------------------------------- + + Widget _buildFeeScheduleView(AppDatabase db, AppStrings tr, bool isLight, Color borderColor) { + return StreamBuilder>( + stream: db.watchFeeBrackets(), + builder: (context, snapshot) { + final brackets = snapshot.data ?? []; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + tr.feeSchedule, + style: GoogleFonts.outfit(fontWeight: FontWeight.w700, fontSize: 16), + ), + TextButton.icon( + onPressed: () => _showEditBracketModal(context, db, tr), + icon: const Icon(Icons.add_rounded, size: 18), + label: Text( + tr.isTaglish ? 'Magdagdag ng Bracket' : 'Add Bracket', + style: GoogleFonts.outfit(fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + decoration: SoftDecorations.card(context, radius: 14), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + tr.isTaglish ? 'Saklaw ng Halaga' : 'Amount Range', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isLight ? AppColors.textSecondaryLight : AppColors.textSecondaryDark, + ), + ), + Text( + tr.isTaglish ? 'Patong (Fee)' : 'Convenience Fee', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isLight ? AppColors.textSecondaryLight : AppColors.textSecondaryDark, + ), + ), + ], + ), + ), + Divider(color: borderColor, height: 1), + if (brackets.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + tr.isTaglish ? 'Walang fee brackets.' : 'No fee brackets found.', + style: GoogleFonts.inter(color: Colors.grey.shade600, fontSize: 12), + ), + ), + ...brackets.map((b) { + final rangeStr = '₱ ${b.minAmount.toStringAsFixed(0)} - ₱ ${b.maxAmount.toStringAsFixed(0)}'; + final feeStr = b.percentageFee > 0 + ? '${b.percentageFee.toStringAsFixed(1)}%' + : '₱ ${b.fixedFee.toStringAsFixed(2)}'; + + return ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), + title: Text(rangeStr, style: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 13)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + feeStr, + style: GoogleFonts.outfit( + fontWeight: FontWeight.w800, + fontSize: 14, + color: AppColors.primary, + ), + ), + const SizedBox(width: 6), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 16), + onPressed: () => _showEditBracketModal(context, db, tr, bracket: b), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 16, color: Colors.redAccent), + onPressed: () async { + await db.deleteFeeBracket(b.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr.isTaglish ? 'Naalis ang bracket!' : 'Bracket removed!'), + duration: const Duration(seconds: 1), + ), + ); + } + }, + ), + ], + ), + ); + }), + ], + ), + ), + ], + ); + }, + ); + } + + // -------------------------------------------------------------------------- + // EDIT BRACKET MODAL + // -------------------------------------------------------------------------- + + void _showEditBracketModal(BuildContext context, AppDatabase db, AppStrings tr, {GcashFeeBracket? bracket}) { + final minCtrl = TextEditingController(text: bracket?.minAmount.toStringAsFixed(0) ?? ''); + final maxCtrl = TextEditingController(text: bracket?.maxAmount.toStringAsFixed(0) ?? ''); + final fixedCtrl = TextEditingController(text: bracket?.fixedFee.toStringAsFixed(2) ?? ''); + final percentCtrl = TextEditingController(text: bracket?.percentageFee.toStringAsFixed(1) ?? '0.0'); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + final isLight = Theme.of(ctx).brightness == Brightness.light; + return Container( + padding: EdgeInsets.fromLTRB(20, 20, 20, MediaQuery.of(ctx).viewInsets.bottom + 20), + decoration: BoxDecoration( + color: isLight ? Colors.white : AppColors.surfaceDark, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + bracket == null + ? (tr.isTaglish ? 'Magdagdag ng Fee Bracket' : 'Add Fee Bracket') + : (tr.isTaglish ? 'I-edit ang Fee Bracket' : 'Edit Fee Bracket'), + style: GoogleFonts.outfit(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: TextField( + controller: minCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: tr.isTaglish ? 'Pinakamababa (Min ₱)' : 'Min Amount (₱)', + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: maxCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: tr.isTaglish ? 'Pinakamataas (Max ₱)' : 'Max Amount (₱)', + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: fixedCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: tr.isTaglish ? 'Fixed Fee (₱)' : 'Fixed Fee (₱)', + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: percentCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Percentage (%)', + ), + ), + ), + ], + ), + const SizedBox(height: 18), + ElevatedButton( + onPressed: () async { + final minAmt = double.tryParse(minCtrl.text.trim()) ?? 0.0; + final maxAmt = double.tryParse(maxCtrl.text.trim()) ?? 0.0; + final fixedFee = double.tryParse(fixedCtrl.text.trim()) ?? 0.0; + final percentage = double.tryParse(percentCtrl.text.trim()) ?? 0.0; + + if (maxAmt <= minAmt) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr.isTaglish ? 'Dapat mas mataas ang Max kaysa Min!' : 'Max amount must exceed min!'), + backgroundColor: Colors.redAccent, + ), + ); + return; + } + + final id = bracket?.id ?? _uuid.v4(); + await db.saveFeeBracket( + GcashFeeConfigCompanion.insert( + id: id, + minAmount: minAmt, + maxAmount: maxAmt, + fixedFee: drift.Value(fixedFee), + percentageFee: drift.Value(percentage), + ), + ); + + if (context.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr.isTaglish ? '✅ Na-save ang Fee Bracket!' : '✅ Fee Bracket saved!'), + backgroundColor: AppColors.primary, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + child: Text(tr.isTaglish ? 'I-SAVE ANG BRACKET' : 'SAVE BRACKET'), + ), + ], + ), + ); + }, + ); + } + + // -------------------------------------------------------------------------- + // CASH-IN & CASH-OUT TRANSACTION MODAL + // -------------------------------------------------------------------------- + + void _showTransactionModal(BuildContext context, AppDatabase db, AppStrings tr, {required bool isCashIn}) { + final phoneCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + final amountCtrl = TextEditingController(); + final refCtrl = TextEditingController(); + + double calculatedFee = 10.0; + bool isDuplicateRef = false; + showModalBottomSheet( context: context, isScrollControlled: true, @@ -190,93 +595,427 @@ class GcashScreen extends ConsumerWidget { final isLight = Theme.of(ctx).brightness == Brightness.light; final borderColor = isLight ? AppColors.borderLight : AppColors.borderDark; + return StatefulBuilder( + builder: (sheetContext, setModalState) { + void updateFee(String val) async { + final amt = double.tryParse(val.trim()) ?? 0.0; + final fee = await db.calculateGcashFee(amt); + setModalState(() { + calculatedFee = fee; + }); + } + + void checkDuplicateRef(String val) async { + if (val.trim().isEmpty) { + setModalState(() => isDuplicateRef = false); + return; + } + final isUsed = await db.isReferenceNumberUsed(val.trim()); + setModalState(() => isDuplicateRef = isUsed); + } + + return Container( + padding: EdgeInsets.fromLTRB(20, 20, 20, MediaQuery.of(ctx).viewInsets.bottom + 20), + decoration: BoxDecoration( + color: isLight ? Colors.white : AppColors.surfaceDark, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + border: Border.all(color: borderColor, width: 1.0), + ), + child: SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: (isCashIn ? Colors.blue.shade600 : AppColors.primary).withAlpha(25), + radius: 16, + child: Icon( + isCashIn ? Icons.arrow_upward_rounded : Icons.arrow_downward_rounded, + color: isCashIn ? Colors.blue.shade600 : AppColors.primary, + size: 18, + ), + ), + const SizedBox(width: 10), + Text( + isCashIn ? tr.cashInAction : tr.cashOutAction, + style: GoogleFonts.outfit(fontSize: 18, fontWeight: FontWeight.w700), + ), + ], + ), + const SizedBox(height: 14), + + // Customer Phone + TextField( + controller: phoneCtrl, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: tr.isTaglish ? 'Numero ng GCash (09xx...)' : 'GCash Phone Number', + prefixIcon: const Icon(Icons.phone_android_rounded, size: 20), + ), + ), + const SizedBox(height: 10), + + // Customer Name (Optional) + TextField( + controller: nameCtrl, + decoration: InputDecoration( + labelText: tr.isTaglish ? 'Pangalan ng Customer (Opsyonal)' : 'Customer Name (Optional)', + prefixIcon: const Icon(Icons.person_outline_rounded, size: 20), + ), + ), + const SizedBox(height: 10), + + // Amount Field + TextField( + controller: amountCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: updateFee, + decoration: InputDecoration( + labelText: tr.isTaglish ? 'Halaga (Halimbawa: 500)' : 'Amount (e.g. 500)', + prefixIcon: const Icon(Icons.attach_money_rounded, size: 20), + ), + ), + const SizedBox(height: 8), + + // Quick Amount Chips + Wrap( + spacing: 6, + children: [100.0, 300.0, 500.0, 1000.0, 2000.0, 5000.0].map((quickAmt) { + return ActionChip( + label: Text('₱${quickAmt.toStringAsFixed(0)}', style: GoogleFonts.inter(fontSize: 11)), + onPressed: () { + amountCtrl.text = quickAmt.toStringAsFixed(0); + updateFee(quickAmt.toStringAsFixed(0)); + }, + ); + }).toList(), + ), + const SizedBox(height: 10), + + // Reference Number & OCR Scan Button + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: refCtrl, + onChanged: checkDuplicateRef, + decoration: InputDecoration( + labelText: tr.isTaglish + ? '13-digit Reference No. (Opsyonal)' + : '13-digit Reference No. (Optional)', + prefixIcon: const Icon(Icons.receipt_rounded, size: 20), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: tr.isTaglish ? 'I-scan ang Resibo (OCR)' : 'Scan Receipt (OCR)', + icon: const Icon(Icons.camera_alt_outlined), + onPressed: () async { + // Simulate or trigger text OCR slotting + _showOcrScanDialog(sheetContext, (extractedRef, extractedAmt) { + if (extractedRef != null) { + refCtrl.text = extractedRef; + checkDuplicateRef(extractedRef); + } + if (extractedAmt != null && extractedAmt > 0) { + amountCtrl.text = extractedAmt.toStringAsFixed(0); + updateFee(extractedAmt.toStringAsFixed(0)); + } + }); + }, + ), + ], + ), + + // Duplicate Reference Warning (Anti-Scam) + if (isDuplicateRef) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade300), + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + tr.isTaglish + ? 'Babala: Naitala na ang Reference No. na ito! Baka ito ay recycled screenshot.' + : 'Warning: This Reference No. was already logged! Potential recycled screenshot.', + style: GoogleFonts.inter(fontSize: 11, color: Colors.red.shade900, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + + // Auto-Calculated Fee Card + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: AppColors.primaryLight.withAlpha(200), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withAlpha(80), width: 1.0), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + tr.isTaglish ? 'Awtomatikong Patong (Fee):' : 'Calculated Convenience Fee:', + style: GoogleFonts.inter( + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppColors.primary, + ), + ), + Text( + '₱ ${calculatedFee.toStringAsFixed(2)}', + style: GoogleFonts.outfit( + fontWeight: FontWeight.w800, + fontSize: 18, + color: AppColors.primary, + ), + ), + ], + ), + ), + const SizedBox(height: 18), + + // Submit Button + ElevatedButton( + onPressed: () async { + final amt = double.tryParse(amountCtrl.text.trim()) ?? 0.0; + if (amt <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(tr.isTaglish ? 'Maglagay ng wastong halaga!' : 'Please enter a valid amount!'), + backgroundColor: Colors.redAccent, + ), + ); + return; + } + + final txType = isCashIn ? 'CASH_IN' : 'CASH_OUT'; + final phone = phoneCtrl.text.trim(); + final name = nameCtrl.text.trim(); + final refNo = refCtrl.text.trim(); + + await db.recordGcash( + transactionType: txType, + amount: amt, + feeCharged: calculatedFee, + referenceNumber: refNo.isEmpty ? null : refNo, + customerName: name.isEmpty ? null : name, + customerPhone: phone.isEmpty ? null : phone, + ); + + await FeedbackHelper.triggerSuccessFeedback(); + + if (context.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + tr.isTaglish + ? '✅ ${isCashIn ? "Cash-In" : "Cash-Out"} naitala! (+₱${calculatedFee.toStringAsFixed(2)} tubo)' + : '✅ ${isCashIn ? "Cash-In" : "Cash-Out"} logged! (+₱${calculatedFee.toStringAsFixed(2)} fee)', + ), + backgroundColor: AppColors.primary, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: isCashIn ? Colors.blue.shade600 : AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + tr.isTaglish ? 'I-REHISTRO ANG TRANSAKSYON' : 'RECORD TRANSACTION', + style: GoogleFonts.outfit(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + // -------------------------------------------------------------------------- + // OCR SIMULATION & SCAN DIALOG + // -------------------------------------------------------------------------- + + void _showOcrScanDialog(BuildContext context, Function(String? ref, double? amt) onExtracted) { + final tr = ref.read(stringsProvider); + final pasteCtrl = TextEditingController(); + + showDialog( + context: context, + builder: (dlgCtx) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.document_scanner_rounded, color: Colors.blue), + const SizedBox(width: 8), + Text( + tr.isTaglish ? 'Scan / I-paste ang Resibo' : 'Scan / Paste Receipt', + style: GoogleFonts.outfit(fontWeight: FontWeight.w700, fontSize: 16), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr.isTaglish + ? 'I-paste ang SMS resibo o text mula sa screenshot ng GCash. Awtomatikong kukunin ang 13-digit Reference No. at halaga.' + : 'Paste GCash SMS receipt or screenshot text. Automatically extracts 13-digit Reference No. and amount.', + style: GoogleFonts.inter(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 12), + TextField( + controller: pasteCtrl, + maxLines: 4, + decoration: const InputDecoration( + hintText: 'Halimbawa: You have received PHP 500.00 of GCash from... Ref. No. 1002 481 029412', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dlgCtx), + child: Text(tr.isTaglish ? 'Kanselahin' : 'Cancel'), + ), + ElevatedButton( + onPressed: () { + final text = pasteCtrl.text; + final result = GcashReceiptOcrService.parseRawText(text); + Navigator.pop(dlgCtx); + onExtracted(result.referenceNumber, result.amount); + }, + child: Text(tr.isTaglish ? 'I-extract' : 'Extract'), + ), + ], + ), + ); + } + + // -------------------------------------------------------------------------- + // PRINT RATE CARD DIALOG + // -------------------------------------------------------------------------- + + void _showPrintRateCardDialog(BuildContext context, AppDatabase db, AppStrings tr) async { + final brackets = await db.getFeeBrackets(); + + if (!context.mounted) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) { + final isLight = Theme.of(ctx).brightness == Brightness.light; return Container( padding: const EdgeInsets.all(22), decoration: BoxDecoration( color: isLight ? Colors.white : AppColors.surfaceDark, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - border: Border.all(color: borderColor, width: 1.0), - boxShadow: isLight ? AppColors.softShadowLight : AppColors.softShadowDark, ), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - isCashIn ? tr.cashInAction : tr.cashOutAction, - style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 16), - TextField( - keyboardType: TextInputType.phone, - decoration: InputDecoration( - labelText: tr.isTaglish ? 'Numero ng GCash (09xx...)' : 'GCash Phone Number', - prefixIcon: const Icon(Icons.phone_android_rounded, size: 20), - ), - ), - const SizedBox(height: 12), - TextField( - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration( - labelText: tr.isTaglish ? 'Halaga (Halimbawa: 500)' : 'Amount (e.g. 500)', - prefixIcon: const Icon(Icons.attach_money_rounded, size: 20), - ), - ), - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: AppColors.primaryLight.withAlpha(200), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.primary.withAlpha(80), width: 1.0), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - tr.isTaglish ? 'Awtomatikong Patong (Fee):' : 'Calculated Convenience Fee:', - style: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 13, color: AppColors.primary), - ), - Text( - '₱ 10.00', - style: GoogleFonts.outfit(fontWeight: FontWeight.w800, fontSize: 18, color: AppColors.primary), - ), - ], - ), - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () { - Navigator.pop(ctx); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(tr.isTaglish ? '✅ GCash transaksyon naitala!' : '✅ GCash transaction recorded!'), - backgroundColor: AppColors.primary, - ), - ); - }, - child: Text(tr.isTaglish ? 'I-REHISTRO ANG TRANSAKSYON' : 'RECORD TRANSACTION'), - ), - ], - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr.isTaglish ? 'I-print ang GCash Rate Card' : 'Print GCash Rate Card', + style: GoogleFonts.outfit(fontWeight: FontWeight.w700, fontSize: 18), + ), + const SizedBox(height: 8), + Text( + tr.isTaglish + ? 'Pumili ng format para i-laminate sa tindahan o i-print sa thermal printer:' + : 'Choose format to laminate on store grill or print via thermal printer:', + style: GoogleFonts.inter(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.picture_as_pdf_rounded, color: Colors.blue), + title: Text(tr.isTaglish ? 'Standard A4 Poster (Pang-Laminate)' : 'Standard A4 Poster (Lamination)'), + subtitle: Text(tr.isTaglish ? 'Buong laki para isabit sa tindahan' : 'Full size for store signage', style: const TextStyle(fontSize: 11)), + onTap: () async { + Navigator.pop(ctx); + await RateCardPdfService.printOrShareRateCard( + storeName: 'Sari-Sari Store', + brackets: brackets, + format: RateCardFormat.a4Sheet, + ); + }, + ), + ListTile( + leading: const Icon(Icons.note_rounded, color: Colors.indigo), + title: Text(tr.isTaglish ? 'A5 Half Sheet Flyer' : 'A5 Half Sheet Flyer'), + subtitle: Text(tr.isTaglish ? 'Kalahating papel, compact flyer' : 'Half sheet, compact poster', style: const TextStyle(fontSize: 11)), + onTap: () async { + Navigator.pop(ctx); + await RateCardPdfService.printOrShareRateCard( + storeName: 'Sari-Sari Store', + brackets: brackets, + format: RateCardFormat.a5HalfSheet, + ); + }, + ), + ListTile( + leading: const Icon(Icons.receipt_long_rounded, color: AppColors.primary), + title: Text(tr.isTaglish ? 'Thermal Receipt (58mm / 80mm)' : 'Thermal Receipt (58mm / 80mm)'), + subtitle: Text(tr.isTaglish ? 'Bluetooth thermal printer mini receipt' : 'Bluetooth thermal printer mini receipt', style: const TextStyle(fontSize: 11)), + onTap: () async { + Navigator.pop(ctx); + await RateCardPdfService.printOrShareRateCard( + storeName: 'Sari-Sari Store', + brackets: brackets, + format: RateCardFormat.posThermal80mm, + ); + }, + ), + ], ), ); }, ); } + // -------------------------------------------------------------------------- + // SOFT BALANCE CARD HELPER + // -------------------------------------------------------------------------- + Widget _buildSoftBalanceCard( BuildContext context, { required String title, required String amount, + required String subtitle, required IconData icon, required Color color, }) { final isLight = Theme.of(context).brightness == Brightness.light; return Container( - padding: const EdgeInsets.all(16), - decoration: SoftDecorations.card(context, radius: 16), + padding: const EdgeInsets.all(14), + decoration: SoftDecorations.card(context, radius: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -284,8 +1023,8 @@ class GcashScreen extends ConsumerWidget { children: [ CircleAvatar( backgroundColor: color.withAlpha(25), - radius: 17, - child: Icon(icon, color: color, size: 18), + radius: 15, + child: Icon(icon, color: color, size: 16), ), const SizedBox(width: 8), Expanded( @@ -301,10 +1040,15 @@ class GcashScreen extends ConsumerWidget { ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 8), Text( amount, - style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.w800, color: color), + style: GoogleFonts.outfit(fontSize: 18, fontWeight: FontWeight.w800, color: color), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: GoogleFonts.inter(fontSize: 10, color: Colors.grey.shade600), ), ], ), diff --git a/frontend/lib/features/gcash/rate_card_pdf_service.dart b/frontend/lib/features/gcash/rate_card_pdf_service.dart new file mode 100644 index 0000000..35717a2 --- /dev/null +++ b/frontend/lib/features/gcash/rate_card_pdf_service.dart @@ -0,0 +1,303 @@ +import 'dart:typed_data'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import '../../core/database/app_database.dart'; + +enum RateCardFormat { + a4Sheet, + a5HalfSheet, + posThermal80mm, + posThermal58mm, +} + +class RateCardPdfService { + /// Generate printable rate card PDF bytes + static Future generateRateCardPdf({ + required String storeName, + required List brackets, + RateCardFormat format = RateCardFormat.a4Sheet, + String? gcashNumber, + }) async { + final pdf = pw.Document(); + + final PdfPageFormat pageFormat; + switch (format) { + case RateCardFormat.a4Sheet: + pageFormat = PdfPageFormat.a4; + break; + case RateCardFormat.a5HalfSheet: + pageFormat = PdfPageFormat.a5; + break; + case RateCardFormat.posThermal80mm: + pageFormat = const PdfPageFormat( + 80 * PdfPageFormat.mm, + double.infinity, + marginAll: 4 * PdfPageFormat.mm, + ); + break; + case RateCardFormat.posThermal58mm: + pageFormat = const PdfPageFormat( + 58 * PdfPageFormat.mm, + double.infinity, + marginAll: 3 * PdfPageFormat.mm, + ); + break; + } + + final isThermal = format == RateCardFormat.posThermal80mm || + format == RateCardFormat.posThermal58mm; + + pdf.addPage( + pw.Page( + pageFormat: pageFormat, + build: (pw.Context context) { + if (isThermal) { + return _buildThermalLayout( + storeName: storeName, + brackets: brackets, + gcashNumber: gcashNumber, + is58mm: format == RateCardFormat.posThermal58mm, + ); + } + return _buildStandardSheetLayout( + storeName: storeName, + brackets: brackets, + gcashNumber: gcashNumber, + isA5: format == RateCardFormat.a5HalfSheet, + ); + }, + ), + ); + + return pdf.save(); + } + + /// Print or share rate card using OS printing dialog + static Future printOrShareRateCard({ + required String storeName, + required List brackets, + RateCardFormat format = RateCardFormat.a4Sheet, + String? gcashNumber, + }) async { + final bytes = await generateRateCardPdf( + storeName: storeName, + brackets: brackets, + format: format, + gcashNumber: gcashNumber, + ); + + await Printing.layoutPdf( + onLayout: (PdfPageFormat _) async => bytes, + name: 'TindaGrid_${storeName}_GCash_Rate_Card', + ); + } + + /// High-contrast layout for A4/A5 store posters + static pw.Widget _buildStandardSheetLayout({ + required String storeName, + required List brackets, + String? gcashNumber, + required bool isA5, + }) { + final titleSize = isA5 ? 18.0 : 24.0; + final subtitleSize = isA5 ? 12.0 : 14.0; + final rowFontSize = isA5 ? 11.0 : 13.0; + + return pw.Container( + padding: pw.EdgeInsets.all(isA5 ? 12 : 20), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.blue900, width: 3), + borderRadius: pw.BorderRadius.circular(12), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + // Header Banner + pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: const pw.BoxDecoration( + color: PdfColors.blue800, + borderRadius: pw.BorderRadius.all(pw.Radius.circular(8)), + ), + child: pw.Column( + children: [ + pw.Text( + 'GCASH CASH-IN & CASH-OUT', + style: pw.TextStyle( + fontSize: titleSize, + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + textAlign: pw.TextAlign.center, + ), + pw.SizedBox(height: 4), + pw.Text( + storeName.toUpperCase(), + style: pw.TextStyle( + fontSize: subtitleSize, + fontWeight: pw.FontWeight.bold, + color: PdfColors.amber300, + ), + textAlign: pw.TextAlign.center, + ), + if (gcashNumber != null && gcashNumber.isNotEmpty) ...[ + pw.SizedBox(height: 4), + pw.Text( + 'GCash No: $gcashNumber', + style: pw.TextStyle( + fontSize: subtitleSize, + color: PdfColors.white, + ), + ), + ], + ], + ), + ), + pw.SizedBox(height: 16), + + // Rates Table + pw.TableHelper.fromTextArray( + headers: ['TRANSACTION AMOUNT (HALAGA)', 'SERVICE FEE (PATONG)'], + headerStyle: pw.TextStyle( + fontSize: rowFontSize, + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + headerDecoration: const pw.BoxDecoration(color: PdfColors.blue700), + cellAlignment: pw.Alignment.centerLeft, + cellAlignments: { + 0: pw.Alignment.centerLeft, + 1: pw.Alignment.centerRight, + }, + cellStyle: pw.TextStyle(fontSize: rowFontSize), + data: brackets.map((b) { + final rangeStr = 'P ${b.minAmount.toStringAsFixed(0)} - P ${b.maxAmount.toStringAsFixed(0)}'; + final feeStr = b.percentageFee > 0 + ? '${b.percentageFee.toStringAsFixed(1)}%' + : 'P ${b.fixedFee.toStringAsFixed(2)}'; + return [rangeStr, feeStr]; + }).toList(), + border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), + headerPadding: const pw.EdgeInsets.symmetric(vertical: 8, horizontal: 10), + cellPadding: const pw.EdgeInsets.symmetric(vertical: 8, horizontal: 10), + ), + pw.SizedBox(height: 16), + + // Security & Instructions Footer + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + color: PdfColors.grey100, + border: pw.Border.all(color: PdfColors.grey300), + borderRadius: pw.BorderRadius.circular(6), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'PAALALA SA MGA SUKI:', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: isA5 ? 9.0 : 10.0, + color: PdfColors.red800, + ), + ), + pw.SizedBox(height: 2), + pw.Text( + '* I-double check ang numero bago mag-confirm ng send.', + style: pw.TextStyle(fontSize: isA5 ? 8.5 : 9.5), + ), + pw.Text( + '* Ipakita ang Reference Number o SMS resibo sa tindera bago kunin ang pera.', + style: pw.TextStyle(fontSize: isA5 ? 8.5 : 9.5), + ), + pw.Text( + '* Bawal ang reused screenshot. Automatic na nade-detect ang duplicate reference numbers.', + style: pw.TextStyle(fontSize: isA5 ? 8.5 : 9.5), + ), + ], + ), + ), + pw.Spacer(), + + // Powered by TindaGrid footer + pw.Center( + child: pw.Text( + 'Generated with TindaGrid - Offline-First Sari-Sari OS', + style: pw.TextStyle( + fontSize: 8, + color: PdfColors.grey600, + fontStyle: pw.FontStyle.italic, + ), + ), + ), + ], + ), + ); + } + + /// POS Thermal Receipt layout (58mm / 80mm) + static pw.Widget _buildThermalLayout({ + required String storeName, + required List brackets, + String? gcashNumber, + required bool is58mm, + }) { + final baseFont = is58mm ? 8.0 : 10.0; + final headerFont = is58mm ? 10.0 : 12.0; + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.stretch, + children: [ + pw.Center( + child: pw.Text( + storeName.toUpperCase(), + style: pw.TextStyle(fontSize: headerFont, fontWeight: pw.FontWeight.bold), + ), + ), + pw.Center( + child: pw.Text( + 'GCASH RATES', + style: pw.TextStyle(fontSize: baseFont, fontWeight: pw.FontWeight.bold), + ), + ), + if (gcashNumber != null && gcashNumber.isNotEmpty) + pw.Center( + child: pw.Text( + 'No: $gcashNumber', + style: pw.TextStyle(fontSize: baseFont), + ), + ), + pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed), + ...brackets.map((b) { + final rangeStr = '${b.minAmount.toStringAsFixed(0)}-${b.maxAmount.toStringAsFixed(0)}'; + final feeStr = b.percentageFee > 0 + ? '${b.percentageFee.toStringAsFixed(1)}%' + : 'P${b.fixedFee.toStringAsFixed(0)}'; + + return pw.Padding( + padding: const pw.EdgeInsets.symmetric(vertical: 2), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text(rangeStr, style: pw.TextStyle(fontSize: baseFont)), + pw.Text( + feeStr, + style: pw.TextStyle(fontSize: baseFont, fontWeight: pw.FontWeight.bold), + ), + ], + ), + ); + }), + pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed), + pw.Center( + child: pw.Text( + 'Salamat sa Tiwala!', + style: pw.TextStyle(fontSize: baseFont, fontStyle: pw.FontStyle.italic), + ), + ), + ], + ); + } +} diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 998083d..d26d8b7 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "13.0.0" + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" args: dependency: transitive description: @@ -89,6 +97,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.4.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -440,6 +464,22 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.1" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "621e94de22fd0f1e902e593b892746d80ab7542b39c2552aa091d904eb82fc6f" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: e13685af5093c8be009a3555909b72a80e10cfe8f97752bcbdf99fa0ccbda810 + url: "https://pub.dev" + source: hosted + version: "0.17.1" graphs: dependency: transitive description: @@ -480,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" intl: dependency: transitive description: @@ -696,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -744,6 +800,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + name: pdf + sha256: "6e40827f370d196e97c6611724d923a3d86c431ce8e2ce1e59edd03a67a1a6e6" + url: "https://pub.dev" + source: hosted + version: "3.13.1" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" petitparser: dependency: transitive description: @@ -776,6 +848,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + printing: + dependency: "direct main" + description: + name: printing + sha256: "0f72f29f2e08b8453b7a45b6c7781eb792b1da3468bf0910be0eb3b1d6781f49" + url: "https://pub.dev" + source: hosted + version: "5.15.1" pub_semver: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 19d9501..1838659 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -51,6 +51,9 @@ dependencies: vibration: ^3.2.1 audioplayers: ^6.8.1 qr_flutter: ^4.1.0 + pdf: ^3.13.1 + printing: ^5.15.1 + google_mlkit_text_recognition: ^0.17.1 dev_dependencies: flutter_test: diff --git a/frontend/test/gcash_phase4_test.dart b/frontend/test/gcash_phase4_test.dart new file mode 100644 index 0000000..0ca88b3 --- /dev/null +++ b/frontend/test/gcash_phase4_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:tindagrid/core/database/app_database.dart'; +import 'package:tindagrid/features/gcash/gcash_receipt_ocr_service.dart'; +import 'package:tindagrid/features/gcash/rate_card_pdf_service.dart'; + +void main() { + late AppDatabase db; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + group('Phase 4: GCash Float Ledger, Fee Scheduler & Rate Card PDF Tests', () { + test('4.1 Dual-Float Tracking Engine calculates real-time balances and fee profit', () async { + const baseCash = 5000.0; + const baseGcash = 10000.0; + + // 1. Initial State + var stream = db.watchDualFloatBalance(baseCash: baseCash, baseGcash: baseGcash); + var initial = await stream.first; + expect(initial.gcashFloat, equals(10000.0)); + expect(initial.cashDrawer, equals(5000.0)); + expect(initial.totalFeesEarned, equals(0.0)); + + // 2. Perform Cash-In: Customer deposits ₱500, pays ₱10 fee + // Cash Drawer gets ₱510. GCash float loses ₱500. + await db.recordGcash( + transactionType: 'CASH_IN', + amount: 500.0, + feeCharged: 10.0, + referenceNumber: '1002481029412', + customerName: 'Maria Santos', + customerPhone: '09171234567', + ); + + var afterCashIn = await stream.first; + expect(afterCashIn.gcashFloat, equals(9500.0)); + expect(afterCashIn.cashDrawer, equals(5510.0)); + expect(afterCashIn.totalFeesEarned, equals(10.0)); + expect(afterCashIn.cashInCount, equals(1)); + + // 3. Perform Cash-Out: Customer withdraws ₱1,000, pays ₱15 fee + // GCash float receives ₱1,000. Cash Drawer pays out net ₱985 (₱1000 - ₱15 fee) + await db.recordGcash( + transactionType: 'CASH_OUT', + amount: 1000.0, + feeCharged: 15.0, + referenceNumber: '0021984210984', + customerName: 'Juan Dela Cruz', + customerPhone: '09189876543', + ); + + var afterCashOut = await stream.first; + expect(afterCashOut.gcashFloat, equals(10500.0)); + expect(afterCashOut.cashDrawer, equals(4525.0)); + expect(afterCashOut.totalFeesEarned, equals(25.0)); + expect(afterCashOut.cashOutCount, equals(1)); + + // 4. Store Payment: Customer pays ₱200 grocery bill via GCash QR + // GCash float increases by ₱200. Cash drawer untouched. + await db.recordGcash( + transactionType: 'STORE_PAYMENT', + amount: 200.0, + feeCharged: 0.0, + referenceNumber: '9921048123901', + ); + + var afterPayment = await stream.first; + expect(afterPayment.gcashFloat, equals(10700.0)); + expect(afterPayment.cashDrawer, equals(4525.0)); + expect(afterPayment.storePaymentCount, equals(1)); + }); + + test('4.1 Anti-Fraud: Reference number duplicate detection catches recycled receipts', () async { + await db.recordGcash( + transactionType: 'CASH_IN', + amount: 300.0, + feeCharged: 10.0, + referenceNumber: '1002 9481 02941', + ); + + // Check with exact string + expect(await db.isReferenceNumberUsed('1002 9481 02941'), isTrue); + + // Check without spaces (sanitized comparison) + expect(await db.isReferenceNumberUsed('1002948102941'), isTrue); + + // Check non-existing reference number + expect(await db.isReferenceNumberUsed('9999999999999'), isFalse); + }); + + test('4.2 Customizable Fee Brackets seeds, saves, and calculates tiered rates', () async { + // 1. Seed defaults + await db.seedDefaultFeeBrackets(); + final brackets = await db.getFeeBrackets(); + expect(brackets.length, greaterThanOrEqualTo(6)); + + // Verify default fee for ₱500 is ₱10.00 + final fee500 = await db.calculateGcashFee(500.0); + expect(fee500, equals(10.0)); + + // Verify default fee for ₱800 is ₱15.00 + final fee800 = await db.calculateGcashFee(800.0); + expect(fee800, equals(15.0)); + + // 2. Add custom bracket: ₱10,001 - ₱20,000 at 2.0% + await db.saveFeeBracket( + const GcashFeeConfigCompanion( + id: drift.Value('bracket-tier-high'), + minAmount: drift.Value(10001.0), + maxAmount: drift.Value(20000.0), + fixedFee: drift.Value(0.0), + percentageFee: drift.Value(2.0), + ), + ); + + final fee15000 = await db.calculateGcashFee(15000.0); + expect(fee15000, equals(300.0)); // 2% of 15,000 + }); + + test('4.3 Printable Rate Card PDF generates valid bytes for A4, A5, and Thermal formats', () async { + await db.seedDefaultFeeBrackets(); + final brackets = await db.getFeeBrackets(); + + // Test A4 Sheet PDF + final a4Bytes = await RateCardPdfService.generateRateCardPdf( + storeName: 'Aling Nena Tindahan', + brackets: brackets, + format: RateCardFormat.a4Sheet, + gcashNumber: '0917-123-4567', + ); + expect(a4Bytes, isNotEmpty); + expect(a4Bytes.length, greaterThan(1000)); + + // Test A5 Half Sheet PDF + final a5Bytes = await RateCardPdfService.generateRateCardPdf( + storeName: 'Aling Nena Tindahan', + brackets: brackets, + format: RateCardFormat.a5HalfSheet, + ); + expect(a5Bytes, isNotEmpty); + + // Test 80mm POS Thermal Receipt PDF + final thermalBytes = await RateCardPdfService.generateRateCardPdf( + storeName: 'Aling Nena Tindahan', + brackets: brackets, + format: RateCardFormat.posThermal80mm, + ); + expect(thermalBytes, isNotEmpty); + }); + + test('4.4 On-Device Receipt OCR parses 13-digit Reference and Amount from raw text', () { + const smsText = + 'You have received PHP 1,500.00 of GCash from Juan Dela Cruz (09181234567) on 10/20/2026. Ref. No. 1002 481 029412.'; + + final result = GcashReceiptOcrService.parseRawText(smsText); + expect(result.isSuccess, isTrue); + expect(result.referenceNumber, equals('1002481029412')); + expect(result.amount, equals(1500.0)); + + // Test solid reference format + const solidRefText = 'Transaction Successful! Amount: P 250.00 Ref No: 0021482910245'; + final solidResult = GcashReceiptOcrService.parseRawText(solidRefText); + expect(solidResult.isSuccess, isTrue); + expect(solidResult.referenceNumber, equals('0021482910245')); + expect(solidResult.amount, equals(250.0)); + }); + }); +}