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: