1111 lines
49 KiB
Dart
1111 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: Colors.white,
|
|
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: 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: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
|
filled: true,
|
|
fillColor: 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: Colors.white,
|
|
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: 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: 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: (wallets) {
|
|
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: Colors.white,
|
|
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: 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: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black87),
|
|
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: 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|