LCOV - code coverage report
Current view: top level - lib/pages/settings/widgets - import_tab.dart Coverage Total Hit
Test: lcov.info Lines: 48.6 % 179 87
Test Date: 2026-11-14 15:04:37 Functions: - 0 0

            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              : }
        

Generated by: LCOV version 2.0-1