Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for scanning UPI QR codes with the device camera -->
<uses-permission android:name="android.permission.CAMERA" />

<application
android:label="Clean Expense"
android:name="${applicationName}"
Expand Down Expand Up @@ -49,5 +52,12 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Required (Android 11+) so upi_intent can discover installed UPI
apps. No host filter — many UPI apps register the `upi` scheme
without host="pay", and restricting it hides them. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="upi" />
</intent>
</queries>
</manifest>
13 changes: 13 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,23 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Camera access is needed to scan UPI QR codes for payments.</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>mailto</string>
<!-- UPI apps, so upi_pay can detect and launch them -->
<string>upi</string>
<string>tez</string>
<string>gpay</string>
<string>phonepe</string>
<string>paytmmp</string>
<string>paytm</string>
<string>bhim</string>
<string>credpay</string>
<string>ybl</string>
<string>imobileapp</string>
</array>
</dict>
</plist>
54 changes: 54 additions & 0 deletions lib/data/utils/upi_uri.dart
Original file line number Diff line number Diff line change
@@ -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,
);
}
}
11 changes: 11 additions & 0 deletions lib/screens/home/components/home_app_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions lib/screens/upi/components/category_chip.dart
Original file line number Diff line number Diff line change
@@ -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,
),
);
}
}
197 changes: 197 additions & 0 deletions lib/screens/upi/components/category_picker_sheet.dart
Original file line number Diff line number Diff line change
@@ -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<String?> show(BuildContext context, {String? selected}) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: AppTheme.cardBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) => CategoryPickerSheet(selected: selected),
);
}

@override
State<CategoryPickerSheet> createState() => _CategoryPickerSheetState();
}

class _CategoryPickerSheetState extends State<CategoryPickerSheet> {
final TextEditingController _search = TextEditingController();

// Sorted by usage (most-used first), then defaults alphabetically.
late final List<String> _all =
BaseAppCommand.blocExpense.getSuggestionsForType(
TransactionType.outgoing,
);
late final Set<String> _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<String> get _mostUsed =>
_all.where(_used.contains).take(8).toList(growable: false);

List<String> 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,
),
);
}
}
Loading