All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			
		
			
				
	
	
		
			214 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			214 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:typed_data';
 | |
| 
 | |
| import 'package:confirm_dialog/confirm_dialog.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.dart';
 | |
| import 'package:hooks_riverpod/hooks_riverpod.dart';
 | |
| import 'package:moneymgr_mobile/services/storage/expenses.dart';
 | |
| import 'package:moneymgr_mobile/services/storage/prefs.dart';
 | |
| import 'package:moneymgr_mobile/utils/extensions.dart';
 | |
| import 'package:moneymgr_mobile/utils/time_utils.dart';
 | |
| import 'package:moneymgr_mobile/widgets/pdf_viewer.dart';
 | |
| 
 | |
| import '../utils/hooks.dart';
 | |
| 
 | |
| class ExpenseEditor extends HookConsumerWidget {
 | |
|   final Uint8List file;
 | |
|   final Future<void> Function(BaseExpenseInfo) onFinished;
 | |
|   final Function()? onRescan;
 | |
|   final Function()? onDelete;
 | |
|   final BaseExpenseInfo? initialData;
 | |
| 
 | |
|   const ExpenseEditor({
 | |
|     super.key,
 | |
|     required this.file,
 | |
|     required this.onFinished,
 | |
|     this.onRescan,
 | |
|     this.onDelete,
 | |
|     this.initialData,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final serverConfig = ref.watch(prefsProvider).requireValue.serverConfig()!;
 | |
| 
 | |
|     final labelController = useTextEditingController(text: initialData?.label);
 | |
|     final costController = useTextEditingController(
 | |
|       text: initialData?.cost.toString(),
 | |
|     );
 | |
|     final timeController = useState(initialData?.time ?? DateTime.now());
 | |
| 
 | |
|     final (:pending, :snapshot, :hasError) = useAsyncTask();
 | |
| 
 | |
|     // Force refresh of field if required
 | |
|     final previousData = useState<BaseExpenseInfo?>(null);
 | |
|     if (initialData != previousData.value) {
 | |
|       previousData.value = initialData;
 | |
|       labelController.text = initialData?.label ?? "";
 | |
|       costController.text = initialData?.cost.toString() ?? "";
 | |
|       timeController.value = initialData?.time ?? DateTime.now();
 | |
|     }
 | |
| 
 | |
|     // Clear cost value
 | |
|     handleClearCost() {
 | |
|       costController.text = "";
 | |
|     }
 | |
| 
 | |
|     // Pick a new date
 | |
|     handlePickDate() async {
 | |
|       final date = await showDatePicker(
 | |
|         context: context,
 | |
|         firstDate: DateTime(2000),
 | |
|         lastDate: DateTime(2099),
 | |
|         initialDate: timeController.value,
 | |
|       );
 | |
|       if (date != null) timeController.value = date;
 | |
|     }
 | |
| 
 | |
|     // Save expense
 | |
|     handleSubmit() async {
 | |
|       if (costController.text.isEmpty) {
 | |
|         context.showTextSnackBar("Please specify expense cost!");
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       pending.value = onFinished(
 | |
|         BaseExpenseInfo(
 | |
|           label: labelController.text.isEmpty ? null : labelController.text,
 | |
|           cost: double.tryParse(costController.text) ?? 0,
 | |
|           time: timeController.value,
 | |
|         ),
 | |
|       );
 | |
| 
 | |
|       // Reset screen after a scan
 | |
|       await pending.value;
 | |
|       labelController.text = "";
 | |
|       costController.text = "";
 | |
|     }
 | |
| 
 | |
|     // Cancel operation
 | |
|     handleRescan() async {
 | |
|       if (await confirm(
 | |
|             context,
 | |
|             content: Text("Do you really want to discard this expense?"),
 | |
|           ) &&
 | |
|           onRescan != null) {
 | |
|         onRescan!();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Delete expense
 | |
|     handleDelete() async {
 | |
|       if (await confirm(
 | |
|             context,
 | |
|             content: Text("Do you really want to delete this expense?"),
 | |
|           ) &&
 | |
|           onDelete != null) {
 | |
|         onDelete!();
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Open invoice in full screen
 | |
|     handleFullScreenInvoice() {
 | |
|       showDialog(
 | |
|         context: context,
 | |
|         builder: (c) => Scaffold(
 | |
|           appBar: AppBar(title: Text("Expense")),
 | |
|           body: SingleChildScrollView(
 | |
|             child: PDFViewer(pdfBytes: file, fit: BoxFit.fitWidth),
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return Scaffold(
 | |
|       appBar: AppBar(
 | |
|         title: Text("Expense info"),
 | |
|         actions: [
 | |
|           // Rescan expense
 | |
|           onRescan == null
 | |
|               ? Container()
 | |
|               : IconButton(
 | |
|                   onPressed: handleRescan,
 | |
|                   icon: Icon(Icons.restart_alt),
 | |
|                 ),
 | |
| 
 | |
|           // Delete expense
 | |
|           onDelete == null
 | |
|               ? Container()
 | |
|               : IconButton(onPressed: handleDelete, icon: Icon(Icons.delete)),
 | |
| 
 | |
|           // Submit
 | |
|           snapshot.connectionState == ConnectionState.waiting
 | |
|               ? CircularProgressIndicator()
 | |
|               : IconButton(
 | |
|                   onPressed: handleSubmit,
 | |
|                   icon: Icon(Icons.save),
 | |
|                   color: hasError ? Colors.red : null,
 | |
|                 ),
 | |
|         ],
 | |
|       ),
 | |
|       body: Column(
 | |
|         children: [
 | |
|           // Expense preview
 | |
|           Expanded(
 | |
|             child: GestureDetector(
 | |
|               onTap: handleFullScreenInvoice,
 | |
|               child: PDFViewer(pdfBytes: file, fit: BoxFit.contain),
 | |
|             ),
 | |
|           ),
 | |
| 
 | |
|           SizedBox(height: 10),
 | |
| 
 | |
|           // Cost
 | |
|           TextField(
 | |
|             controller: costController,
 | |
|             keyboardType: TextInputType.numberWithOptions(
 | |
|               decimal: true,
 | |
|               signed: false,
 | |
|             ),
 | |
|             decoration: InputDecoration(
 | |
|               labelText: 'Cost',
 | |
|               suffixIcon: IconButton(
 | |
|                 onPressed: handleClearCost,
 | |
|                 icon: const Icon(Icons.clear),
 | |
|               ),
 | |
|             ),
 | |
|             textInputAction: TextInputAction.done,
 | |
|           ),
 | |
| 
 | |
|           SizedBox(height: 10),
 | |
| 
 | |
|           // Date
 | |
|           TextField(
 | |
|             enabled: true,
 | |
|             readOnly: true,
 | |
|             controller: TextEditingController(
 | |
|               text: timeController.value.simpleDate,
 | |
|             ),
 | |
|             keyboardType: TextInputType.datetime,
 | |
|             decoration: InputDecoration(
 | |
|               labelText: 'Date',
 | |
|               suffixIcon: IconButton(
 | |
|                 onPressed: handlePickDate,
 | |
|                 icon: const Icon(Icons.date_range),
 | |
|               ),
 | |
|             ),
 | |
|           ),
 | |
| 
 | |
|           SizedBox(height: 10),
 | |
| 
 | |
|           // Label
 | |
|           TextField(
 | |
|             controller: labelController,
 | |
|             keyboardType: TextInputType.text,
 | |
|             decoration: const InputDecoration(labelText: 'Label'),
 | |
|             textInputAction: TextInputAction.done,
 | |
|             maxLength: serverConfig.constraints.inbox_entry_label.max,
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |