Line data Source code
1 : // Copyright 2023 The terCAD team. All rights reserved.
2 : // Use of this source code is governed by a CC BY-NC-ND 4.0 license that can be found in the LICENSE file.
3 :
4 : import 'package:app_finance/_classes/herald/app_design.dart';
5 : import 'package:app_finance/_classes/storage/app_data.dart';
6 : import 'package:app_finance/_classes/herald/app_locale.dart';
7 : import 'package:app_finance/_classes/storage/file_parser.dart';
8 : import 'package:app_finance/_classes/storage/file_picker.dart';
9 : import 'package:app_finance/_classes/storage/statement_parser.dart';
10 : import 'package:app_finance/_classes/storage/transaction_log.dart';
11 : import 'package:app_finance/_classes/controller/focus_controller.dart';
12 : import 'package:app_finance/_classes/storage/transaction_log/export_excel.dart';
13 : import 'package:app_finance/design/form/list_selector_item.dart';
14 : import 'package:app_finance/_classes/structure/interface_app_data.dart';
15 : import 'package:app_finance/_classes/controller/date_format_helper.dart';
16 : import 'package:app_finance/_classes/storage/app_preferences.dart';
17 : import 'package:app_finance/_configs/theme_helper.dart';
18 : import 'package:app_finance/_ext/build_context_ext.dart';
19 : import 'package:app_finance/design/form/list_selector.dart';
20 : import 'package:app_finance/design/form/simple_input.dart';
21 : import 'package:app_finance/design/generic/loading_widget.dart';
22 : import 'package:app_finance/design/wrapper/input_wrapper.dart';
23 : import 'package:app_finance/pages/settings/widgets/recover_tab/date_time_helper_widget.dart';
24 : import 'package:app_finance/design/wrapper/single_scroll_wrapper.dart';
25 : import 'package:material_ui/material_ui.dart';
26 : import 'package:flutter_currency_picker/flutter_currency_picker.dart';
27 : import 'package:provider/provider.dart';
28 :
29 : class ImportTab extends StatefulWidget {
30 2 : const ImportTab({
31 : super.key,
32 : });
33 :
34 1 : @override
35 1 : ImportTabState createState() => ImportTabState();
36 : }
37 :
38 : class ImportTabState extends State<ImportTab> {
39 : late FocusController focus;
40 : late AppData state;
41 : List<List<dynamic>>? fileContent;
42 : StringBuffer errorMessage = StringBuffer();
43 : List<String> columnMap = [];
44 : String? fileFormat;
45 : int pending = 0;
46 : bool isLoading = false;
47 : final dateFormat = TextEditingController(text: 'M/d/yyyy HH:mm');
48 :
49 1 : @override
50 : void initState() {
51 2 : focus = FocusController();
52 1 : super.initState();
53 : }
54 :
55 1 : @override
56 : void dispose() {
57 2 : focus.dispose();
58 2 : dateFormat.dispose();
59 1 : super.dispose();
60 : }
61 :
62 2 : late Map<String, String?> attrValue = {
63 1 : FileParser.attrAccountName: AppPreferences.get(AppPreferences.prefAccount),
64 1 : FileParser.attrCategoryName: AppPreferences.get(AppPreferences.prefBudget),
65 1 : FileParser.attrBillCurrency: AppPreferences.get(AppPreferences.prefCurrency),
66 2 : FileParser.attrBillType: AppLocale.labels.bill,
67 : };
68 :
69 0 : Future<void> pickFile(List<String> ext) async {
70 : try {
71 0 : setState(() => errorMessage.clear());
72 0 : final picker = FilePicker(ext);
73 0 : final content = await picker.pickFile();
74 0 : if (!mounted) {
75 : return;
76 : }
77 0 : setState(() {
78 0 : fileContent = content;
79 0 : fileFormat = ext.first;
80 0 : pending = picker.pending;
81 0 : columnMap = picker.columnMap;
82 0 : if (StatementParser.isStatement(ext.first)) {
83 0 : dateFormat.text = StatementParser.dateFormat;
84 0 : } else if (columnMap.contains(FileParser.attrBillDate)) {
85 0 : int index = columnMap.indexOf(FileParser.attrBillDate);
86 0 : dateFormat.text = DateFormatHelper().detectFormat([fileContent!.last[index]], AppLocale.code);
87 : }
88 : });
89 : } catch (e) {
90 0 : if (!mounted) {
91 : return;
92 : }
93 0 : setState(() => errorMessage.writeln(e.toString()));
94 : }
95 : }
96 :
97 1 : Future<void> parseFile() async {
98 2 : state.isLoading = true;
99 0 : fnSearch(AppDataType type, String value) => state.getList(type).where((e) => e.title == value).firstOrNull;
100 0 : fnAdd(InterfaceAppData item) {
101 0 : item = state.add(item);
102 0 : TransactionLog.save(item);
103 : return item;
104 : }
105 :
106 2 : final parser = FileParser(columnMap: columnMap, search: fnSearch, add: fnAdd);
107 1 : final def = {
108 2 : FileParser.attrAccountName: attrValue[FileParser.attrAccountName],
109 2 : FileParser.attrCategoryName: attrValue[FileParser.attrCategoryName],
110 2 : FileParser.attrBillCurrency: attrValue[FileParser.attrBillCurrency],
111 2 : FileParser.defDateFormat: dateFormat.text,
112 : };
113 :
114 2 : final isStatement = StatementParser.isStatement(fileFormat ?? '');
115 : var added = 0;
116 : var skipped = 0;
117 4 : for (int i = 1; i < (fileContent?.length ?? 0); i++) {
118 : try {
119 3 : dynamic newItem = await parser.parseFileLine(fileContent![i], def);
120 4 : if (isStatement && newItem.uuid != null && state.getByUuid(newItem.uuid) != null) {
121 1 : skipped++;
122 : continue;
123 : }
124 3 : newItem = state.add(newItem, newItem.uuid);
125 1 : TransactionLog.save(newItem);
126 1 : added++;
127 : } catch (e) {
128 0 : if (!mounted) {
129 : return;
130 : }
131 0 : setState(() => errorMessage.writeln('[$i / ${fileContent?.length}] ${e.toString()}.'));
132 : }
133 : }
134 2 : await state.restate();
135 1 : if (!mounted) {
136 : return;
137 : }
138 2 : setState(() {
139 : if (isStatement) {
140 5 : errorMessage.writeln(AppLocale.labels.integrationImported(added, pending, skipped));
141 : }
142 1 : fileContent = null;
143 1 : fileFormat = null;
144 : });
145 : }
146 :
147 0 : Future<void> exportFile() async {
148 : try {
149 0 : final file = ExportExcel(state);
150 0 : await file.exportAll();
151 : } catch (e) {
152 0 : if (!mounted) {
153 : return;
154 : }
155 0 : setState(() => errorMessage.writeln(e.toString()));
156 : }
157 : }
158 :
159 1 : Future<void> wrapCall(Future<void> Function() callback) async {
160 2 : setState(() {
161 1 : isLoading = true;
162 2 : errorMessage.clear();
163 : });
164 1 : await callback();
165 1 : await Future.delayed(const Duration(seconds: 1));
166 1 : if (!mounted) {
167 : return;
168 : }
169 2 : setState(() {
170 1 : isLoading = false;
171 2 : String isFinished = AppLocale.labels.processIsFinished;
172 2 : errorMessage.write(isFinished);
173 3 : if (errorMessage.toString() == isFinished) {
174 0 : Future.delayed(const Duration(seconds: 2), () {
175 0 : if (!mounted) {
176 : return;
177 : }
178 0 : setState(() => errorMessage.clear());
179 : });
180 : }
181 : });
182 : }
183 :
184 1 : @override
185 : Widget build(BuildContext context) {
186 1 : final indent = ThemeHelper.getIndent(2);
187 1 : final width = ThemeHelper.getWidth(context, 12);
188 1 : final textTheme = context.textTheme;
189 1 : final ColorScheme colorScheme = context.colorScheme;
190 :
191 2 : return Consumer<AppData>(builder: (context, appState, _) {
192 1 : state = appState;
193 1 : return SingleScrollWrapper(
194 1 : controller: focus,
195 1 : child: Padding(
196 1 : padding: EdgeInsets.all(indent),
197 1 : child: Column(
198 1 : crossAxisAlignment: AppDesign.getAlignment(),
199 1 : children: [
200 : ThemeHelper.hIndent2x,
201 3 : if (errorMessage.toString() != '')
202 6 : Text(errorMessage.toString(), style: textTheme.headlineMedium?.copyWith(color: colorScheme.error)),
203 2 : if (isLoading) ...[
204 2 : SizedBox(height: indent * 6),
205 2 : LoadingWidget(isLoading: isLoading),
206 1 : ] else if (fileContent != null) ...[
207 0 : ...List<Widget>.generate(fileContent!.first.length, (index) {
208 0 : return Column(
209 0 : crossAxisAlignment: AppDesign.getAlignment(),
210 0 : children: [
211 : ThemeHelper.hIndent2x,
212 0 : Text(
213 0 : AppLocale.labels.columnMap(fileContent!.first[index]),
214 0 : style: textTheme.bodyLarge,
215 : ),
216 0 : ListSelector<ListSelectorItem>(
217 0 : options: FileParser.getMappingTypes(),
218 0 : value: ListSelectorItem(id: columnMap[index], name: ''),
219 0 : hintText: AppLocale.labels.columnMapTooltip(fileContent!.first[index]),
220 0 : setState: (value) => setState(() {
221 0 : columnMap[index] = value?.id ?? '';
222 0 : if (columnMap[index] == FileParser.attrBillDate) {
223 0 : dateFormat.text =
224 0 : DateFormatHelper().detectFormat([fileContent!.last[index]], AppLocale.code);
225 : }
226 : }),
227 : ),
228 : ],
229 : );
230 : }),
231 0 : const Divider(),
232 0 : if (!columnMap.contains(FileParser.attrAccountName)) ...[
233 : ThemeHelper.hIndent2x,
234 0 : InputWrapper(
235 : type: NamedInputType.accountSelector,
236 0 : title: AppLocale.labels.def('${AppLocale.labels.account}: ${AppLocale.labels.title}'),
237 0 : tooltip: AppLocale.labels.titleAccountTooltip,
238 0 : value: state.getByUuid(attrValue[FileParser.attrAccountName] ?? ''),
239 0 : onChange: (value) => setState(() => attrValue[FileParser.attrAccountName] = value?.uuid),
240 : width: width,
241 0 : state: state,
242 : ),
243 : ],
244 0 : if (!columnMap.contains(FileParser.attrCategoryName)) ...[
245 : ThemeHelper.hIndent2x,
246 0 : InputWrapper(
247 : type: NamedInputType.budgetSelector,
248 0 : title: AppLocale.labels.def('${AppLocale.labels.budget}: ${AppLocale.labels.title}'),
249 0 : tooltip: AppLocale.labels.titleBudgetTooltip,
250 0 : value: state.getByUuid(attrValue[FileParser.attrCategoryName] ?? ''),
251 0 : onChange: (value) => setState(() => attrValue[FileParser.attrCategoryName] = value?.uuid),
252 : width: width,
253 0 : state: state,
254 : ),
255 : ],
256 0 : if (!columnMap.contains(FileParser.attrBillType)) ...[
257 : ThemeHelper.hIndent2x,
258 0 : Text(
259 0 : AppLocale.labels.def('${AppLocale.labels.bill}: ${AppLocale.labels.billTypeTooltip}'),
260 0 : style: textTheme.bodyLarge,
261 : ),
262 0 : ListSelector<ListSelectorItem>(
263 0 : value: attrValue[FileParser.attrBillType] != null
264 0 : ? ListSelectorItem(id: attrValue[FileParser.attrBillType]!, name: '')
265 : : null,
266 0 : hintText: AppLocale.labels.billTypeTooltip,
267 0 : options: [
268 0 : ListSelectorItem(id: AppLocale.labels.bill, name: AppLocale.labels.bill),
269 0 : ListSelectorItem(id: AppLocale.labels.flowTypeInvoice, name: AppLocale.labels.flowTypeInvoice),
270 : ],
271 0 : setState: (value) => setState(() => attrValue[FileParser.attrBillType] = value?.id),
272 : ),
273 : ],
274 0 : if (!columnMap.contains(FileParser.attrBillCurrency)) ...[
275 : ThemeHelper.hIndent2x,
276 0 : InputWrapper.currency(
277 0 : title: AppLocale.labels.def('${AppLocale.labels.bill}: ${AppLocale.labels.currency}'),
278 0 : value: CurrencyProvider.find(attrValue[FileParser.attrBillCurrency]),
279 0 : onChange: (value) => setState(() => attrValue[FileParser.attrBillCurrency] = value?.code),
280 : ),
281 : ],
282 0 : Column(crossAxisAlignment: AppDesign.getAlignment(), children: [
283 : ThemeHelper.hIndent4x,
284 0 : Text(
285 0 : AppLocale.labels.dateFormat,
286 0 : style: textTheme.bodyLarge,
287 : ),
288 0 : SimpleInput(controller: dateFormat),
289 : const DateTimeHelperWidget(),
290 : ThemeHelper.hIndent4x,
291 0 : SizedBox(
292 : width: double.infinity,
293 0 : child: FloatingActionButton(
294 : heroTag: 'import_tab_parse',
295 0 : onPressed: () => wrapCall(parseFile),
296 0 : tooltip: AppLocale.labels.parseFile,
297 0 : child: Text(
298 0 : AppLocale.labels.parseFile,
299 : ),
300 : ),
301 : ),
302 : ]),
303 1 : ] else ...[
304 4 : ...List<Widget>.generate(FilePicker.fileFormats.length * 2, (index) {
305 2 : if (index % 2 == 0) {
306 : return ThemeHelper.hIndent2x;
307 : } else {
308 2 : final format = FilePicker.fileFormats[index ~/ 2];
309 1 : return SizedBox(
310 : width: double.infinity,
311 1 : child: FloatingActionButton(
312 1 : heroTag: 'import_tab_pick_$format',
313 0 : onPressed: () => wrapCall(() => pickFile([format])),
314 2 : tooltip: AppLocale.labels.pickFile(format),
315 1 : child: Text(
316 2 : AppLocale.labels.pickFile(format),
317 : ),
318 : ),
319 : );
320 : }
321 : }),
322 1 : ThemeHelper.hIndent2x,
323 1 : const Divider(),
324 1 : ThemeHelper.hIndent2x,
325 1 : SizedBox(
326 : width: double.infinity,
327 1 : child: FloatingActionButton(
328 : heroTag: 'export_tab_pick_xlsx',
329 0 : onPressed: () => wrapCall(() => exportFile()),
330 2 : tooltip: AppLocale.labels.exportFile('xlsx'),
331 1 : child: Text(
332 2 : AppLocale.labels.exportFile('xlsx'),
333 : ),
334 : ),
335 : ),
336 : ]
337 : ],
338 : ),
339 : ),
340 : );
341 : });
342 : }
343 : }
|