Files
Kifi/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart

1112 lines
49 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'widgets/attachment_gallery_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as flutter_secure_storage;
import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import '../providers/providers.dart';
import '../data/models.dart';
import '../../../core/utils/snackbar_service.dart';
import '../../../core/services/ocr_service.dart';
import '../../../core/services/notification_service.dart';
class AddTransactionScreen extends ConsumerStatefulWidget {
final Transaction? transaction;
const AddTransactionScreen({super.key, this.transaction});
@override
ConsumerState<AddTransactionScreen> createState() => _AddTransactionScreenState();
}
class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
final amountController = TextEditingController();
final descriptionController = TextEditingController();
DateTime selectedDate = DateTime.now();
Category? selectedCategory;
Wallet? fromWallet;
Wallet? toWallet;
bool isSaving = false;
bool isRecurring = false;
String recurringFrequency = 'MONTHLY';
final List<String> frequencies = ['DAILY', 'WEEKLY', 'MONTHLY'];
DateTime? dueDate;
String alertSchedule = 'NONE';
TimeOfDay alertTime = const TimeOfDay(hour: 9, minute: 0);
final List<TransactionItem> lineItems = [];
final List<TransactionAttachment> existingAttachments = [];
final List<int> deletedAttachmentIds = [];
final List<Map<String, String>> base64Attachments = [];
final ImagePicker _picker = ImagePicker();
String? _jwtToken;
final OcrService _ocrService = OcrService();
void _handleOcrResult(OcrResult? res) {
if (res == null) return;
if (res.amount != null) {
setState(() {
amountController.text = res.amount.toString();
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Found amount: Rs. ${res.amount}')));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not find an amount in the receipt.')));
}
}
@override
void initState() {
super.initState();
const flutter_secure_storage.FlutterSecureStorage().read(key: 'jwt_token').then((value) {
if (mounted) setState(() => _jwtToken = value);
});
if (widget.transaction != null) {
final t = widget.transaction!;
amountController.text = t.amount.toString();
if (t.description != null) descriptionController.text = t.description!;
selectedDate = t.date;
if (t.items != null) lineItems.addAll(t.items!);
if (t.attachments != null) existingAttachments.addAll(t.attachments!);
dueDate = t.dueDate;
alertSchedule = t.alertSchedule ?? 'NONE';
if (t.alertTime != null) {
final parts = t.alertTime!.split(':');
if (parts.length >= 2) {
alertTime = TimeOfDay(hour: int.tryParse(parts[0]) ?? 9, minute: int.tryParse(parts[1]) ?? 0);
}
}
}
}
void _resolveInitialSelections(List<Category>? categories, List<Wallet>? wallets) {
if (widget.transaction != null && selectedCategory == null && categories != null) {
final matches = categories.where((c) => c.id == widget.transaction!.categoryId);
if (matches.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => selectedCategory = matches.first);
});
}
}
if (wallets != null && wallets.isNotEmpty && (fromWallet == null || toWallet == null)) {
if (widget.transaction != null) {
final fMatches = wallets.where((w) => w.id == widget.transaction!.fromWalletId);
if (fMatches.isNotEmpty && fromWallet == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => fromWallet = fMatches.first);
});
}
final tMatches = wallets.where((w) => w.id == widget.transaction!.toWalletId);
if (tMatches.isNotEmpty && toWallet == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => toWallet = tMatches.first);
});
}
}
}
if (widget.transaction == null) {
if (categories != null && categories.length == 1 && selectedCategory == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => selectedCategory = categories.first);
});
}
if (wallets != null && wallets.length == 1 && toWallet == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => toWallet = wallets.first);
});
}
}
}
Future<void> _pickImage(ImageSource source) async {
if ((base64Attachments.length + existingAttachments.length) >= 3) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Max 3 attachments allowed')));
return;
}
final XFile? image = await _picker.pickImage(source: source);
if (image != null) {
final bytes = await image.readAsBytes();
final compressedBytes = await FlutterImageCompress.compressWithList(
bytes,
minWidth: 600,
quality: 80,
);
final base64String = base64Encode(compressedBytes);
setState(() {
base64Attachments.add({
'fileName': image.name,
'contentType': 'image/jpeg',
'base64Content': base64String,
});
});
}
}
void _addLineItem() {
final nameCtrl = TextEditingController();
final amountCtrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Add Item', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: nameCtrl,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
labelText: 'Item Name',
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
)
),
const SizedBox(height: 16),
TextField(
controller: amountCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
labelText: 'Amount',
prefixText: 'Rs. ',
prefixStyle: TextStyle(color: Theme.of(context).textTheme.bodyLarge?.color, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
)
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold))
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () {
if (nameCtrl.text.isNotEmpty && amountCtrl.text.isNotEmpty) {
setState(() {
lineItems.add(TransactionItem(name: nameCtrl.text, amount: double.parse(amountCtrl.text)));
double currentTotal = double.tryParse(amountController.text) ?? 0;
amountController.text = (currentTotal + double.parse(amountCtrl.text)).toString();
});
Navigator.pop(ctx);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Add', style: TextStyle(fontWeight: FontWeight.bold))
),
],
),
],
),
),
),
),
),
);
}
Future<void> _showCreateWalletDialog() async {
final ctrl = TextEditingController();
String selectedNature = 'CASH';
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE'];
await showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: const Color(0xFF6C63FF).withValues(alpha: 0.1), shape: BoxShape.circle),
child: const Icon(LucideIcons.wallet, color: Color(0xFF6C63FF), size: 24),
),
const SizedBox(width: 16),
const Text('New Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 24),
TextField(
controller: ctrl,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Account Name (e.g. Household)',
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: selectedNature,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedNature = val);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (ctrl.text.isNotEmpty) {
await ref.read(walletProvider.notifier).createWallet(name: ctrl.text, nature: selectedNature);
if (context.mounted) Navigator.pop(ctx);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Create', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
);
}
),
);
}
void _showCategoryPicker() {
showModalBottomSheet(
context: context,
builder: (ctx) {
return Consumer(builder: (context, ref, child) {
final categoriesState = ref.watch(categoryProvider);
return Column(
children: [
ListTile(
title: const Text('Select Category', style: TextStyle(fontWeight: FontWeight.bold)),
trailing: IconButton(
icon: const Icon(LucideIcons.plus),
onPressed: () => _showQuickAdd('Category'),
),
),
Expanded(
child: categoriesState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
data: (categories) => ListView.builder(
itemCount: categories.length,
itemBuilder: (context, index) {
final c = categories[index];
return ListTile(
leading: const Icon(LucideIcons.tag),
title: Text(c.name),
onTap: () {
setState(() => selectedCategory = c);
Navigator.pop(context);
},
);
},
),
),
)
],
);
});
},
);
}
void _showWalletPicker(bool isDestination) {
showModalBottomSheet(
context: context,
builder: (ctx) {
return Consumer(builder: (context, ref, child) {
final walletsState = ref.watch(walletProvider);
return Column(
children: [
ListTile(
title: Text(isDestination ? 'Select To Account' : 'Select From Account', style: const TextStyle(fontWeight: FontWeight.bold)),
leading: IconButton(
icon: const Icon(LucideIcons.plus),
tooltip: 'Create New Account',
onPressed: () {
Navigator.pop(context); // Close picker
_showCreateWalletDialog();
},
),
trailing: IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () {
if (isDestination) {
setState(() => toWallet = null);
} else {
setState(() => fromWallet = null);
}
Navigator.pop(context);
},
),
),
Expanded(
child: walletsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
data: (walletsData) {
final wallets = walletsData.where((w) => w.nature != 'EQUITY').toList();
return ListView.builder(
itemCount: wallets.length,
itemBuilder: (context, index) {
final w = wallets[index];
return ListTile(
leading: const Icon(LucideIcons.wallet),
title: Text(w.name),
subtitle: Text('${w.nature ?? "CASH"} • Balance: Rs. ${w.balance}'),
onTap: () {
if (isDestination) {
setState(() => toWallet = w);
} else {
setState(() => fromWallet = w);
}
Navigator.pop(context);
},
);
},
);
}
),
)
],
);
});
},
);
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: DateTime(2000),
lastDate: DateTime(2101),
);
if (picked != null && picked != selectedDate) {
setState(() {
selectedDate = picked;
});
}
}
void _showQuickAdd(String type) {
Navigator.pop(context);
final ctrl = TextEditingController();
showDialog(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Add New $type', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: ctrl,
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Name',
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (ctrl.text.isEmpty) return;
if (type == 'Category') {
await ref.read(categoryProvider.notifier).addCategory(ctrl.text, 'tag');
}
if (mounted) Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Add', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
),
);
}
Future<void> _saveTransaction() async {
final amountText = amountController.text.trim();
if (amountText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter an amount')));
return;
}
if (toWallet == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a To Account')));
return;
}
// Check if From Account is mandatory
bool fromAccountOptional = (toWallet?.nature == 'SAVINGS' || toWallet?.nature == 'INCOME');
if (fromWallet == null && !fromAccountOptional) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a From Account')));
return;
}
if (fromWallet != null && toWallet != null && fromWallet?.id == toWallet?.id) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('From and To accounts cannot be the same')));
return;
}
final amount = double.tryParse(amountText);
if (amount == null) return;
if (fromWallet != null && fromWallet!.balance < amount) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Insufficient balance in From Account')));
return;
}
setState(() => isSaving = true);
// Determine internal type based on natures
String type = 'TRANSFER';
if (fromWallet == null) {
type = 'INCOME';
} else if (toWallet == null) { // Though toWallet is mandatory, just in case
type = 'EXPENSE';
} else if (fromWallet?.nature == 'INCOME') {
type = 'INCOME';
} else if (toWallet?.nature == 'PAYABLES' || toWallet?.nature == 'EXPENSE') {
type = 'EXPENSE';
} else if (toWallet?.nature == 'INVESTMENTS') {
type = 'INVESTMENT';
}
if (isRecurring && widget.transaction == null) {
DateTime nextDate = selectedDate;
if (recurringFrequency == 'DAILY') {
nextDate = nextDate.add(const Duration(days: 1));
} else if (recurringFrequency == 'WEEKLY') {
nextDate = nextDate.add(const Duration(days: 7));
} else if (recurringFrequency == 'MONTHLY') {
nextDate = DateTime(nextDate.year, nextDate.month + 1, nextDate.day);
}
final rt = RecurringTransaction(
id: 0,
type: type,
amount: amount,
categoryId: selectedCategory?.id,
fromWalletId: fromWallet?.id,
toWalletId: toWallet?.id,
frequency: recurringFrequency,
description: descriptionController.text.trim(),
nextExecutionDate: nextDate,
status: 'ACTIVE',
);
await ref.read(recurringTransactionProvider.notifier).addRecurringTransaction(rt);
final tx = Transaction(
id: 0,
type: type,
amount: amount,
date: selectedDate,
description: descriptionController.text.trim(),
categoryId: selectedCategory?.id,
fromWalletId: fromWallet?.id,
toWalletId: toWallet?.id,
dueDate: dueDate,
alertSchedule: alertSchedule == 'NONE' ? null : alertSchedule,
alertTime: alertSchedule == 'NONE' ? null : '${alertTime.hour.toString().padLeft(2, '0')}:${alertTime.minute.toString().padLeft(2, '0')}:00',
items: lineItems.isNotEmpty ? lineItems : null,
);
await ref.read(transactionProvider.notifier).addTransaction(tx, base64Attachments: base64Attachments);
} else {
final tx = Transaction(
id: widget.transaction?.id ?? 0,
type: type,
amount: amount,
date: selectedDate,
description: descriptionController.text.trim(),
categoryId: selectedCategory?.id,
fromWalletId: fromWallet?.id,
toWalletId: toWallet?.id,
dueDate: dueDate,
alertSchedule: alertSchedule == 'NONE' ? null : alertSchedule,
alertTime: alertSchedule == 'NONE' ? null : '${alertTime.hour.toString().padLeft(2, '0')}:${alertTime.minute.toString().padLeft(2, '0')}:00',
items: lineItems.isNotEmpty ? lineItems : null,
);
if (widget.transaction == null) {
await ref.read(transactionProvider.notifier).addTransaction(tx, base64Attachments: base64Attachments);
} else {
await ref.read(transactionProvider.notifier).updateTransaction(
tx,
base64Attachments: base64Attachments,
deletedAttachmentIds: deletedAttachmentIds,
);
}
if (alertSchedule != 'NONE' && dueDate != null) {
DateTime alertDate = dueDate!;
if (alertSchedule == '1_DAY_BEFORE') {
alertDate = dueDate!.subtract(const Duration(days: 1));
} else if (alertSchedule == '2_DAYS_BEFORE') {
alertDate = dueDate!.subtract(const Duration(days: 2));
} else if (alertSchedule == '1_WEEK_BEFORE') {
alertDate = dueDate!.subtract(const Duration(days: 7));
}
final notifyDate = DateTime(alertDate.year, alertDate.month, alertDate.day, alertTime.hour, alertTime.minute);
if (notifyDate.isAfter(DateTime.now())) {
NotificationService().scheduleNotification(
id: DateTime.now().millisecondsSinceEpoch.remainder(100000),
title: 'Payment Due!',
body: 'Rs. ${amount.toStringAsFixed(0)} is due for ${toWallet?.name}',
scheduledDate: notifyDate,
);
}
}
}
if (mounted) {
SnackBarService.showSuccess(context, 'Transaction saved successfully');
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final categoriesState = ref.watch(categoryProvider);
final walletsState = ref.watch(walletProvider);
_resolveInitialSelections(categoriesState.value, walletsState.value);
return Scaffold(
appBar: AppBar(
title: Text(widget.transaction == null ? 'Add Transaction' : 'Edit Transaction'),
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
IconButton(
icon: const Icon(LucideIcons.camera),
tooltip: 'Scan Receipt',
onPressed: () async {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Take a Photo'),
onTap: () async {
Navigator.pop(ctx);
final res = await _ocrService.scanReceiptFromCamera();
_handleOcrResult(res);
},
),
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Choose from Gallery'),
onTap: () async {
Navigator.pop(ctx);
final res = await _ocrService.scanReceiptFromGallery();
_handleOcrResult(res);
},
),
],
),
),
);
},
)
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.next,
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF6C63FF)),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '0.00',
hintStyle: TextStyle(color: Colors.grey.shade400),
prefixText: 'Rs. ',
prefixStyle: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Theme.of(context).textTheme.bodyLarge?.color),
filled: true,
fillColor: const Color(0xFF6C63FF).withValues(alpha: 0.05),
contentPadding: const EdgeInsets.symmetric(vertical: 24, horizontal: 24),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(24), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 24),
TextField(
controller: descriptionController,
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Description (Optional)',
prefixIcon: const Icon(LucideIcons.alignLeft, color: Colors.grey),
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.grey.shade900 : Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
ListTile(
title: Text(DateFormat('dd MMM yyyy').format(selectedDate)),
trailing: const Icon(LucideIcons.calendar),
onTap: _pickDate,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
),
const SizedBox(height: 12),
Tooltip(
message: 'The destination account where the money is going to.',
child: ListTile(
title: Text(toWallet?.name ?? 'To Account'),
subtitle: toWallet != null ? Text(toWallet!.nature ?? 'CASH') : null,
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => _showWalletPicker(true),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
),
),
const SizedBox(height: 12),
Tooltip(
message: 'The source account where the money is coming from.',
child: ListTile(
title: Text(fromWallet?.name ?? 'From Account${(toWallet?.nature == "SAVINGS" || toWallet?.nature == "INCOME") ? " (Optional)" : ""}'),
subtitle: fromWallet != null ? Text(fromWallet!.nature ?? 'CASH') : null,
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => _showWalletPicker(false),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
),
),
const SizedBox(height: 12),
ListTile(
title: Text(selectedCategory?.name ?? 'Select Category (Optional)'),
trailing: const Icon(LucideIcons.chevronRight),
onTap: _showCategoryPicker,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
),
const SizedBox(height: 12),
if (toWallet?.nature == 'PAYABLES' || toWallet?.nature == 'LOAN') ...[
ListTile(
title: Text(dueDate == null ? 'Set Due Date (Optional)' : 'Due Date: ${DateFormat('dd MMM yyyy').format(dueDate!)}'),
trailing: const Icon(LucideIcons.calendar),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: dueDate ?? DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 30)),
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
);
if (picked != null) {
setState(() => dueDate = picked);
}
},
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
),
if (dueDate != null) ...[
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: alertSchedule,
decoration: InputDecoration(
labelText: 'Alert Notification',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
items: const [
DropdownMenuItem(value: 'NONE', child: Text('No Alert')),
DropdownMenuItem(value: 'ON_DUE_DATE', child: Text('On Due Date')),
DropdownMenuItem(value: '1_DAY_BEFORE', child: Text('1 Day Before')),
DropdownMenuItem(value: '2_DAYS_BEFORE', child: Text('2 Days Before')),
DropdownMenuItem(value: '1_WEEK_BEFORE', child: Text('1 Week Before')),
],
onChanged: (val) {
if (val != null) setState(() => alertSchedule = val);
},
),
if (alertSchedule != 'NONE') ...[
const SizedBox(height: 8),
ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
title: const Text('Alert Time'),
trailing: Text(alertTime.format(context), style: const TextStyle(fontWeight: FontWeight.bold)),
onTap: () async {
final time = await showTimePicker(
context: context,
initialTime: alertTime,
);
if (time != null) {
setState(() => alertTime = time);
}
},
),
],
],
const SizedBox(height: 12),
],
// Line Items Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Items', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
TextButton.icon(
onPressed: _addLineItem,
icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add Item'),
)
],
),
if (lineItems.isNotEmpty)
Container(
decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(12)),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: lineItems.length,
separatorBuilder: (c, i) => const Divider(height: 1),
itemBuilder: (c, i) {
final item = lineItems[i];
return ListTile(
title: Text(item.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Rs. ${item.amount}', style: const TextStyle(fontWeight: FontWeight.bold)),
IconButton(
icon: const Icon(LucideIcons.trash2, color: Colors.red, size: 18),
onPressed: () => setState(() => lineItems.removeAt(i)),
)
],
),
);
},
),
),
const SizedBox(height: 12),
// Attachments Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Attachments (${base64Attachments.length}/3)', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
if (base64Attachments.length < 3)
TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('Camera'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(LucideIcons.image),
title: const Text('Gallery'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
],
),
),
);
},
icon: const Icon(LucideIcons.paperclip, size: 16),
label: const Text('Attach'),
)
],
),
if (existingAttachments.isNotEmpty)
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: existingAttachments.length,
itemBuilder: (c, i) {
final att = existingAttachments[i];
return Stack(
children: [
GestureDetector(
onTap: () => _openGallery(i),
child: Container(
margin: const EdgeInsets.only(right: 8, top: 8),
width: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: _jwtToken != null
? NetworkImage('https://app.technobeesolutions.in/api/kifi/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'})
: const AssetImage('assets/images/placeholder.png') as ImageProvider,
fit: BoxFit.cover,
),
),
),
),
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () {
setState(() {
deletedAttachmentIds.add(att.id);
existingAttachments.removeAt(i);
});
},
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
child: const Icon(LucideIcons.x, size: 12, color: Colors.white),
),
),
),
],
);
},
),
),
if (base64Attachments.isNotEmpty)
SizedBox(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: base64Attachments.length,
itemBuilder: (c, i) {
final att = base64Attachments[i];
return Stack(
children: [
GestureDetector(
onTap: () => _openGallery(existingAttachments.length + i),
child: Container(
margin: const EdgeInsets.only(right: 8, top: 8),
width: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
image: DecorationImage(
image: MemoryImage(base64Decode(att['base64Content']!)),
fit: BoxFit.cover,
),
),
),
),
Positioned(
top: 0,
right: 0,
child: GestureDetector(
onTap: () => setState(() => base64Attachments.removeAt(i)),
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle),
child: const Icon(LucideIcons.x, size: 12, color: Colors.white),
),
),
),
],
);
},
),
),
const SizedBox(height: 24),
if (widget.transaction == null)
SwitchListTile(
title: const Text('Recurring Transaction'),
value: isRecurring,
activeColor: Theme.of(context).colorScheme.primary,
onChanged: (val) => setState(() => isRecurring = val),
),
if (isRecurring && widget.transaction == null)
DropdownButtonFormField<String>(
value: recurringFrequency,
decoration: InputDecoration(
labelText: 'Frequency',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: frequencies.map((f) => DropdownMenuItem(value: f, child: Text(f))).toList(),
onChanged: (val) {
if (val != null) setState(() => recurringFrequency = val);
},
),
const SizedBox(height: 32),
SizedBox(
height: 56,
child: ElevatedButton(
onPressed: isSaving ? null : _saveTransaction,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: isSaving
? const CircularProgressIndicator()
: const Text('Save Transaction', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 32),
],
),
),
),
);
}
void _openGallery(int index) {
final List<ImageProvider> allImages = [];
// Add existing attachments
for (final att in existingAttachments) {
if (_jwtToken != null) {
allImages.add(NetworkImage('https://app.technobeesolutions.in/api/kifi/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'}));
} else {
allImages.add(const AssetImage('assets/images/placeholder.png'));
}
}
// Add new attachments
for (final att in base64Attachments) {
allImages.add(MemoryImage(base64Decode(att['base64Content']!)));
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen(
images: allImages,
initialIndex: index,
),
),
);
}
}