diff --git a/README.md b/README.md index 775f73d..5d0f1df 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ We have exciting features planned for the future: - [ ] Ask AI: "Where am I spending the most?" - [ ] Multi-Expense Entry: Add multiple expenses in one go. - [ ] Dynamic Stats UI: AI-powered insights. -- [ ] **UPI Scanner**: Scan QR, select app, and auto-log expense. +- [X] **UPI Scanner**: Scan QR, select app, and auto-log expense. - [ ] **Custom Themes**: Create and share your own chat themes. - [ ] **Darker Logo**: A sleek new look. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 73451d2..077c161 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2051b0d..bdba2e3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,10 +45,23 @@ UIApplicationSupportsIndirectInputEvents + NSCameraUsageDescription + Camera access is needed to scan UPI QR codes for payments. LSApplicationQueriesSchemes https mailto + + upi + tez + gpay + phonepe + paytmmp + paytm + bhim + credpay + ybl + imobileapp diff --git a/lib/data/utils/upi_uri.dart b/lib/data/utils/upi_uri.dart new file mode 100644 index 0000000..4330b03 --- /dev/null +++ b/lib/data/utils/upi_uri.dart @@ -0,0 +1,54 @@ +/// Lightweight parser for UPI deep-link QR codes. +/// +/// A UPI QR encodes a string like: +/// upi://pay?pa=merchant@bank&pn=Merchant%20Name&am=100.00&cu=INR&tn=Order +/// +/// We only need the payee address (`pa`), an optional display name (`pn`) +/// and an optional pre-filled amount (`am`). Everything else is ignored — we +/// rebuild a fresh intent at pay time with the user-entered amount. +class UpiQrData { + /// Payee VPA / UPI ID (the `pa` parameter). Always present. + final String vpa; + + /// Payee display name (the `pn` parameter), if the QR provided one. + final String? name; + + /// Pre-filled amount (the `am` parameter), if the QR provided one. + final double? amount; + + const UpiQrData({required this.vpa, this.name, this.amount}); + + /// Parse a scanned string into [UpiQrData]. + /// + /// Returns `null` when the string is not a usable UPI QR — i.e. it isn't a + /// `upi://` link or it lacks a payee address. Callers should treat `null` + /// as "not a UPI QR" and let the user rescan. + static UpiQrData? tryParse(String? raw) { + if (raw == null) return null; + final trimmed = raw.trim(); + if (trimmed.isEmpty) return null; + + final uri = Uri.tryParse(trimmed); + if (uri == null) return null; + if (uri.scheme.toLowerCase() != 'upi') return null; + + final params = uri.queryParameters; + final pa = params['pa']?.trim(); + if (pa == null || pa.isEmpty) return null; + + final pn = params['pn']?.trim(); + + double? amount; + final am = params['am']?.trim(); + if (am != null && am.isNotEmpty) { + final parsed = double.tryParse(am); + if (parsed != null && parsed > 0) amount = parsed; + } + + return UpiQrData( + vpa: pa, + name: (pn != null && pn.isNotEmpty) ? pn : null, + amount: amount, + ); + } +} diff --git a/lib/screens/home/components/home_app_bar.dart b/lib/screens/home/components/home_app_bar.dart index a0a2dda..635c91c 100644 --- a/lib/screens/home/components/home_app_bar.dart +++ b/lib/screens/home/components/home_app_bar.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../../../data/bloc/app_bloc.dart'; import '../../settings/settings_screen.dart'; import '../../transactions/transactions_screen.dart'; +import '../../upi/upi_scan_screen.dart'; import '../../../theme.dart'; @@ -66,6 +67,16 @@ class HomeAppBar extends StatelessWidget implements PreferredSizeWidget { ), ), actions: [ + // Scan UPI QR & Pay Button + IconButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const UpiScanScreen()), + ), + icon: const Icon(Icons.qr_code_scanner_rounded), + color: AppTheme.primaryNavy, + ), + // Transaction Button IconButton( onPressed: () => Navigator.push( diff --git a/lib/screens/upi/components/category_chip.dart b/lib/screens/upi/components/category_chip.dart new file mode 100644 index 0000000..3185af4 --- /dev/null +++ b/lib/screens/upi/components/category_chip.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import '../../../theme.dart'; + +/// A selectable, pill-shaped category chip shared by the payment screen and the +/// category picker sheet. +class CategoryChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const CategoryChip({ + super.key, + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => onTap(), + showCheckmark: false, + backgroundColor: AppTheme.inputFill, + selectedColor: AppTheme.primaryNavy, + labelStyle: TextStyle( + color: selected ? AppTheme.textWhite : AppTheme.tagText, + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide.none, + ), + ); + } +} diff --git a/lib/screens/upi/components/category_picker_sheet.dart b/lib/screens/upi/components/category_picker_sheet.dart new file mode 100644 index 0000000..200aafc --- /dev/null +++ b/lib/screens/upi/components/category_picker_sheet.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../data/command/commands.dart'; +import '../../../data/data/expense/expense.dart'; +import '../../../theme.dart'; +import 'category_chip.dart'; + +/// Bottom sheet for picking an expense category. +/// +/// Shows a search field, a "Most used" row (categories the user actually uses, +/// ordered by frequency) and the full alphabetical list. Returns the chosen +/// category string via [Navigator.pop]. +class CategoryPickerSheet extends StatefulWidget { + final String? selected; + const CategoryPickerSheet({super.key, this.selected}); + + /// Opens the sheet and resolves to the chosen category, or `null` if dismissed. + static Future show(BuildContext context, {String? selected}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.cardBackground, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (_) => CategoryPickerSheet(selected: selected), + ); + } + + @override + State createState() => _CategoryPickerSheetState(); +} + +class _CategoryPickerSheetState extends State { + final TextEditingController _search = TextEditingController(); + + // Sorted by usage (most-used first), then defaults alphabetically. + late final List _all = + BaseAppCommand.blocExpense.getSuggestionsForType( + TransactionType.outgoing, + ); + late final Set _used = + BaseAppCommand.blocExpense.usedCategories.toSet(); + + String _query = ''; + + @override + void initState() { + super.initState(); + _search.addListener( + () => setState(() => _query = _search.text.trim().toLowerCase()), + ); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + List get _mostUsed => + _all.where(_used.contains).take(8).toList(growable: false); + + List get _filtered => _query.isEmpty + ? _all + : _all.where((c) => c.contains(_query)).toList(growable: false); + + void _pick(String category) => Navigator.of(context).pop(category); + + @override + Widget build(BuildContext context) { + final insets = MediaQuery.viewInsetsOf(context).bottom; + final mostUsed = _mostUsed; + final filtered = _filtered; + + return Padding( + padding: EdgeInsets.only(bottom: insets), + child: DraggableScrollableSheet( + expand: false, + initialChildSize: 0.7, + maxChildSize: 0.92, + minChildSize: 0.5, + builder: (context, scrollController) { + return Column( + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Text( + 'Select category', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.primaryNavy, + ), + ), + ], + ), + ), + + // Search. + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: TextField( + controller: _search, + autofocus: false, + decoration: const InputDecoration( + hintText: 'Search categories', + prefixIcon: Icon(Icons.search_rounded), + ), + ), + ), + + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), + children: [ + if (_query.isEmpty && mostUsed.isNotEmpty) ...[ + const _SectionLabel('Most used'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final c in mostUsed) + CategoryChip( + label: c, + selected: c == widget.selected, + onTap: () => _pick(c), + ), + ], + ), + const SizedBox(height: 20), + const _SectionLabel('All categories'), + const SizedBox(height: 8), + ], + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final c in filtered) + CategoryChip( + label: c, + selected: c == widget.selected, + onTap: () => _pick(c), + ), + ], + ), + if (filtered.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 24), + child: Text( + 'No matching categories.', + style: TextStyle(color: AppTheme.textSecondary), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } + +} + +/// Small muted section heading used inside the category sheet. +class _SectionLabel extends StatelessWidget { + final String text; + const _SectionLabel(this.text); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: GoogleFonts.outfit( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, + ), + ); + } +} diff --git a/lib/screens/upi/components/payment_confirm_dialog.dart b/lib/screens/upi/components/payment_confirm_dialog.dart new file mode 100644 index 0000000..abcd4ca --- /dev/null +++ b/lib/screens/upi/components/payment_confirm_dialog.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../theme.dart'; + +/// Fallback confirmation shown when the UPI app does not report a clear success +/// status (common with Google Pay, and always the case on iOS, which can't +/// return transaction data). Lets the user confirm whether the payment actually +/// went through so genuine payments aren't silently dropped. +/// +/// Resolves to `true` if the user confirms they paid, `false`/`null` otherwise. +class PaymentConfirmDialog extends StatelessWidget { + final double amount; + + const PaymentConfirmDialog({super.key, required this.amount}); + + static Future show(BuildContext context, {required double amount}) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PaymentConfirmDialog(amount: amount), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: AppTheme.cardBackground, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppTheme.accentPurple.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.help_outline_rounded, + color: AppTheme.accentPurple, + size: 32, + ), + ), + const SizedBox(height: 20), + Text( + 'Did the payment go through?', + textAlign: TextAlign.center, + style: GoogleFonts.outfit( + fontSize: 19, + fontWeight: FontWeight.bold, + color: AppTheme.primaryNavy, + ), + ), + const SizedBox(height: 8), + Text( + "We couldn't automatically confirm your ₹${amount.toStringAsFixed(2)} " + 'payment. Add it to your expenses only if it was completed.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + height: 1.4, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: AppTheme.inputFill, + foregroundColor: AppTheme.textSecondary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + "No, didn't pay", + style: TextStyle(fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text('Yes, paid'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/upi/components/payment_widgets.dart b/lib/screens/upi/components/payment_widgets.dart new file mode 100644 index 0000000..b401b21 --- /dev/null +++ b/lib/screens/upi/components/payment_widgets.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../theme.dart'; +import 'category_chip.dart'; + +/// Payee identity block: avatar (initial) + name + UPI id. Display only — the +/// payee is never persisted. +class PayeeHeader extends StatelessWidget { + final String name; + final String vpa; + + const PayeeHeader({super.key, required this.name, required this.vpa}); + + @override + Widget build(BuildContext context) { + final initial = name.trim().isNotEmpty ? name.trim()[0].toUpperCase() : '?'; + return Column( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: AppTheme.primaryNavy, + child: Text( + initial, + style: const TextStyle( + color: AppTheme.textWhite, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 12), + Text( + name, + textAlign: TextAlign.center, + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppTheme.primaryNavy, + ), + ), + const SizedBox(height: 2), + Text( + vpa, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary), + ), + ], + ); + } +} + +/// The large, centered amount input — the focal point of the screen. +class AmountField extends StatelessWidget { + final TextEditingController controller; + final bool autofocus; + + const AmountField({ + super.key, + required this.controller, + required this.autofocus, + }); + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: TextField( + controller: controller, + autofocus: autofocus, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ], + style: GoogleFonts.outfit( + fontSize: 48, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + decoration: InputDecoration( + isCollapsed: true, + filled: false, + prefixText: '₹ ', + prefixStyle: GoogleFonts.outfit( + fontSize: 36, + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + hintText: '0', + hintStyle: GoogleFonts.outfit( + fontSize: 48, + fontWeight: FontWeight.bold, + color: AppTheme.textSecondary.withValues(alpha: 0.4), + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ); + } +} + +/// Compact, chip-style optional note field. +class NoteField extends StatelessWidget { + final TextEditingController controller; + + const NoteField({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 260), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.inputFill, + borderRadius: BorderRadius.circular(20), + ), + child: TextField( + controller: controller, + textAlign: TextAlign.center, + textCapitalization: TextCapitalization.sentences, + style: const TextStyle(fontSize: 14, color: AppTheme.textPrimary), + decoration: const InputDecoration( + isCollapsed: true, + filled: false, + hintText: 'Add a note', + hintStyle: TextStyle(color: AppTheme.textSecondary), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ); + } +} + +/// Category label + quick-pick chips + a "More" chip that opens the full picker. +class CategorySection extends StatelessWidget { + final String? selected; + final List quickPicks; + final ValueChanged onSelected; + final VoidCallback onMore; + + const CategorySection({ + super.key, + required this.selected, + required this.quickPicks, + required this.onSelected, + required this.onMore, + }); + + @override + Widget build(BuildContext context) { + // Show the selected category as a chip even if it isn't a quick pick. + final picks = [ + if (selected != null && !quickPicks.contains(selected)) selected!, + ...quickPicks, + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Category', + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final c in picks) + CategoryChip( + label: c, + selected: c == selected, + onTap: () => onSelected(c), + ), + ActionChip( + avatar: const Icon(Icons.tune_rounded, + size: 18, color: AppTheme.primaryNavy), + label: const Text('More'), + onPressed: onMore, + backgroundColor: AppTheme.cardBackground, + labelStyle: const TextStyle( + color: AppTheme.primaryNavy, + fontWeight: FontWeight.w600, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: AppTheme.dividerColor), + ), + ), + ], + ), + ], + ); + } +} + +/// Bottom action bar holding the primary "Select app to pay" button. +class PayBar extends StatelessWidget { + final VoidCallback onPressed; + + const PayBar({super.key, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: const BoxDecoration( + color: AppTheme.cardBackground, + boxShadow: [ + BoxShadow( + color: Color(0x0F000000), blurRadius: 16, offset: Offset(0, -4)), + ], + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.account_balance_wallet_rounded), + label: const Text('Select app to pay'), + ), + ), + ); + } +} diff --git a/lib/screens/upi/upi_payment_screen.dart b/lib/screens/upi/upi_payment_screen.dart new file mode 100644 index 0000000..f4525c6 --- /dev/null +++ b/lib/screens/upi/upi_payment_screen.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:upi_intent/upi_intent.dart'; +import 'package:uuid/uuid.dart'; + +import '../../data/command/commands.dart'; +import '../../data/command/expense/expense_command.dart'; +import '../../data/data/expense/expense.dart'; +import '../../data/utils/upi_uri.dart'; +import '../../theme.dart'; +import 'components/category_picker_sheet.dart'; +import 'components/payment_confirm_dialog.dart'; +import 'components/payment_widgets.dart'; + +/// Collects the amount, category and an optional note for a scanned UPI QR, +/// then launches a UPI app to pay with (via the vendored `upi_intent` picker). +/// +/// The expense is saved ONLY when the payment app reports a SUCCESS status. +/// We persist only amount + category (plus the existing note/date/type) — +/// never payee details. +/// +/// NOTE: UPI status is unreliable by design — Google Pay frequently returns no +/// status even on success, and iOS cannot return transaction data at all. With +/// strict success-only saving, those genuine payments will not be recorded. +class UpiPaymentScreen extends StatefulWidget { + final UpiQrData qr; + const UpiPaymentScreen({super.key, required this.qr}); + + @override + State createState() => _UpiPaymentScreenState(); +} + +class _UpiPaymentScreenState extends State { + static const _uuid = Uuid(); + + late final TextEditingController _amountController = TextEditingController( + text: widget.qr.amount?.toStringAsFixed(2) ?? '', + ); + final TextEditingController _noteController = TextEditingController(); + + String? _category; + + /// Quick-pick chips: the few most relevant outgoing categories. + late final List _quickPicks = BaseAppCommand.blocExpense + .getSuggestionsForType(TransactionType.outgoing) + .take(4) + .toList(growable: false); + + @override + void dispose() { + _amountController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + void _error(String message) { + HapticFeedback.heavyImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: AppTheme.dangerRed, + behavior: SnackBarBehavior.floating, + ), + ); + } + + /// Builds + persists the expense. Called only after a successful payment. + Future _saveExpense(double amount, String category, String note) { + final expense = ExpenseData( + id: _uuid.v4(), + amount: amount, + category: category.toLowerCase(), + date: DateTime.now(), + type: TransactionType.outgoing, + note: note, + ); + return ExpenseCommand().addExpense(expense); + } + + Future _openCategoryPicker() async { + final picked = await CategoryPickerSheet.show(context, selected: _category); + if (picked != null && mounted) setState(() => _category = picked); + } + + Future _selectApp() async { + FocusScope.of(context).unfocus(); + + final amount = double.tryParse(_amountController.text.trim()); + if (amount == null || amount <= 0) { + _error('Please enter a valid amount.'); + return; + } + if (_category == null) { + _error('Please pick a category.'); + return; + } + + final note = _noteController.text.trim(); + final messenger = ScaffoldMessenger.of(context); + + UpiResponse? response; + try { + // Shows the (themed) built-in app picker, launches the chosen UPI app, + // and resolves with the transaction status once control returns. + response = await UpiIntent.pay( + context: context, + payment: UpiPayment( + payeeVpa: widget.qr.vpa, + payeeName: widget.qr.name ?? widget.qr.vpa, + amount: amount, + transactionNote: note.isNotEmpty ? note : _category!, + transactionRefId: 'TXN${_uuid.v4().replaceAll('-', '')}', + ), + ); + } on UpiException catch (e) { + _error(e.message); + return; + } + + // Null → user dismissed the picker without launching an app. Don't save. + if (response == null) return; + + // On a confirmed success we save straight away. Otherwise the status is + // unreliable (GPay often reports nothing on success; iOS never does), so + // we ask the user to confirm rather than silently dropping a real payment. + if (!response.isSuccess) { + if (!mounted) return; + final confirmed = + await PaymentConfirmDialog.show(context, amount: amount); + if (confirmed != true) return; + } + + try { + await _saveExpense(amount, _category!, note); + } catch (e) { + if (mounted) _error('Saving failed: $e'); + return; + } + + if (!mounted) return; + Navigator.of(context).pop(); + messenger.showSnackBar( + const SnackBar( + content: Text('Expense saved'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.scaffoldBackground, + appBar: AppBar(title: const Text('Payment')), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Column( + children: [ + PayeeHeader( + name: widget.qr.name ?? 'UPI Payment', + vpa: widget.qr.vpa, + ), + const SizedBox(height: 40), + AmountField( + controller: _amountController, + autofocus: widget.qr.amount == null, + ), + const SizedBox(height: 16), + NoteField(controller: _noteController), + const SizedBox(height: 40), + CategorySection( + selected: _category, + quickPicks: _quickPicks, + onSelected: (c) => setState(() => _category = c), + onMore: _openCategoryPicker, + ), + ], + ), + ), + ), + PayBar(onPressed: _selectApp), + ], + ), + ), + ); + } +} diff --git a/lib/screens/upi/upi_scan_screen.dart b/lib/screens/upi/upi_scan_screen.dart new file mode 100644 index 0000000..6ee50e6 --- /dev/null +++ b/lib/screens/upi/upi_scan_screen.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../data/utils/upi_uri.dart'; +import '../../theme.dart'; +import 'upi_payment_screen.dart'; + +/// Full-screen camera view that scans a UPI QR code. +/// +/// On the first valid UPI QR detected, it parses the payee details and pushes +/// [UpiPaymentScreen]. Non-UPI / malformed QRs surface an inline hint and the +/// scanner keeps running so the user can try again. +class UpiScanScreen extends StatefulWidget { + const UpiScanScreen({super.key}); + + @override + State createState() => _UpiScanScreenState(); +} + +class _UpiScanScreenState extends State { + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.noDuplicates, + ); + + /// Guards against handling more than one detection while we navigate away. + bool _handled = false; + String? _hint; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _onDetect(BarcodeCapture capture) async { + if (_handled) return; + + final raw = capture.barcodes + .map((b) => b.rawValue) + .firstWhere((v) => v != null && v.isNotEmpty, orElse: () => null); + + final data = UpiQrData.tryParse(raw); + if (data == null) { + // Not a UPI QR — nudge the user and keep scanning. + if (mounted && _hint == null) { + setState(() => _hint = "That doesn't look like a UPI QR. Try another."); + } + return; + } + + _handled = true; + HapticFeedback.mediumImpact(); + await _controller.stop(); + + if (!mounted) return; + await Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => UpiPaymentScreen(qr: data)), + ); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final cutout = size.width * 0.7; + final scanRect = Rect.fromCenter( + center: Offset(size.width / 2, size.height * 0.42), + width: cutout, + height: cutout, + ); + + return Scaffold( + backgroundColor: Colors.black, + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + leading: IconButton( + tooltip: 'Close', + icon: const Icon(Icons.close_rounded, color: Colors.white), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: const Text('Scan & Pay'), + actions: [ + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + final on = state.torchState == TorchState.on; + final available = state.torchState != TorchState.unavailable; + return IconButton( + tooltip: 'Flash', + icon: Icon(on ? Icons.flash_on_rounded : Icons.flash_off_rounded), + color: on ? AppTheme.primaryGreen : Colors.white, + onPressed: available ? () => _controller.toggleTorch() : null, + ); + }, + ), + const SizedBox(width: 4), + ], + ), + body: Stack( + fit: StackFit.expand, + children: [ + // Full-bleed camera preview. NOTE: no `scanWindow` — restricting + // detection to the cutout rect breaks scanning on some devices + // (coordinate-space mismatch with BoxFit.cover). The cutout below is + // purely a visual guide; detection runs on the full frame. + MobileScanner( + controller: _controller, + onDetect: _onDetect, + fit: BoxFit.cover, + errorBuilder: (context, error) => _CameraError(error: error), + ), + + // Dimmed overlay with a clear, rounded cutout in the center. + IgnorePointer( + child: CustomPaint( + size: Size.infinite, + painter: _ScannerOverlayPainter( + cutout: scanRect, + radius: 24, + borderColor: _hint != null ? AppTheme.dangerRed : Colors.white, + ), + ), + ), + + // Hint / instruction text below the cutout. + Positioned( + top: scanRect.bottom + 28, + left: 32, + right: 32, + child: Text( + _hint ?? 'Align the UPI QR code within the frame', + textAlign: TextAlign.center, + style: TextStyle( + color: _hint != null ? AppTheme.dangerRed : Colors.white, + fontSize: 15, + fontWeight: FontWeight.w500, + shadows: const [Shadow(color: Colors.black87, blurRadius: 8)], + ), + ), + ), + ], + ), + ); + } +} + +/// Paints a translucent scrim over the whole screen, punches out a rounded +/// square for the scan window, and draws white corner brackets around it. +class _ScannerOverlayPainter extends CustomPainter { + final Rect cutout; + final double radius; + final Color borderColor; + + _ScannerOverlayPainter({ + required this.cutout, + required this.radius, + required this.borderColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final rrect = RRect.fromRectAndRadius(cutout, Radius.circular(radius)); + + // Scrim everywhere except the cutout. + final scrim = Path() + ..addRect(Offset.zero & size) + ..addRRect(rrect) + ..fillType = PathFillType.evenOdd; + canvas.drawPath(scrim, Paint()..color = Colors.black.withValues(alpha: 0.55)); + + // Thin frame border. + canvas.drawRRect( + rrect, + Paint() + ..color = borderColor.withValues(alpha: 0.6) + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + + // Corner brackets. + final bracket = Paint() + ..color = borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round; + const len = 28.0; + final r = radius; + + void corner(Offset c, double dx, double dy) { + final path = Path() + ..moveTo(c.dx, c.dy + dy * (len)) + ..lineTo(c.dx, c.dy + dy * r) + ..arcToPoint( + Offset(c.dx + dx * r, c.dy), + radius: Radius.circular(r), + clockwise: dx == dy, + ) + ..lineTo(c.dx + dx * len, c.dy); + canvas.drawPath(path, bracket); + } + + corner(cutout.topLeft, 1, 1); + corner(cutout.topRight, -1, 1); + corner(cutout.bottomLeft, 1, -1); + corner(cutout.bottomRight, -1, -1); + } + + @override + bool shouldRepaint(_ScannerOverlayPainter old) => + old.cutout != cutout || old.borderColor != borderColor; +} + +/// Friendly fallback shown when the camera can't start (e.g. permission denied). +class _CameraError extends StatelessWidget { + final MobileScannerException error; + const _CameraError({required this.error}); + + @override + Widget build(BuildContext context) { + final denied = error.errorCode == MobileScannerErrorCode.permissionDenied; + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.no_photography_outlined, + color: Colors.white70, size: 48), + const SizedBox(height: 16), + Text( + denied + ? 'Camera permission is required to scan UPI QR codes. Enable it in Settings and try again.' + : 'Unable to start the camera. Please try again.', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 15), + ), + ], + ), + ); + } +} diff --git a/packages/upi_intent/CHANGELOG.md b/packages/upi_intent/CHANGELOG.md new file mode 100644 index 0000000..172ce9d --- /dev/null +++ b/packages/upi_intent/CHANGELOG.md @@ -0,0 +1,26 @@ +## 1.0.1+vendored + +Forked from upstream 1.0.1 and vendored into this repo (consumed via a path +dependency). Changes: + +* 🎨 Themed the built-in app picker to the host app via `colorScheme.primary` +* 🔧 Removed the deprecated manifest `package=` attribute (AGP 8 `namespace`) +* 🧹 Stripped `example/`, tests, screenshots and unused plugin boilerplate +* 🚫 `publish_to: "none"` — internal only + +## 1.0.1 + +* 🏷️ Added `topics` for better pub.dev search discoverability +* 🔗 Updated homepage URL + +## 1.0.0 + +* 🎉 Initial release +* ✨ Beautiful built-in UPI app picker bottom sheet +* 🔒 NPCI-compliant `upi://pay` URL construction +* ✅ VPA validator with regex-based validation +* 📱 Full Android + iOS support +* 🤖 Android 11+ ready with `` manifest support +* 🌙 Dark mode support in app picker +* 🧪 Type-safe models: `UpiPayment`, `UpiResponse`, `UpiApp` +* 📦 Zero bloated dependencies diff --git a/packages/upi_intent/LICENSE b/packages/upi_intent/LICENSE new file mode 100644 index 0000000..2bbde55 --- /dev/null +++ b/packages/upi_intent/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dodani Yash + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/upi_intent/README.md b/packages/upi_intent/README.md new file mode 100644 index 0000000..537af56 --- /dev/null +++ b/packages/upi_intent/README.md @@ -0,0 +1,91 @@ +# upi_intent (vendored) + +A **vendored and customized copy** of the [`upi_intent`](https://pub.dev/packages/upi_intent) +package (MIT). It lives inside this repo and is consumed via a path dependency +rather than from pub.dev. + +```yaml +# app pubspec.yaml +dependencies: + upi_intent: + path: packages/upi_intent +``` + +## Why it's vendored + +The app needs the UPI app picker to match its own visual style, and we want to +own the dependency rather than rely on an external package's release cadence. +Vendoring lets us: + +- **Theme the built-in picker** to the app's palette (see _Customizations_). +- **Pin and patch** the native code without waiting on upstream. +- Keep the payment-launch layer fully under our control. + +It replaced the discontinued `upi_pay`, whose stale hardcoded app list failed to +detect modern Google Pay / PhonePe builds. This package instead discovers apps +through the live Android intent resolver, so newly released UPI apps appear +automatically. + +## What it does + +- Detects installed UPI apps (Android: intent resolver; iOS: URL schemes). +- Shows a bottom-sheet app picker, launches the chosen app with an + NPCI-compliant `upi://pay?...` URL. +- On Android, reads back the transaction status via `onActivityResult`. + +> **Platform note:** UPI status is unreliable by design. Google Pay frequently +> returns no status even on a successful payment, and iOS cannot return +> transaction data at all. Treat `UpiTransactionStatus.success` as the only firm +> signal and confirm with the user otherwise — the app does this with a fallback +> dialog. + +## Usage + +```dart +import 'package:upi_intent/upi_intent.dart'; + +final UpiResponse? response = await UpiIntent.pay( + context: context, + payment: UpiPayment( + payeeVpa: 'merchant@upi', // required + payeeName: 'My Shop', // required + amount: 99.00, // optional (user fills if null) + transactionNote: 'Order #1234', + transactionRefId: 'TXN...', + ), +); + +if (response != null && response.isSuccess) { + // confirmed paid +} +``` + +Other entry points: `UpiIntent.getInstalledApps()`, `UpiIntent.payWithApp(...)`, +`UpiIntent.buildUpiUrl(...)`, and `UpiValidator`. + +### Required platform setup (done in the host app) + +- **Android** (`AndroidManifest.xml`): a `` block for the `upi` scheme. + This package also declares the queries in its own manifest, which merge in. +- **iOS** (`Info.plist`): `LSApplicationQueriesSchemes` listing UPI app schemes + (`gpay`, `phonepe`, `paytmmp`, `bhim`, `upi`, …). + +## Customizations vs. upstream 1.0.1 + +This is not a verbatim copy. Changes made for this repo: + +- **Themed picker** — `lib/src/widgets/upi_app_picker.dart` now uses + `Theme.of(context).colorScheme.primary` instead of a hardcoded blue, so it + adopts the host app's theme. (No dependency on app code — it only reads the + ambient `ThemeData`.) +- **Manifest fix** — removed the deprecated `package="..."` attribute from + `android/src/main/AndroidManifest.xml` (conflicts with the AGP 8 `namespace`). +- **Slimmed down** — removed the `example/`, unit tests, screenshots, and the + unused `flutter create plugin` boilerplate (`lib/upi_intent_method_channel.dart`, + `lib/upi_intent_platform_interface.dart`); the real implementation is in + `lib/src/platform/`. +- `publish_to: "none"` — this fork is internal and must not be published. + +## License + +MIT, inherited from the upstream `upi_intent` package. See [LICENSE](LICENSE). diff --git a/packages/upi_intent/analysis_options.yaml b/packages/upi_intent/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/packages/upi_intent/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/upi_intent/android/build.gradle b/packages/upi_intent/android/build.gradle new file mode 100644 index 0000000..41d7a1c --- /dev/null +++ b/packages/upi_intent/android/build.gradle @@ -0,0 +1,66 @@ +group = "com.upiintent.upi_intent" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.2.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.11.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.upiintent.upi_intent" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 24 + } + + dependencies { + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/packages/upi_intent/android/settings.gradle b/packages/upi_intent/android/settings.gradle new file mode 100644 index 0000000..dfb024d --- /dev/null +++ b/packages/upi_intent/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'upi_intent' diff --git a/packages/upi_intent/android/src/main/AndroidManifest.xml b/packages/upi_intent/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..42a07a8 --- /dev/null +++ b/packages/upi_intent/android/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/upi_intent/android/src/main/kotlin/com/upiintent/upi_intent/UpiIntentPlugin.kt b/packages/upi_intent/android/src/main/kotlin/com/upiintent/upi_intent/UpiIntentPlugin.kt new file mode 100644 index 0000000..601877c --- /dev/null +++ b/packages/upi_intent/android/src/main/kotlin/com/upiintent/upi_intent/UpiIntentPlugin.kt @@ -0,0 +1,166 @@ +package com.upiintent.upi_intent + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.net.Uri +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry +import java.io.ByteArrayOutputStream + +class UpiIntentPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, + PluginRegistry.ActivityResultListener { + + private lateinit var channel: MethodChannel + private lateinit var context: Context + private var activity: Activity? = null + private var pendingResult: Result? = null + + companion object { + private const val CHANNEL = "upi_intent" + private const val UPI_PAYMENT_REQUEST_CODE = 7896 + } + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL) + channel.setMethodCallHandler(this) + context = flutterPluginBinding.applicationContext + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + binding.addActivityResultListener(this) + } + + override fun onDetachedFromActivity() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + binding.addActivityResultListener(this) + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getInstalledUpiApps" -> handleGetInstalledApps(result) + "launchUpiApp" -> { + val upiUrl = call.argument("upiUrl") + val packageName = call.argument("packageName") + if (upiUrl == null) { + result.error("INVALID_ARGS", "upiUrl is required", null) + return + } + handleLaunchUpiApp(upiUrl, packageName, result) + } + else -> result.notImplemented() + } + } + + private fun handleGetInstalledApps(result: Result) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("upi://pay")) + val pm: PackageManager = context.packageManager + + @Suppress("DEPRECATION") + val resolvedApps = pm.queryIntentActivities(intent, 0) + + val appList = resolvedApps.map { resolveInfo -> + val icon = resolveInfo.loadIcon(pm) + val iconBytes = drawableToBytes(icon) + mapOf( + "name" to resolveInfo.loadLabel(pm).toString(), + "packageName" to resolveInfo.activityInfo.packageName, + "icon" to iconBytes + ) + } + result.success(appList) + } catch (e: Exception) { + result.error("GET_APPS_ERROR", e.message, null) + } + } + + private fun handleLaunchUpiApp(upiUrl: String, packageName: String?, result: Result) { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(upiUrl) + if (packageName != null) setPackage(packageName) + } + + if (activity == null) { + result.error("NO_ACTIVITY", "No activity available", null) + return + } + + pendingResult = result + activity!!.startActivityForResult(intent, UPI_PAYMENT_REQUEST_CODE) + } catch (e: Exception) { + result.error("LAUNCH_ERROR", "Failed to launch UPI app: ${e.message}", null) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode != UPI_PAYMENT_REQUEST_CODE) return false + + val result = pendingResult ?: return false + pendingResult = null + + try { + // Parse UPI response from intent data + val response = data?.let { intent -> + val extras = intent.extras + if (extras != null) { + val responseBuilder = StringBuilder() + val keys = listOf("Status", "txnId", "responseCode", "ApprovalRefNo") + keys.forEach { key -> + val value = extras.getString(key) + if (value != null) { + if (responseBuilder.isNotEmpty()) responseBuilder.append("&") + responseBuilder.append("$key=$value") + } + } + responseBuilder.toString().ifEmpty { null } + } else null + } + + result.success(response) + } catch (e: Exception) { + result.error("RESPONSE_ERROR", e.message, null) + } + return true + } + + private fun drawableToBytes(drawable: Drawable): ByteArray { + val bitmap = Bitmap.createBitmap( + drawable.intrinsicWidth.coerceAtLeast(1), + drawable.intrinsicHeight.coerceAtLeast(1), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + return stream.toByteArray() + } +} diff --git a/packages/upi_intent/ios/Classes/UpiIntentPlugin.swift b/packages/upi_intent/ios/Classes/UpiIntentPlugin.swift new file mode 100644 index 0000000..ae05cb7 --- /dev/null +++ b/packages/upi_intent/ios/Classes/UpiIntentPlugin.swift @@ -0,0 +1,90 @@ +import Flutter +import UIKit + +public class UpiIntentPlugin: NSObject, FlutterPlugin { + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "upi_intent", binaryMessenger: registrar.messenger()) + let instance = UpiIntentPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getInstalledUpiApps": + handleGetInstalledApps(result: result) + case "launchUpiApp": + guard let args = call.arguments as? [String: Any], + let upiUrl = args["upiUrl"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "upiUrl is required", details: nil)) + return + } + handleLaunchUpiApp(upiUrl: upiUrl, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - Get Installed UPI Apps + + /// Returns installed UPI-capable apps on iOS + /// Note: iOS has limited app discovery due to sandboxing. + /// We check known URL schemes for popular UPI apps. + private func handleGetInstalledApps(result: @escaping FlutterResult) { + let upiApps: [(name: String, packageName: String, scheme: String)] = [ + ("Google Pay", "com.google.GooglePayIndia", "gpay://"), + ("PhonePe", "com.phonepe.PhonePeApp", "phonepe://"), + ("Paytm", "net.one97.paytm", "paytmmp://"), + ("Amazon Pay", "com.amazon.AmazonIN", "amznmobile://"), + ("BHIM", "in.gov.uidai.BHIMApp", "bhim://"), + ] + + var installedApps: [[String: Any]] = [] + + for app in upiApps { + if let url = URL(string: app.scheme), + UIApplication.shared.canOpenURL(url) { + installedApps.append([ + "name": app.name, + "packageName": app.packageName, + "icon": FlutterStandardTypedData(bytes: Data()) // iOS doesn't give app icons + ]) + } + } + + result(installedApps) + } + + // MARK: - Launch UPI App + + private func handleLaunchUpiApp(upiUrl: String, result: @escaping FlutterResult) { + guard let url = URL(string: upiUrl) else { + result(FlutterError(code: "INVALID_URL", message: "Invalid UPI URL: \(upiUrl)", details: nil)) + return + } + + DispatchQueue.main.async { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + if success { + // iOS doesn't return transaction data like Android. + // Return a pending status — the app must verify via backend. + result("Status=pending") + } else { + result(FlutterError( + code: "LAUNCH_FAILED", + message: "Failed to open UPI URL", + details: nil + )) + } + } + } else { + result(FlutterError( + code: "NO_APP", + message: "No UPI app found that can handle this URL", + details: nil + )) + } + } + } +} diff --git a/packages/upi_intent/ios/Resources/PrivacyInfo.xcprivacy b/packages/upi_intent/ios/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..a34b7e2 --- /dev/null +++ b/packages/upi_intent/ios/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/packages/upi_intent/ios/upi_intent.podspec b/packages/upi_intent/ios/upi_intent.podspec new file mode 100644 index 0000000..b892a70 --- /dev/null +++ b/packages/upi_intent/ios/upi_intent.podspec @@ -0,0 +1,29 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint upi_intent.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'upi_intent' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + # If your plugin requires a privacy manifest, for example if it uses any + # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your + # plugin's privacy impact, and then uncomment this line. For more information, + # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + # s.resource_bundles = {'upi_intent_privacy' => ['Resources/PrivacyInfo.xcprivacy']} +end diff --git a/packages/upi_intent/lib/src/builder/upi_url_builder.dart b/packages/upi_intent/lib/src/builder/upi_url_builder.dart new file mode 100644 index 0000000..a329c5a --- /dev/null +++ b/packages/upi_intent/lib/src/builder/upi_url_builder.dart @@ -0,0 +1,40 @@ +import '../models/upi_payment.dart'; + +/// Builds NPCI-spec compliant UPI payment URLs +/// +/// Format: `upi://pay?pa=VPA&pn=NAME&am=AMOUNT&cu=INR&...` +class UpiUrlBuilder { + UpiUrlBuilder._(); + + /// Build a UPI payment URL from [UpiPayment] + static String build(UpiPayment payment) { + final params = { + 'pa': payment.payeeVpa, + 'pn': Uri.encodeComponent(payment.payeeName), + 'cu': 'INR', + }; + + if (payment.amount != null && payment.amount! > 0) { + params['am'] = payment.amount!.toStringAsFixed(2); + } + + if (payment.transactionNote != null && + payment.transactionNote!.isNotEmpty) { + params['tn'] = Uri.encodeComponent(payment.transactionNote!); + } + + if (payment.transactionRefId != null && + payment.transactionRefId!.isNotEmpty) { + params['tr'] = payment.transactionRefId!; + } + + if (payment.merchantCode != null && payment.merchantCode!.isNotEmpty) { + params['mc'] = payment.merchantCode!; + } + + final query = + params.entries.map((e) => '${e.key}=${e.value}').join('&'); + + return 'upi://pay?$query'; + } +} diff --git a/packages/upi_intent/lib/src/models/upi_app.dart b/packages/upi_intent/lib/src/models/upi_app.dart new file mode 100644 index 0000000..15c278f --- /dev/null +++ b/packages/upi_intent/lib/src/models/upi_app.dart @@ -0,0 +1,39 @@ +import 'dart:typed_data'; + +/// A UPI-enabled app installed on the device +class UpiApp { + /// Display name (e.g., "Google Pay") + final String name; + + /// Android package name (e.g., "com.google.android.apps.nbu.paisa.user") + final String packageName; + + /// App icon as raw bytes (PNG) + final Uint8List? icon; + + const UpiApp({ + required this.name, + required this.packageName, + this.icon, + }); + + @override + String toString() => 'UpiApp(name: $name, package: $packageName)'; +} + +/// Well-known UPI app package names +class KnownUpiApps { + KnownUpiApps._(); + + static const String googlePay = + 'com.google.android.apps.nbu.paisa.user'; + static const String phonePe = 'com.phonepe.app'; + static const String paytm = 'net.one97.paytm'; + static const String amazonPay = 'in.amazon.mshop.android.shopping'; + static const String bhim = 'in.org.npci.upiapp'; + static const String airtelPay = 'com.airtel.india.payments'; + static const String whatsApp = 'com.whatsapp'; + static const String jioMoney = 'com.jio.jiopaymcp'; + static const String iMobile = 'com.csam.icici.bank.imobile'; + static const String yono = 'com.sbi.SBIFreedomPlus'; +} diff --git a/packages/upi_intent/lib/src/models/upi_exception.dart b/packages/upi_intent/lib/src/models/upi_exception.dart new file mode 100644 index 0000000..cb0d16d --- /dev/null +++ b/packages/upi_intent/lib/src/models/upi_exception.dart @@ -0,0 +1,13 @@ +/// Custom exception for UPI-related errors +class UpiException implements Exception { + /// Human-readable error message + final String message; + + /// Optional error code + final String? code; + + const UpiException(this.message, {this.code}); + + @override + String toString() => 'UpiException: $message${code != null ? ' (code: $code)' : ''}'; +} diff --git a/packages/upi_intent/lib/src/models/upi_payment.dart b/packages/upi_intent/lib/src/models/upi_payment.dart new file mode 100644 index 0000000..7b3ff98 --- /dev/null +++ b/packages/upi_intent/lib/src/models/upi_payment.dart @@ -0,0 +1,29 @@ +/// UPI Payment request model (NPCI spec compliant) +class UpiPayment { + /// Payee UPI VPA (e.g., "merchant@upi") — REQUIRED + final String payeeVpa; + + /// Payee display name — REQUIRED + final String payeeName; + + /// Transaction amount in INR — Optional (user fills if null) + final double? amount; + + /// Short transaction note shown in UPI app + final String? transactionNote; + + /// Unique merchant transaction reference ID + final String? transactionRefId; + + /// Merchant Category Code + final String? merchantCode; + + const UpiPayment({ + required this.payeeVpa, + required this.payeeName, + this.amount, + this.transactionNote, + this.transactionRefId, + this.merchantCode, + }); +} diff --git a/packages/upi_intent/lib/src/models/upi_response.dart b/packages/upi_intent/lib/src/models/upi_response.dart new file mode 100644 index 0000000..e60bd3a --- /dev/null +++ b/packages/upi_intent/lib/src/models/upi_response.dart @@ -0,0 +1,72 @@ +/// UPI transaction result status +enum UpiTransactionStatus { + /// Payment completed successfully + success, + + /// Payment failed + failure, + + /// Payment submitted but not yet confirmed + submitted, + + /// Status unknown (network/timeout issues) + unknown, +} + +/// Result of a UPI payment transaction +class UpiResponse { + /// Final transaction status + final UpiTransactionStatus status; + + /// UPI transaction ID assigned by PSP + final String? transactionId; + + /// NPCI response code + final String? responseCode; + + /// Bank approval reference number + final String? approvalRefNo; + + /// Full raw response string from UPI app + final String rawResponse; + + const UpiResponse({ + required this.status, + required this.rawResponse, + this.transactionId, + this.responseCode, + this.approvalRefNo, + }); + + /// Parse raw UPI response string into [UpiResponse] + factory UpiResponse.fromResponseString(String response) { + final params = Uri.splitQueryString(response); + final statusStr = params['Status']?.toLowerCase() ?? + params['status']?.toLowerCase(); + + final status = switch (statusStr) { + 'success' => UpiTransactionStatus.success, + 'failure' => UpiTransactionStatus.failure, + 'submitted' => UpiTransactionStatus.submitted, + _ => UpiTransactionStatus.unknown, + }; + + return UpiResponse( + rawResponse: response, + status: status, + transactionId: params['txnId'] ?? params['txnid'], + responseCode: params['responseCode'], + approvalRefNo: params['ApprovalRefNo'], + ); + } + + /// Whether payment was successful + bool get isSuccess => status == UpiTransactionStatus.success; + + /// Whether payment failed + bool get isFailure => status == UpiTransactionStatus.failure; + + @override + String toString() => + 'UpiResponse(status: $status, txnId: $transactionId)'; +} diff --git a/packages/upi_intent/lib/src/platform/upi_intent_method_channel.dart b/packages/upi_intent/lib/src/platform/upi_intent_method_channel.dart new file mode 100644 index 0000000..76a551e --- /dev/null +++ b/packages/upi_intent/lib/src/platform/upi_intent_method_channel.dart @@ -0,0 +1,43 @@ +import 'package:flutter/services.dart'; + +import '../models/upi_app.dart'; +import '../models/upi_response.dart'; +import 'upi_intent_platform_interface.dart'; + +/// Android + iOS method channel implementation +class MethodChannelUpiIntent extends UpiIntentPlatform { + static const MethodChannel _channel = MethodChannel('upi_intent'); + + @override + Future> getInstalledApps() async { + final List? result = + await _channel.invokeMethod>('getInstalledUpiApps'); + + if (result == null) return []; + + return result.map((app) { + final map = Map.from(app as Map); + return UpiApp( + name: map['name'] as String, + packageName: map['packageName'] as String, + icon: map['icon'] != null + ? Uint8List.fromList(List.from(map['icon'] as List)) + : null, + ); + }).toList(); + } + + @override + Future launchUpiApp({ + required String upiUrl, + required String packageName, + }) async { + final String? response = await _channel.invokeMethod( + 'launchUpiApp', + {'upiUrl': upiUrl, 'packageName': packageName}, + ); + + if (response == null || response.isEmpty) return null; + return UpiResponse.fromResponseString(response); + } +} diff --git a/packages/upi_intent/lib/src/platform/upi_intent_platform_interface.dart b/packages/upi_intent/lib/src/platform/upi_intent_platform_interface.dart new file mode 100644 index 0000000..d46539b --- /dev/null +++ b/packages/upi_intent/lib/src/platform/upi_intent_platform_interface.dart @@ -0,0 +1,34 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../models/upi_app.dart'; +import '../models/upi_response.dart'; + +/// Platform interface for upi_intent plugin +abstract class UpiIntentPlatform extends PlatformInterface { + UpiIntentPlatform() : super(token: _token); + + static final Object _token = Object(); + static UpiIntentPlatform _instance = _UpiIntentPlatformDefault(); + + static UpiIntentPlatform get instance => _instance; + + static set instance(UpiIntentPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + /// Returns list of UPI apps installed on the device + Future> getInstalledApps() { + throw UnimplementedError('getInstalledApps() not implemented.'); + } + + /// Launches a UPI app with the given URL and returns transaction response + Future launchUpiApp({ + required String upiUrl, + required String packageName, + }) { + throw UnimplementedError('launchUpiApp() not implemented.'); + } +} + +class _UpiIntentPlatformDefault extends UpiIntentPlatform {} diff --git a/packages/upi_intent/lib/src/validator/upi_validator.dart b/packages/upi_intent/lib/src/validator/upi_validator.dart new file mode 100644 index 0000000..b4ace21 --- /dev/null +++ b/packages/upi_intent/lib/src/validator/upi_validator.dart @@ -0,0 +1,48 @@ +/// Validates UPI payment fields before launching an intent +class UpiValidator { + UpiValidator._(); + + /// UPI VPA regex: alphanumeric/dot/dash/underscore @ 3+ alpha chars + static final _vpaRegex = RegExp(r'^[a-zA-Z0-9._\-]+@[a-zA-Z]{3,}$'); + + /// Validates a UPI Virtual Payment Address (VPA) + /// + /// Examples of valid VPAs: + /// - `user@upi` + /// - `9876543210@paytm` + /// - `john.doe@oksbi` + static bool isValidVpa(String vpa) { + if (vpa.trim().isEmpty) return false; + return _vpaRegex.hasMatch(vpa.trim()); + } + + /// Validates a payment amount + /// + /// Rules: + /// - Must be positive + /// - Must not exceed ₹1,00,000 (per NPCI limit) + static bool isValidAmount(double amount) { + if (amount <= 0) return false; + if (amount > 100000) return false; + return true; + } + + /// Converts a 10-digit phone number to a UPI ID + /// + /// Example: `phoneToVpa("9876543210", "paytm")` → `"9876543210@paytm"` + static String phoneToVpa(String phone, String bankHandle) { + final clean = phone.replaceAll(RegExp(r'[^0-9]'), ''); + if (clean.length != 10) { + throw ArgumentError('Phone number must be exactly 10 digits'); + } + return '$clean@$bankHandle'; + } + + /// Extracts bank handle from VPA + /// + /// Example: `bankHandle("user@oksbi")` → `"oksbi"` + static String? bankHandle(String vpa) { + final parts = vpa.split('@'); + return parts.length == 2 ? parts[1] : null; + } +} diff --git a/packages/upi_intent/lib/src/widgets/upi_app_picker.dart b/packages/upi_intent/lib/src/widgets/upi_app_picker.dart new file mode 100644 index 0000000..42c7355 --- /dev/null +++ b/packages/upi_intent/lib/src/widgets/upi_app_picker.dart @@ -0,0 +1,217 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../models/upi_app.dart'; + +/// A beautiful bottom sheet that lets user pick their UPI app +/// +/// This is the USP of upi_intent — no other package has this built-in! +class UpiAppPicker extends StatelessWidget { + final List apps; + + const UpiAppPicker({super.key, required this.apps}); + + /// Show the UPI app picker bottom sheet + /// + /// Returns the selected [UpiApp], or null if user dismissed + static Future show( + BuildContext context, + List apps, + ) { + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => UpiAppPicker(apps: apps), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1E1E2E) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.black12, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.account_balance_wallet_rounded, + color: theme.colorScheme.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pay with UPI', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + 'Choose your preferred app', + style: theme.textTheme.bodySmall?.copyWith( + color: isDark ? Colors.white54 : Colors.black45, + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 20), + + // App Grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 16, + crossAxisSpacing: 8, + childAspectRatio: 0.85, + ), + itemCount: apps.length, + itemBuilder: (_, i) => _UpiAppTile( + app: apps[i], + onTap: () => Navigator.of(context).pop(apps[i]), + ), + ), + + const SizedBox(height: 20), + + // Cancel button + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: + isDark ? Colors.white10 : Colors.grey.shade100, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Cancel', + style: TextStyle( + color: isDark ? Colors.white70 : Colors.black54, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// A single UPI app tile in the grid +class _UpiAppTile extends StatelessWidget { + final UpiApp app; + final VoidCallback onTap; + + const _UpiAppTile({required this.app, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // App icon + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isDark ? Colors.white12 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: app.icon != null + ? Image.memory( + Uint8List.fromList(app.icon!), + fit: BoxFit.cover, + ) + : Icon( + Icons.account_balance_wallet_rounded, + color: theme.colorScheme.primary, + size: 28, + ), + ), + ), + const SizedBox(height: 6), + // App name + Text( + app.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} diff --git a/packages/upi_intent/lib/upi_intent.dart b/packages/upi_intent/lib/upi_intent.dart new file mode 100644 index 0000000..4946121 --- /dev/null +++ b/packages/upi_intent/lib/upi_intent.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import 'src/builder/upi_url_builder.dart'; +import 'src/models/upi_app.dart'; +import 'src/models/upi_exception.dart'; +import 'src/models/upi_payment.dart'; +import 'src/models/upi_response.dart'; +import 'src/platform/upi_intent_method_channel.dart'; +import 'src/platform/upi_intent_platform_interface.dart'; +import 'src/validator/upi_validator.dart'; +import 'src/widgets/upi_app_picker.dart'; + +export 'src/models/upi_app.dart'; +export 'src/models/upi_exception.dart'; +export 'src/models/upi_payment.dart'; +export 'src/models/upi_response.dart'; +export 'src/validator/upi_validator.dart'; +export 'src/widgets/upi_app_picker.dart'; + +/// The main entry point for upi_intent plugin +/// +/// Example usage: +/// ```dart +/// final response = await UpiIntent.pay( +/// context: context, +/// payment: UpiPayment( +/// payeeVpa: 'merchant@upi', +/// payeeName: 'My Shop', +/// amount: 99.00, +/// transactionNote: 'Order #1234', +/// ), +/// ); +/// +/// if (response != null && response.isSuccess) { +/// print('Payment successful! TxnID: ${response.transactionId}'); +/// } +/// ``` +class UpiIntent { + UpiIntent._(); + + static UpiIntentPlatform get _platform { + UpiIntentPlatform.instance = MethodChannelUpiIntent(); + return UpiIntentPlatform.instance; + } + + /// Get list of UPI apps installed on the device + /// + /// Returns empty list if no UPI apps are installed. + static Future> getInstalledApps() async { + return _platform.getInstalledApps(); + } + + /// Launch UPI payment with a beautiful app picker + /// + /// Shows a bottom sheet with all installed UPI apps. + /// Returns [UpiResponse] on completion, or null if user cancelled. + /// + /// Throws [UpiException] if: + /// - VPA is invalid + /// - Amount is invalid + /// - No UPI apps are installed + static Future pay({ + required BuildContext context, + required UpiPayment payment, + }) async { + // Validate inputs + if (!UpiValidator.isValidVpa(payment.payeeVpa)) { + throw UpiException( + 'Invalid UPI VPA: "${payment.payeeVpa}". ' + 'Valid format: username@bankname (e.g., user@upi)', + ); + } + + if (payment.amount != null && + !UpiValidator.isValidAmount(payment.amount!)) { + throw UpiException( + 'Invalid amount: ${payment.amount}. ' + 'Amount must be between ₹0.01 and ₹1,00,000.', + ); + } + + // Get installed apps + final apps = await getInstalledApps(); + if (apps.isEmpty) { + throw UpiException( + 'No UPI apps found on this device. ' + 'Please install Google Pay, PhonePe, or Paytm.', + code: 'NO_UPI_APPS', + ); + } + + // Show app picker + if (!context.mounted) return null; + final selectedApp = await UpiAppPicker.show(context, apps); + if (selectedApp == null) return null; // User cancelled + + // Build URL and launch + final upiUrl = UpiUrlBuilder.build(payment); + return _platform.launchUpiApp( + upiUrl: upiUrl, + packageName: selectedApp.packageName, + ); + } + + /// Pay directly with a specific UPI app (no picker shown) + /// + /// Use this when you already know which app the user wants. + static Future payWithApp({ + required UpiPayment payment, + required UpiApp app, + }) async { + if (!UpiValidator.isValidVpa(payment.payeeVpa)) { + throw UpiException('Invalid UPI VPA: "${payment.payeeVpa}"'); + } + + final upiUrl = UpiUrlBuilder.build(payment); + return _platform.launchUpiApp( + upiUrl: upiUrl, + packageName: app.packageName, + ); + } + + /// Build a raw UPI URL without launching anything + /// + /// Useful for QR code generation or sharing payment links. + static String buildUpiUrl(UpiPayment payment) { + return UpiUrlBuilder.build(payment); + } +} diff --git a/packages/upi_intent/pubspec.yaml b/packages/upi_intent/pubspec.yaml new file mode 100644 index 0000000..b530d85 --- /dev/null +++ b/packages/upi_intent/pubspec.yaml @@ -0,0 +1,38 @@ +name: upi_intent +description: "Vendored & customized fork of upi_intent — launches UPI payment apps via a themeable built-in picker. Internal to this repo; not published." +version: 1.0.1 +# Vendored into this repo (consumed via a path dependency) so we can theme the +# picker and control the dependency. Original: https://pub.dev/packages/upi_intent +publish_to: "none" +homepage: https://pub.dev/packages/upi_intent + +topics: + - upi + - payments + - fintech + - india + - flutter + +environment: + sdk: ^3.10.1 + flutter: '>=3.10.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + url_launcher: ^6.2.5 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + android: + package: com.upiintent.upi_intent + pluginClass: UpiIntentPlugin + ios: + pluginClass: UpiIntentPlugin diff --git a/pubspec.lock b/pubspec.lock index 772baa9..dcf7b6b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -460,18 +460,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -488,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" nested: dependency: transitive description: @@ -713,10 +721,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" timeago: dependency: "direct main" description: @@ -733,6 +741,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + upi_intent: + dependency: "direct main" + description: + path: "packages/upi_intent" + relative: true + source: path + version: "1.0.1" url_launcher: dependency: "direct main" description: @@ -886,5 +901,5 @@ packages: source: hosted version: "2.1.0" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.10.1 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 90ef9e3..03871b9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.1+2 environment: sdk: ^3.10.0 @@ -62,6 +62,12 @@ dependencies: fl_chart: ^1.1.1 collection: ^1.19.1 + # UPI scan & pay + mobile_scanner: ^7.2.0 + # Vendored (see packages/upi_intent) so we can theme the built-in picker. + upi_intent: + path: packages/upi_intent + dev_dependencies: