Compare commits
	
		
			25 Commits
		
	
	
		
			1.0.4
			...
			5a2a8cb615
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2a8cb615 | |||
| 5cf5fac8f4 | |||
| 8e143db354 | |||
| 1237c9706e | |||
| 1add0b4cfe | |||
| 6920d6d9b0 | |||
| 27e92660f1 | |||
| 743e5ba410 | |||
| 8039b1c807 | |||
| 9ef84ba63a | |||
| 56e5ae6629 | |||
| 4443131516 | |||
| 365d7589b1 | |||
| 23cc189e53 | |||
| 3098d12e8a | |||
| 0943104cc8 | |||
| 3beaba806a | |||
| 1788e7f184 | |||
| 71d32d72ef | |||
| 28f61a3099 | |||
| f61e3541fb | |||
| fb7891d913 | |||
| d9ede224cf | |||
| fc9334b20b | |||
| c4cbd7ec8b | 
							
								
								
									
										12
									
								
								moneymgr_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								moneymgr_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							@@ -87,7 +87,7 @@ dependencies = [
 | 
			
		||||
 "mime",
 | 
			
		||||
 "percent-encoding",
 | 
			
		||||
 "pin-project-lite",
 | 
			
		||||
 "rand 0.9.1",
 | 
			
		||||
 "rand 0.9.2",
 | 
			
		||||
 "sha1",
 | 
			
		||||
 "smallvec",
 | 
			
		||||
 "tokio",
 | 
			
		||||
@@ -2298,7 +2298,7 @@ dependencies = [
 | 
			
		||||
 "light-openid",
 | 
			
		||||
 "log",
 | 
			
		||||
 "mime_guess",
 | 
			
		||||
 "rand 0.9.1",
 | 
			
		||||
 "rand 0.9.2",
 | 
			
		||||
 "rust-embed",
 | 
			
		||||
 "rust-s3",
 | 
			
		||||
 "rust_xlsxwriter",
 | 
			
		||||
@@ -2741,9 +2741,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "rand"
 | 
			
		||||
version = "0.9.1"
 | 
			
		||||
version = "0.9.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
 | 
			
		||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "rand_chacha 0.9.0",
 | 
			
		||||
 "rand_core 0.9.3",
 | 
			
		||||
@@ -3232,9 +3232,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "serde_json"
 | 
			
		||||
version = "1.0.140"
 | 
			
		||||
version = "1.0.141"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
 | 
			
		||||
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "itoa",
 | 
			
		||||
 "memchr",
 | 
			
		||||
 
 | 
			
		||||
@@ -22,9 +22,9 @@ rust-s3 = "0.36.0-beta.2"
 | 
			
		||||
thiserror = "2.0.12"
 | 
			
		||||
tokio = "1.45.1"
 | 
			
		||||
futures-util = "0.3.31"
 | 
			
		||||
serde_json = "1.0.140"
 | 
			
		||||
serde_json = "1.0.141"
 | 
			
		||||
light-openid = "1.0.4"
 | 
			
		||||
rand = "0.9.1"
 | 
			
		||||
rand = "0.9.2"
 | 
			
		||||
ipnet = { version = "2.11.0", features = ["serde"] }
 | 
			
		||||
lazy-regex = "3.4.1"
 | 
			
		||||
jwt-simple = { version = "0.12.12", default-features = false, features = ["pure-rust"] }
 | 
			
		||||
 
 | 
			
		||||
@@ -126,6 +126,14 @@ pub struct AppConfig {
 | 
			
		||||
    /// Redis password
 | 
			
		||||
    #[clap(long, env, default_value = "secretredis")]
 | 
			
		||||
    redis_password: String,
 | 
			
		||||
 | 
			
		||||
    /// Application download URL
 | 
			
		||||
    #[clap(
 | 
			
		||||
        long,
 | 
			
		||||
        env,
 | 
			
		||||
        default_value = "https://gitea.communiquons.org/pierre/MoneyMgr/releases/download/latest/moneymgr_mobile_arm64-v8a.apk"
 | 
			
		||||
    )]
 | 
			
		||||
    pub apk_download_url: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
lazy_static::lazy_static! {
 | 
			
		||||
 
 | 
			
		||||
@@ -70,6 +70,7 @@ impl Default for ServerConstraints {
 | 
			
		||||
struct ServerConfig {
 | 
			
		||||
    auth_disabled: bool,
 | 
			
		||||
    oidc_provider_name: &'static str,
 | 
			
		||||
    apk_download_url: &'static str,
 | 
			
		||||
    accounts_types: &'static [AccountTypeDesc],
 | 
			
		||||
    constraints: ServerConstraints,
 | 
			
		||||
}
 | 
			
		||||
@@ -79,6 +80,7 @@ impl Default for ServerConfig {
 | 
			
		||||
        Self {
 | 
			
		||||
            auth_disabled: AppConfig::get().is_auth_disabled(),
 | 
			
		||||
            oidc_provider_name: AppConfig::get().openid_provider().name,
 | 
			
		||||
            apk_download_url: AppConfig::get().apk_download_url.as_str(),
 | 
			
		||||
            constraints: Default::default(),
 | 
			
		||||
            accounts_types: &ACCOUNT_TYPES,
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,45 +1,29 @@
 | 
			
		||||
import 'dart:io';
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:logging/logging.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/services/storage/expenses.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/services/storage/prefs.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/utils/ocr_utils.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/utils/pdf_utils.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/widgets/expense_editor.dart';
 | 
			
		||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
			
		||||
import 'package:scanbot_sdk/scanbot_sdk.dart';
 | 
			
		||||
import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart' hide IconButton, EdgeInsets;
 | 
			
		||||
 | 
			
		||||
part 'scan_screen.g.dart';
 | 
			
		||||
 | 
			
		||||
/// Scan a document & return generated PDF as byte file
 | 
			
		||||
@riverpod
 | 
			
		||||
Future<Uint8List?> _scanDocument(Ref ref) async {
 | 
			
		||||
  var configuration = DocumentScanningFlow(
 | 
			
		||||
    appearance: DocumentFlowAppearanceConfiguration(
 | 
			
		||||
      statusBarMode: StatusBarMode.DARK,
 | 
			
		||||
    ),
 | 
			
		||||
    cleanScanningSession: true,
 | 
			
		||||
    outputSettings: DocumentScannerOutputSettings(pagesScanLimit: 1),
 | 
			
		||||
    screens: DocumentScannerScreens(
 | 
			
		||||
      review: ReviewScreenConfiguration(enabled: false),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
  var documentResult = await ScanbotSdkUiV2.startDocumentScanner(configuration);
 | 
			
		||||
Future<(Uint8List?, BaseExpenseInfo?)> _scanDocument(Ref ref) async {
 | 
			
		||||
  final prefs = ref.watch(prefsProvider).requireValue;
 | 
			
		||||
 | 
			
		||||
  if (documentResult.status != OperationStatus.OK) {
 | 
			
		||||
    throw Exception("Scanner failed with status ${documentResult.status}");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Convert result to PDF
 | 
			
		||||
  var result = await ScanbotSdk.document.createPDFForDocument(
 | 
			
		||||
    PDFFromDocumentParams(
 | 
			
		||||
      documentID: documentResult.data!.uuid,
 | 
			
		||||
      pdfConfiguration: PdfConfiguration(),
 | 
			
		||||
    ),
 | 
			
		||||
  final pdf = await scanDocAsPDF();
 | 
			
		||||
  final img = await renderPdf(pdfBytes: pdf);
 | 
			
		||||
  final amount = await extractInfoFromBill(
 | 
			
		||||
    imgBuff: img,
 | 
			
		||||
    extractDates: !prefs.disableExtractDates(),
 | 
			
		||||
  );
 | 
			
		||||
  final pdfPath = result.pdfFileUri.replaceFirst("file://", "");
 | 
			
		||||
  return File(pdfPath).readAsBytes();
 | 
			
		||||
  return (pdf, amount);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ScanScreen extends HookConsumerWidget {
 | 
			
		||||
@@ -52,8 +36,8 @@ class ScanScreen extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    restartScan() async {
 | 
			
		||||
      try {
 | 
			
		||||
        final val = ref.refresh(_scanDocumentProvider);
 | 
			
		||||
        Logger.root.info("Load again startup result: $val");
 | 
			
		||||
        ref.invalidate(_scanDocumentProvider);
 | 
			
		||||
        Logger.root.info("Load again startup");
 | 
			
		||||
      } catch (e, s) {
 | 
			
		||||
        Logger.root.shout("Failed to try again startup loading! $e $s");
 | 
			
		||||
      }
 | 
			
		||||
@@ -62,12 +46,13 @@ class ScanScreen extends HookConsumerWidget {
 | 
			
		||||
    return Padding(
 | 
			
		||||
      padding: const EdgeInsets.all(8.0),
 | 
			
		||||
      child: switch (scanDocProvider) {
 | 
			
		||||
        AsyncData(:final value) when value != null => ExpenseEditor(
 | 
			
		||||
          file: value,
 | 
			
		||||
        AsyncData(:final value) when value.$1 != null => ExpenseEditor(
 | 
			
		||||
          file: value.$1!,
 | 
			
		||||
          initialData: value.$2,
 | 
			
		||||
          onFinished: (expense) async {
 | 
			
		||||
            await expenses.add(
 | 
			
		||||
              info: expense,
 | 
			
		||||
              fileContent: value,
 | 
			
		||||
              fileContent: value.$1!,
 | 
			
		||||
              fileMimeType: "application/pdf",
 | 
			
		||||
            );
 | 
			
		||||
            restartScan();
 | 
			
		||||
@@ -76,7 +61,7 @@ class ScanScreen extends HookConsumerWidget {
 | 
			
		||||
        ),
 | 
			
		||||
 | 
			
		||||
        // No data
 | 
			
		||||
        AsyncData(:final value) when value == null => ScanErrorScreen(
 | 
			
		||||
        AsyncData(:final value) when value.$1 == null => ScanErrorScreen(
 | 
			
		||||
          message: "No document scanned!",
 | 
			
		||||
          onTryAgain: restartScan,
 | 
			
		||||
        ),
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,11 @@ class SettingsScreen extends ConsumerWidget {
 | 
			
		||||
      ref.invalidate(prefsProvider);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleToggleDisableExtractDate(v) async {
 | 
			
		||||
      await prefs.setDisableExtractDates(v);
 | 
			
		||||
      ref.invalidate(prefsProvider);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(title: const Text('Settings')),
 | 
			
		||||
      body: ListView(
 | 
			
		||||
@@ -40,6 +45,14 @@ class SettingsScreen extends ConsumerWidget {
 | 
			
		||||
              "Do not start camera automatically on application startup",
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          SwitchListTile(
 | 
			
		||||
            value: prefs.disableExtractDates(),
 | 
			
		||||
            onChanged: handleToggleDisableExtractDate,
 | 
			
		||||
            title: Text("Do not extract dates"),
 | 
			
		||||
            subtitle: Text(
 | 
			
		||||
              "Do not attempt to extract dates from scanned expenses",
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          const Divider(),
 | 
			
		||||
          ListTile(
 | 
			
		||||
            leading: const Icon(Icons.info_outline),
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,14 @@ extension MoneyMgrSharedPreferences on SharedPreferencesWithCache {
 | 
			
		||||
    await setBool("startOnScansListScreen", start);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  bool disableExtractDates() {
 | 
			
		||||
    return getBool("disableExtractDates") ?? false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> setDisableExtractDates(bool disable) async {
 | 
			
		||||
    await setBool("disableExtractDates", disable);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ServerConfig? serverConfig() {
 | 
			
		||||
    final json = getString("serverConfig");
 | 
			
		||||
    if (json != null) return ServerConfig.fromJson(jsonDecode(json));
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										86
									
								
								moneymgr_mobile/lib/utils/ocr_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								moneymgr_mobile/lib/utils/ocr_utils.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,86 @@
 | 
			
		||||
import 'dart:math';
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
import 'dart:ui' as ui;
 | 
			
		||||
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
 | 
			
		||||
import 'package:logging/logging.dart';
 | 
			
		||||
import 'package:moneymgr_mobile/services/storage/expenses.dart';
 | 
			
		||||
 | 
			
		||||
/// Attempt to extract information from invoice image
 | 
			
		||||
Future<BaseExpenseInfo?> extractInfoFromBill({
 | 
			
		||||
  required Uint8List imgBuff,
 | 
			
		||||
  required bool extractDates,
 | 
			
		||||
}) async {
 | 
			
		||||
  final decodedImage = await decodeImageFromList(imgBuff);
 | 
			
		||||
 | 
			
		||||
  final byteData = await decodedImage.toByteData(
 | 
			
		||||
    format: ui.ImageByteFormat.rawRgba,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  final image = InputImage.fromBitmap(
 | 
			
		||||
    bitmap: byteData!.buffer.asUint8List(),
 | 
			
		||||
    width: decodedImage.width,
 | 
			
		||||
    height: decodedImage.height,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
 | 
			
		||||
  final extractionResult = await textRecognizer.processImage(image);
 | 
			
		||||
 | 
			
		||||
  Logger.root.fine("Expense text: ${extractionResult.text}");
 | 
			
		||||
 | 
			
		||||
  // Check for highestCost amount on invoice
 | 
			
		||||
  final costRegexp = RegExp(
 | 
			
		||||
    r'([0-9]+([ ]*(\\.|,)[ ]*[0-9]{1,2}){0,1})([ \\t\\n]*(EUR|eur|€)|E)',
 | 
			
		||||
    multiLine: true,
 | 
			
		||||
    caseSensitive: false,
 | 
			
		||||
  );
 | 
			
		||||
  var highestCost = 0.0;
 | 
			
		||||
  for (final match in costRegexp.allMatches(extractionResult.text)) {
 | 
			
		||||
    if (match.groupCount == 0) continue;
 | 
			
		||||
 | 
			
		||||
    // Process only numeric value
 | 
			
		||||
    final value = (match.group(1) ?? "").replaceAll(",", ".");
 | 
			
		||||
    highestCost = max(highestCost, double.tryParse(value) ?? 0.0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Check for highestCost amount on invoice
 | 
			
		||||
  final dateRegexp = RegExp(
 | 
			
		||||
    r'([0-3][0-9])(\/|-)([0-1][0-9])(\/|-)((20|)[0-9]{2})',
 | 
			
		||||
    multiLine: false,
 | 
			
		||||
    caseSensitive: false,
 | 
			
		||||
  );
 | 
			
		||||
  final currDate = DateTime.now();
 | 
			
		||||
  DateTime? newest;
 | 
			
		||||
  for (final match in dateRegexp.allMatches(extractionResult.text)) {
 | 
			
		||||
    if (match.groupCount < 6) continue;
 | 
			
		||||
 | 
			
		||||
    int year = int.tryParse(match.group(5)!) ?? currDate.year;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      final date = DateTime(
 | 
			
		||||
        year > 99 ? year : (2000 + year),
 | 
			
		||||
        int.tryParse(match.group(3)!) ?? currDate.month,
 | 
			
		||||
        int.tryParse(match.group(1)!) ?? currDate.day,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (newest == null) {
 | 
			
		||||
        newest = date;
 | 
			
		||||
      } else {
 | 
			
		||||
        newest = DateTime.fromMillisecondsSinceEpoch(
 | 
			
		||||
          max(newest.millisecondsSinceEpoch, date.millisecondsSinceEpoch),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e, s) {
 | 
			
		||||
      Logger.root.warning("Failed to parse date! $e$s");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return BaseExpenseInfo(
 | 
			
		||||
    label: null,
 | 
			
		||||
    cost: highestCost,
 | 
			
		||||
    time: extractDates && (newest?.isBefore(currDate) ?? false)
 | 
			
		||||
        ? newest!
 | 
			
		||||
        : currDate,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -6,14 +6,40 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:path/path.dart' as p;
 | 
			
		||||
import 'package:path_provider/path_provider.dart';
 | 
			
		||||
import 'package:pdf_image_renderer/pdf_image_renderer.dart';
 | 
			
		||||
import 'package:scanbot_sdk/scanbot_sdk.dart';
 | 
			
		||||
import 'package:scanbot_sdk/scanbot_sdk_ui_v2.dart' hide IconButton, EdgeInsets;
 | 
			
		||||
 | 
			
		||||
/// Scan document as PDF
 | 
			
		||||
Future<Uint8List> scanDocAsPDF() async {
 | 
			
		||||
  var configuration = DocumentScanningFlow(
 | 
			
		||||
    appearance: DocumentFlowAppearanceConfiguration(
 | 
			
		||||
      statusBarMode: StatusBarMode.DARK,
 | 
			
		||||
    ),
 | 
			
		||||
    cleanScanningSession: true,
 | 
			
		||||
    outputSettings: DocumentScannerOutputSettings(pagesScanLimit: 1),
 | 
			
		||||
    screens: DocumentScannerScreens(
 | 
			
		||||
      review: ReviewScreenConfiguration(enabled: false),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
  var documentResult = await ScanbotSdkUiV2.startDocumentScanner(configuration);
 | 
			
		||||
 | 
			
		||||
  if (documentResult.status != OperationStatus.OK) {
 | 
			
		||||
    throw Exception("Scanner failed with status ${documentResult.status}");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Convert result to PDF
 | 
			
		||||
  var result = await ScanbotSdk.document.createPDFForDocument(
 | 
			
		||||
    PDFFromDocumentParams(
 | 
			
		||||
      documentID: documentResult.data!.uuid,
 | 
			
		||||
      pdfConfiguration: PdfConfiguration(),
 | 
			
		||||
    ),
 | 
			
		||||
  );
 | 
			
		||||
  final pdfPath = result.pdfFileUri.replaceFirst("file://", "");
 | 
			
		||||
  return File(pdfPath).readAsBytes();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Render PDF to image bits
 | 
			
		||||
Future<Uint8List> renderPdf(
 | 
			
		||||
     {
 | 
			
		||||
      String? path,
 | 
			
		||||
      Uint8List? pdfBytes,
 | 
			
		||||
    }) async {
 | 
			
		||||
Future<Uint8List> renderPdf({String? path, Uint8List? pdfBytes}) async {
 | 
			
		||||
  assert(path != null || pdfBytes != null);
 | 
			
		||||
 | 
			
		||||
  // Create temporary file if required
 | 
			
		||||
@@ -62,4 +88,4 @@ Future<Uint8List> renderPdf(
 | 
			
		||||
      await File(path).delete();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -40,6 +40,20 @@ class ExpenseEditor extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
@@ -94,6 +108,19 @@ class ExpenseEditor extends HookConsumerWidget {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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"),
 | 
			
		||||
@@ -125,7 +152,10 @@ class ExpenseEditor extends HookConsumerWidget {
 | 
			
		||||
        children: [
 | 
			
		||||
          // Expense preview
 | 
			
		||||
          Expanded(
 | 
			
		||||
            child: PDFViewer(pdfBytes: file, fit: BoxFit.contain),
 | 
			
		||||
            child: GestureDetector(
 | 
			
		||||
              onTap: handleFullScreenInvoice,
 | 
			
		||||
              child: PDFViewer(pdfBytes: file, fit: BoxFit.contain),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          SizedBox(height: 10),
 | 
			
		||||
@@ -137,7 +167,13 @@ class ExpenseEditor extends HookConsumerWidget {
 | 
			
		||||
              decimal: true,
 | 
			
		||||
              signed: false,
 | 
			
		||||
            ),
 | 
			
		||||
            decoration: const InputDecoration(labelText: 'Cost'),
 | 
			
		||||
            decoration: InputDecoration(
 | 
			
		||||
              labelText: 'Cost',
 | 
			
		||||
              suffixIcon: IconButton(
 | 
			
		||||
                onPressed: handleClearCost,
 | 
			
		||||
                icon: const Icon(Icons.clear),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            textInputAction: TextInputAction.done,
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -488,6 +488,22 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "16.0.0"
 | 
			
		||||
  google_mlkit_commons:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: google_mlkit_commons
 | 
			
		||||
      sha256: "8f40fbac10685cad4715d11e6a0d86837d9ad7168684dfcad29610282a88e67a"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.11.0"
 | 
			
		||||
  google_mlkit_text_recognition:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: google_mlkit_text_recognition
 | 
			
		||||
      sha256: "96173ad4dd7fd06c660e22ac3f9e9f1798a517fe7e48bee68eeec83853224224"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.15.0"
 | 
			
		||||
  graphs:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
 
 | 
			
		||||
@@ -93,6 +93,9 @@ dependencies:
 | 
			
		||||
  # PDF renderer
 | 
			
		||||
  pdf_image_renderer: ^1.0.1
 | 
			
		||||
 | 
			
		||||
  # Text extraction
 | 
			
		||||
  google_mlkit_text_recognition: ^0.15.0
 | 
			
		||||
 | 
			
		||||
dev_dependencies:
 | 
			
		||||
  flutter_test:
 | 
			
		||||
    sdk: flutter
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2431
									
								
								moneymgr_web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2431
									
								
								moneymgr_web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -18,8 +18,8 @@
 | 
			
		||||
    "@mdi/react": "^1.6.1",
 | 
			
		||||
    "@mui/icons-material": "^7.1.2",
 | 
			
		||||
    "@mui/material": "^7.1.2",
 | 
			
		||||
    "@mui/x-charts": "^8.8.0",
 | 
			
		||||
    "@mui/x-data-grid": "^8.8.0",
 | 
			
		||||
    "@mui/x-charts": "^8.9.0",
 | 
			
		||||
    "@mui/x-data-grid": "^8.9.1",
 | 
			
		||||
    "@mui/x-date-pickers": "^8.8.0",
 | 
			
		||||
    "date-and-time": "^3.6.0",
 | 
			
		||||
    "dayjs": "^1.11.13",
 | 
			
		||||
@@ -32,12 +32,12 @@
 | 
			
		||||
    "ts-pattern": "^5.7.1"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@eslint/js": "^9.31.0",
 | 
			
		||||
    "@eslint/js": "^9.32.0",
 | 
			
		||||
    "@types/react": "^19.1.8",
 | 
			
		||||
    "@types/react-dom": "^19.1.6",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.6.0",
 | 
			
		||||
    "eslint": "^9.26.0",
 | 
			
		||||
    "eslint-plugin-react-dom": "^1.49.0",
 | 
			
		||||
    "eslint": "^9.31.0",
 | 
			
		||||
    "eslint-plugin-react-dom": "^1.52.3",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^5.2.0",
 | 
			
		||||
    "eslint-plugin-react-refresh": "^00.4.20",
 | 
			
		||||
    "eslint-plugin-react-x": "^1.52.3",
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,7 @@ import { APIClient } from "./ApiClient";
 | 
			
		||||
export interface ServerConfig {
 | 
			
		||||
  auth_disabled: boolean;
 | 
			
		||||
  oidc_provider_name: string;
 | 
			
		||||
  apk_download_url: string;
 | 
			
		||||
  accounts_types: AccountType[];
 | 
			
		||||
  constraints: ServerConstraints;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import { mdiApi, mdiCash } from "@mdi/js";
 | 
			
		||||
import Icon from "@mdi/react";
 | 
			
		||||
import AndroidIcon from "@mui/icons-material/Android";
 | 
			
		||||
import CloudDownloadIcon from "@mui/icons-material/CloudDownload";
 | 
			
		||||
import LogoutIcon from "@mui/icons-material/Logout";
 | 
			
		||||
import SettingsIcon from "@mui/icons-material/Settings";
 | 
			
		||||
@@ -10,6 +11,7 @@ import MenuItem from "@mui/material/MenuItem";
 | 
			
		||||
import Toolbar from "@mui/material/Toolbar";
 | 
			
		||||
import Typography from "@mui/material/Typography";
 | 
			
		||||
import * as React from "react";
 | 
			
		||||
import { ServerApi } from "../api/ServerApi";
 | 
			
		||||
import { useAuthInfo } from "./BaseAuthenticatedPage";
 | 
			
		||||
import { DarkThemeButton } from "./DarkThemeButtonWidget";
 | 
			
		||||
import { PublicModeButton } from "./PublicModeButtonWidget";
 | 
			
		||||
@@ -100,6 +102,18 @@ export function MoneyWebAppBar(p: {
 | 
			
		||||
              </MenuItem>
 | 
			
		||||
            </RouterLink>
 | 
			
		||||
 | 
			
		||||
            {/* APK download */}
 | 
			
		||||
            <RouterLink to={ServerApi.Config.apk_download_url}>
 | 
			
		||||
              <MenuItem>
 | 
			
		||||
                <ListItemIcon>
 | 
			
		||||
                  <AndroidIcon />
 | 
			
		||||
                </ListItemIcon>
 | 
			
		||||
                <ListItemText secondary="Scan expenses from your smartphone">
 | 
			
		||||
                  Mobile Application
 | 
			
		||||
                </ListItemText>
 | 
			
		||||
              </MenuItem>
 | 
			
		||||
            </RouterLink>
 | 
			
		||||
 | 
			
		||||
            <Divider />
 | 
			
		||||
 | 
			
		||||
            {/* Sign out */}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user