From 36c19eb6ff8a4f8c545dc9bdd6967735dec159b5 Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Thu, 21 May 2026 15:38:40 +0200 Subject: [PATCH 1/8] feat: add iOS shortcuts integration --- ios/Runner/AddWalletTransactionIntent.swift | 159 ++++++++++++++++++++ lib/pages/structure.dart | 41 ++++- lib/providers/transactions_provider.dart | 23 ++- 3 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 ios/Runner/AddWalletTransactionIntent.swift diff --git a/ios/Runner/AddWalletTransactionIntent.swift b/ios/Runner/AddWalletTransactionIntent.swift new file mode 100644 index 00000000..c4e0e060 --- /dev/null +++ b/ios/Runner/AddWalletTransactionIntent.swift @@ -0,0 +1,159 @@ +import AppIntents +import Foundation +import SQLite3 + +@available(iOS 16.0, *) +struct AddWalletTransactionIntent: AppIntent { + static var title: LocalizedStringResource = "Add Wallet Transaction" + static var description = IntentDescription( + "Adds an uncategorized Apple Wallet transaction to Sossoldi." + ) + static var openAppWhenRun = false + + @Parameter(title: "Amount") + var amount: String + + @Parameter(title: "Merchant") + var merchant: String + + @Parameter(title: "Card") + var card: String + + init() {} + + init(amount: String, merchant: String, card: String) { + self.amount = amount + self.merchant = merchant + self.card = card + } + + func perform() async throws -> some IntentResult { + _ = card + try WalletTransactionStore.insert( + amount: try WalletTransactionAmountParser.parse(amount), + merchant: merchant.trimmingCharacters(in: .whitespacesAndNewlines) + ) + return .result() + } +} + +private enum WalletTransactionAmountParser { + static func parse(_ value: String) throws -> Double { + let allowedCharacters = CharacterSet(charactersIn: "0123456789,.-") + let filtered = value.unicodeScalars + .filter { allowedCharacters.contains($0) } + .map(String.init) + .joined() + .replacingOccurrences(of: ",", with: ".") + + let decimalSeparatorCount = filtered.filter { $0 == "." }.count + let normalized = decimalSeparatorCount > 1 + ? removeThousandsSeparators(from: filtered) + : filtered + + guard let amount = Double(normalized), amount.isFinite else { + throw WalletTransactionStoreError.invalidAmount(value) + } + + return abs(amount) + } + + private static func removeThousandsSeparators(from value: String) -> String { + guard let lastSeparator = value.lastIndex(of: ".") else { + return value + } + + return value.enumerated().compactMap { offset, character in + let index = value.index(value.startIndex, offsetBy: offset) + return character == "." && index != lastSeparator ? nil : character + }.map(String.init).joined() + } +} + +private enum WalletTransactionStore { + static func insert(amount: Double, merchant: String) throws { + let databaseURL = try sossoldiDatabaseURL() + guard FileManager.default.fileExists(atPath: databaseURL.path) else { + throw WalletTransactionStoreError.databaseNotFound + } + + var database: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &database, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { + let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown SQLite error" + sqlite3_close(database) + throw WalletTransactionStoreError.openFailed(message) + } + defer { sqlite3_close(database) } + + let now = iso8601Now() + let note = merchant.isEmpty ? "Apple Pay transaction" : merchant + let sql = """ + INSERT INTO "transaction" + (date, amount, type, note, idCategory, idBankAccount, idBankAccountTransfer, recurring, idRecurringTransaction, createdAt, updatedAt) + VALUES + (?, ?, 'OUT', ?, NULL, 0, NULL, 0, NULL, ?, ?) + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { + let message = String(cString: sqlite3_errmsg(database)) + throw WalletTransactionStoreError.prepareFailed(message) + } + defer { sqlite3_finalize(statement) } + + bindText(now, to: statement, at: 1) + sqlite3_bind_double(statement, 2, amount) + bindText(note, to: statement, at: 3) + bindText(now, to: statement, at: 4) + bindText(now, to: statement, at: 5) + + guard sqlite3_step(statement) == SQLITE_DONE else { + let message = String(cString: sqlite3_errmsg(database)) + throw WalletTransactionStoreError.insertFailed(message) + } + } + + private static func sossoldiDatabaseURL() throws -> URL { + let documentsURL = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) + return documentsURL.appendingPathComponent("sossoldi.db") + } + + private static func iso8601Now() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: Date()) + } + + private static func bindText(_ value: String, to statement: OpaquePointer?, at index: Int32) { + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, index, value, -1, transient) + } +} + +private enum WalletTransactionStoreError: LocalizedError { + case invalidAmount(String) + case databaseNotFound + case openFailed(String) + case prepareFailed(String) + case insertFailed(String) + + var errorDescription: String? { + switch self { + case .invalidAmount(let value): + return "Unable to read the Wallet transaction amount: \(value)" + case .databaseNotFound: + return "Open Sossoldi once before running this shortcut." + case .openFailed(let message): + return "Unable to open the Sossoldi database: \(message)" + case .prepareFailed(let message): + return "Unable to prepare the Wallet transaction insert: \(message)" + case .insertFailed(let message): + return "Unable to add the Wallet transaction: \(message)" + } + } +} diff --git a/lib/pages/structure.dart b/lib/pages/structure.dart index dc3099c9..289a4eb5 100644 --- a/lib/pages/structure.dart +++ b/lib/pages/structure.dart @@ -36,6 +36,24 @@ class _StructureState extends ConsumerState { ]; int selectedIndex = 0; + late final _AppResumeObserver _resumeObserver; + + @override + void initState() { + super.initState(); + _resumeObserver = _AppResumeObserver(_refreshTransactions); + WidgetsBinding.instance.addObserver(_resumeObserver); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(_resumeObserver); + super.dispose(); + } + + void _refreshTransactions() { + ref.read(transactionsProvider.notifier).filterTransactions(); + } @override Widget build(BuildContext context) { @@ -45,9 +63,8 @@ class _StructureState extends ConsumerState { // Prevent the fab moving up when the keyboard is opened resizeToAvoidBottomInset: false, appBar: AppBar( - backgroundColor: selectedIndex == 0 - ? Theme.of(context).colorScheme.tertiary - : null, + backgroundColor: + selectedIndex == 0 ? Theme.of(context).colorScheme.tertiary : null, title: switch (selectedIndex) { 0 => null, _ => Text(_pagesTitle.elementAt(selectedIndex)), @@ -87,8 +104,9 @@ class _StructureState extends ConsumerState { selectedFontSize: 8, unselectedFontSize: 8, currentIndex: selectedIndex, - onTap: (index) => - index != 2 ? setState(() => selectedIndex = index) : null, + onTap: + (index) => + index != 2 ? setState(() => selectedIndex = index) : null, items: [ BottomNavigationBarItem( icon: Icon(selectedIndex == 0 ? Icons.home : Icons.home_outlined), @@ -139,3 +157,16 @@ class _StructureState extends ConsumerState { ); } } + +class _AppResumeObserver extends WidgetsBindingObserver { + _AppResumeObserver(this.onResumed); + + final VoidCallback onResumed; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + onResumed(); + } + } +} diff --git a/lib/providers/transactions_provider.dart b/lib/providers/transactions_provider.dart index fcbf8182..96b6b90e 100644 --- a/lib/providers/transactions_provider.dart +++ b/lib/providers/transactions_provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../model/bank_account.dart'; @@ -272,17 +273,23 @@ class TransactionsNotifier extends _$TransactionsNotifier { Future transactionSelect(Transaction transaction) async { ref.read(selectedRecurringPayProvider.notifier).state = transaction.recurring; - if (transaction.type != TransactionType.transfer && - transaction.idCategory != null) { - ref.read(selectedCategoryProvider.notifier).state = ref - .read(categoriesProvider) - .value! - .firstWhere((element) => element.id == transaction.idCategory!); - } + final hasCategory = + transaction.type != TransactionType.transfer && + transaction.idCategory != null; + final category = + hasCategory + ? ref + .read(categoriesProvider) + .value! + .firstWhereOrNull( + (element) => element.id == transaction.idCategory!, + ) + : null; + ref.read(selectedCategoryProvider.notifier).state = category; ref.read(selectedBankAccountProvider.notifier).state = ref .read(accountsProvider) .value! - .firstWhere((element) => element.id == transaction.idBankAccount); + .firstWhereOrNull((element) => element.id == transaction.idBankAccount); ref .read(bankAccountTransferProvider.notifier) .state = transaction.type == TransactionType.transfer From bdf99e5df4595822894a43a2959b00370182b98e Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Fri, 3 Jul 2026 18:00:38 +0200 Subject: [PATCH 2/8] code formatting --- lib/pages/structure.dart | 10 +++++----- lib/providers/transactions_provider.dart | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/pages/structure.dart b/lib/pages/structure.dart index 289a4eb5..95f0fccf 100644 --- a/lib/pages/structure.dart +++ b/lib/pages/structure.dart @@ -63,8 +63,9 @@ class _StructureState extends ConsumerState { // Prevent the fab moving up when the keyboard is opened resizeToAvoidBottomInset: false, appBar: AppBar( - backgroundColor: - selectedIndex == 0 ? Theme.of(context).colorScheme.tertiary : null, + backgroundColor: selectedIndex == 0 + ? Theme.of(context).colorScheme.tertiary + : null, title: switch (selectedIndex) { 0 => null, _ => Text(_pagesTitle.elementAt(selectedIndex)), @@ -104,9 +105,8 @@ class _StructureState extends ConsumerState { selectedFontSize: 8, unselectedFontSize: 8, currentIndex: selectedIndex, - onTap: - (index) => - index != 2 ? setState(() => selectedIndex = index) : null, + onTap: (index) => + index != 2 ? setState(() => selectedIndex = index) : null, items: [ BottomNavigationBarItem( icon: Icon(selectedIndex == 0 ? Icons.home : Icons.home_outlined), diff --git a/lib/providers/transactions_provider.dart b/lib/providers/transactions_provider.dart index 96b6b90e..b54ca51a 100644 --- a/lib/providers/transactions_provider.dart +++ b/lib/providers/transactions_provider.dart @@ -276,15 +276,14 @@ class TransactionsNotifier extends _$TransactionsNotifier { final hasCategory = transaction.type != TransactionType.transfer && transaction.idCategory != null; - final category = - hasCategory - ? ref - .read(categoriesProvider) - .value! - .firstWhereOrNull( - (element) => element.id == transaction.idCategory!, - ) - : null; + final category = hasCategory + ? ref + .read(categoriesProvider) + .value! + .firstWhereOrNull( + (element) => element.id == transaction.idCategory!, + ) + : null; ref.read(selectedCategoryProvider.notifier).state = category; ref.read(selectedBankAccountProvider.notifier).state = ref .read(accountsProvider) From 6159eeab4afbb06ada075b62ad631066ffa1b168 Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Thu, 20 Aug 2026 22:29:38 +0200 Subject: [PATCH 3/8] add intent to xcode project --- ios/Runner.xcodeproj/project.pbxproj | 154 +++++++++++++++------------ 1 file changed, 88 insertions(+), 66 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 76f392c8..8dac66e1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,12 +10,13 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 60717B488D117C302D51AD39 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C46632FB1D4031F1C40B27AD /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8A4C10018D9E4E21A11B0001 /* AddWalletTransactionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4C10008D9E4E21A11B0001 /* AddWalletTransactionIntent.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B6B73809C111246E5879609E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BEA362A8E4D1D74E4515E2A /* Pods_RunnerTests.framework */; }; + CCB94D3DBCA18FDD2F548EBD /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB6A693A3F28D89F58226776 /* Pods_Runner.framework */; }; + EDD9C17A6797AD6583CD9948 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A8AEF816AA98F6D85C774285 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,18 +43,21 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 12C7AD89E8E781A4BFC539F1 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 069C64D101DDF6193397BBE6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 0CFF84BA83864E20D446C34E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 475C63D1361FE5A5162726B7 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 5BEA362A8E4D1D74E4515E2A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3FBA718511A31C139A0B135B /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 551FA4EBC172B229669901A1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 585940EDCC20ABD056224307 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7949C47B2BDF5778B7BB5D5A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 7E765B070269A0B32DDAEC06 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 8A4C10008D9E4E21A11B0001 /* AddWalletTransactionIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddWalletTransactionIntent.swift; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -61,10 +65,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - C03D3953861AE792AFE87028 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - C46632FB1D4031F1C40B27AD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - C5FD826CD5C9D80E8465B6AE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - E18EF08C0830BCDB09043362 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + A8AEF816AA98F6D85C774285 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AB6A693A3F28D89F58226776 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,7 +74,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B6B73809C111246E5879609E /* Pods_RunnerTests.framework in Frameworks */, + EDD9C17A6797AD6583CD9948 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -80,7 +82,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 60717B488D117C302D51AD39 /* Pods_Runner.framework in Frameworks */, + CCB94D3DBCA18FDD2F548EBD /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,12 +92,12 @@ 2B7BE9956F74F52F280FD060 /* Pods */ = { isa = PBXGroup; children = ( - C5FD826CD5C9D80E8465B6AE /* Pods-Runner.debug.xcconfig */, - E18EF08C0830BCDB09043362 /* Pods-Runner.release.xcconfig */, - 475C63D1361FE5A5162726B7 /* Pods-Runner.profile.xcconfig */, - C03D3953861AE792AFE87028 /* Pods-RunnerTests.debug.xcconfig */, - 7E765B070269A0B32DDAEC06 /* Pods-RunnerTests.release.xcconfig */, - 12C7AD89E8E781A4BFC539F1 /* Pods-RunnerTests.profile.xcconfig */, + 069C64D101DDF6193397BBE6 /* Pods-Runner.debug.xcconfig */, + 0CFF84BA83864E20D446C34E /* Pods-Runner.release.xcconfig */, + 7949C47B2BDF5778B7BB5D5A /* Pods-Runner.profile.xcconfig */, + 551FA4EBC172B229669901A1 /* Pods-RunnerTests.debug.xcconfig */, + 585940EDCC20ABD056224307 /* Pods-RunnerTests.release.xcconfig */, + 3FBA718511A31C139A0B135B /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -108,15 +110,6 @@ path = RunnerTests; sourceTree = ""; }; - 8110465CD8424934EFBDF0FA /* Frameworks */ = { - isa = PBXGroup; - children = ( - C46632FB1D4031F1C40B27AD /* Pods_Runner.framework */, - 5BEA362A8E4D1D74E4515E2A /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -136,7 +129,7 @@ 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 2B7BE9956F74F52F280FD060 /* Pods */, - 8110465CD8424934EFBDF0FA /* Frameworks */, + EAA62B2648D1CE14DFC2D249 /* Frameworks */, ); sourceTree = ""; }; @@ -158,12 +151,22 @@ 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 8A4C10008D9E4E21A11B0001 /* AddWalletTransactionIntent.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; + EAA62B2648D1CE14DFC2D249 /* Frameworks */ = { + isa = PBXGroup; + children = ( + AB6A693A3F28D89F58226776 /* Pods_Runner.framework */, + A8AEF816AA98F6D85C774285 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -171,7 +174,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - ADF665451747D4BB42C12B05 /* [CP] Check Pods Manifest.lock */, + 7936D32CDA20A9422D79F506 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 09967A90A294A1D6A4428C6B /* Frameworks */, @@ -190,15 +193,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 5CF2A4C07BB70DFF72346B2D /* [CP] Check Pods Manifest.lock */, + 02A14E6BEAF6E6328F3F6776 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 279A6739CEE2A8E4D77C3063 /* [CP] Embed Pods Frameworks */, - F6CDA2B012B301A90701A2B4 /* [CP] Copy Pods Resources */, + B3DBC117A48C7652A647E37B /* [CP] Embed Pods Frameworks */, + A503D785ABAC4706BA69FB58 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -270,21 +273,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 279A6739CEE2A8E4D77C3063 /* [CP] Embed Pods Frameworks */ = { + 02A14E6BEAF6E6328F3F6776 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { @@ -303,7 +311,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 5CF2A4C07BB70DFF72346B2D /* [CP] Check Pods Manifest.lock */ = { + 7936D32CDA20A9422D79F506 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -318,7 +326,7 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -340,43 +348,38 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - ADF665451747D4BB42C12B05 /* [CP] Check Pods Manifest.lock */ = { + A503D785ABAC4706BA69FB58 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; - F6CDA2B012B301A90701A2B4 /* [CP] Copy Pods Resources */ = { + B3DBC117A48C7652A647E37B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -394,6 +397,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8A4C10018D9E4E21A11B0001 /* AddWalletTransactionIntent.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); @@ -470,7 +474,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -486,27 +490,33 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 64B8374UU7; + DEVELOPMENT_TEAM = 2TVW8ZAJ49; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sossoldi; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.5; - PRODUCT_BUNDLE_IDENTIFIER = com.ripster.sossoldi; + PRODUCT_BUNDLE_IDENTIFIER = com.ripsters.sossoldi; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C03D3953861AE792AFE87028 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 551FA4EBC172B229669901A1 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -524,7 +534,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7E765B070269A0B32DDAEC06 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 585940EDCC20ABD056224307 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -540,7 +550,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 12C7AD89E8E781A4BFC539F1 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 3FBA718511A31C139A0B135B /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -601,7 +611,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -650,7 +660,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -668,21 +678,27 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 64B8374UU7; + DEVELOPMENT_TEAM = 2TVW8ZAJ49; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sossoldi; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.5; - PRODUCT_BUNDLE_IDENTIFIER = com.ripster.sossoldi; + PRODUCT_BUNDLE_IDENTIFIER = com.ripsters.sossoldi; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -694,20 +710,26 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 64B8374UU7; + DEVELOPMENT_TEAM = 2TVW8ZAJ49; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Sossoldi; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.5; - PRODUCT_BUNDLE_IDENTIFIER = com.ripster.sossoldi; + PRODUCT_BUNDLE_IDENTIFIER = com.ripsters.sossoldi; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; From c72abea41f376677af87eb30332d1217f0abdedb Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Wed, 9 Sep 2026 23:25:30 +0200 Subject: [PATCH 4/8] Fix deleting wallet intent transactions without account --- .../create_transaction/create_transaction_page.dart | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/pages/transactions/create_transaction/create_transaction_page.dart b/lib/pages/transactions/create_transaction/create_transaction_page.dart index 2538fd30..a2ad07bd 100644 --- a/lib/pages/transactions/create_transaction/create_transaction_page.dart +++ b/lib/pages/transactions/create_transaction/create_transaction_page.dart @@ -116,12 +116,11 @@ class _CreateTransactionPage extends ConsumerState { } void _refreshAccountAndNavigateBack() async { - ref - .read(accountsProvider.notifier) - .refreshAccount(ref.read(selectedBankAccountProvider)!) - .whenComplete(() { - if (mounted) Navigator.of(context).pop(); - }); + final selectedAccount = ref.read(selectedBankAccountProvider); + if (selectedAccount != null) { + await ref.read(accountsProvider.notifier).refreshAccount(selectedAccount); + } + if (mounted) Navigator.of(context).pop(); } void _createOrUpdateTransaction() async { From 7f605a6a01e3e3bb3883ebcd27cab6ec4c3d2b2e Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Wed, 9 Sep 2026 23:38:44 +0200 Subject: [PATCH 5/8] feat: enhance wallet transaction handling with improved amount parsing and type assignment --- ios/Runner/AddWalletTransactionIntent.swift | 66 +++++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/ios/Runner/AddWalletTransactionIntent.swift b/ios/Runner/AddWalletTransactionIntent.swift index c4e0e060..19f7ae72 100644 --- a/ios/Runner/AddWalletTransactionIntent.swift +++ b/ios/Runner/AddWalletTransactionIntent.swift @@ -29,8 +29,10 @@ struct AddWalletTransactionIntent: AppIntent { func perform() async throws -> some IntentResult { _ = card + let parsedAmount = try WalletTransactionAmountParser.parse(amount) try WalletTransactionStore.insert( - amount: try WalletTransactionAmountParser.parse(amount), + amount: abs(parsedAmount), + type: parsedAmount < 0 ? "IN" : "OUT", merchant: merchant.trimmingCharacters(in: .whitespacesAndNewlines) ) return .result() @@ -39,39 +41,50 @@ struct AddWalletTransactionIntent: AppIntent { private enum WalletTransactionAmountParser { static func parse(_ value: String) throws -> Double { - let allowedCharacters = CharacterSet(charactersIn: "0123456789,.-") - let filtered = value.unicodeScalars - .filter { allowedCharacters.contains($0) } - .map(String.init) - .joined() - .replacingOccurrences(of: ",", with: ".") - - let decimalSeparatorCount = filtered.filter { $0 == "." }.count - let normalized = decimalSeparatorCount > 1 - ? removeThousandsSeparators(from: filtered) - : filtered + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + for style in [NumberFormatter.Style.currency, .decimal] { + let formatter = NumberFormatter() + formatter.locale = .autoupdatingCurrent + formatter.numberStyle = style + formatter.isLenient = true + if let amount = formatter.number(from: trimmed)?.doubleValue, amount.isFinite { + return amount + } + } - guard let amount = Double(normalized), amount.isFinite else { - throw WalletTransactionStoreError.invalidAmount(value) + if let amount = Double(normalizedDecimalString(from: trimmed)), amount.isFinite { + return amount } - return abs(amount) + throw WalletTransactionStoreError.invalidAmount(value) } - private static func removeThousandsSeparators(from value: String) -> String { - guard let lastSeparator = value.lastIndex(of: ".") else { - return value + private static func normalizedDecimalString(from value: String) -> String { + let filtered = value + .unicodeScalars + .filter { CharacterSet(charactersIn: "0123456789,.-").contains($0) } + .map(String.init) + .joined() + + guard let lastDot = filtered.lastIndex(of: ".") else { + return filtered.replacingOccurrences(of: ",", with: ".") } - return value.enumerated().compactMap { offset, character in - let index = value.index(value.startIndex, offsetBy: offset) - return character == "." && index != lastSeparator ? nil : character - }.map(String.init).joined() + guard let lastComma = filtered.lastIndex(of: ",") else { + return filtered + } + + let decimalSeparator = lastDot > lastComma ? "." : "," + let groupingSeparator = decimalSeparator == "." ? "," : "." + + return filtered + .replacingOccurrences(of: groupingSeparator, with: "") + .replacingOccurrences(of: decimalSeparator, with: ".") } } private enum WalletTransactionStore { - static func insert(amount: Double, merchant: String) throws { + static func insert(amount: Double, type: String, merchant: String) throws { let databaseURL = try sossoldiDatabaseURL() guard FileManager.default.fileExists(atPath: databaseURL.path) else { throw WalletTransactionStoreError.databaseNotFound @@ -91,7 +104,7 @@ private enum WalletTransactionStore { INSERT INTO "transaction" (date, amount, type, note, idCategory, idBankAccount, idBankAccountTransfer, recurring, idRecurringTransaction, createdAt, updatedAt) VALUES - (?, ?, 'OUT', ?, NULL, 0, NULL, 0, NULL, ?, ?) + (?, ?, ?, ?, NULL, 0, NULL, 0, NULL, ?, ?) """ var statement: OpaquePointer? @@ -103,9 +116,10 @@ private enum WalletTransactionStore { bindText(now, to: statement, at: 1) sqlite3_bind_double(statement, 2, amount) - bindText(note, to: statement, at: 3) - bindText(now, to: statement, at: 4) + bindText(type, to: statement, at: 3) + bindText(note, to: statement, at: 4) bindText(now, to: statement, at: 5) + bindText(now, to: statement, at: 6) guard sqlite3_step(statement) == SQLITE_DONE else { let message = String(cString: sqlite3_errmsg(database)) From 3808d4f3cbfe266978b2ccbb5f902e9c027ddca1 Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Thu, 10 Sep 2026 08:33:19 +0200 Subject: [PATCH 6/8] swift intent no more write directly into sql hardcoding table and field names --- ios/Runner/AddWalletTransactionIntent.swift | 145 ++++-------------- lib/pages/structure.dart | 5 +- .../pending_wallet_transaction_importer.dart | 126 +++++++++++++++ 3 files changed, 159 insertions(+), 117 deletions(-) create mode 100644 lib/services/wallet/pending_wallet_transaction_importer.dart diff --git a/ios/Runner/AddWalletTransactionIntent.swift b/ios/Runner/AddWalletTransactionIntent.swift index 19f7ae72..1a9d1ec2 100644 --- a/ios/Runner/AddWalletTransactionIntent.swift +++ b/ios/Runner/AddWalletTransactionIntent.swift @@ -1,6 +1,5 @@ import AppIntents import Foundation -import SQLite3 @available(iOS 16.0, *) struct AddWalletTransactionIntent: AppIntent { @@ -29,112 +28,54 @@ struct AddWalletTransactionIntent: AppIntent { func perform() async throws -> some IntentResult { _ = card - let parsedAmount = try WalletTransactionAmountParser.parse(amount) - try WalletTransactionStore.insert( - amount: abs(parsedAmount), - type: parsedAmount < 0 ? "IN" : "OUT", - merchant: merchant.trimmingCharacters(in: .whitespacesAndNewlines) + try PendingWalletTransactionStore.append( + amount: amount, + merchant: merchant.trimmingCharacters(in: .whitespacesAndNewlines), + card: card.trimmingCharacters(in: .whitespacesAndNewlines) ) return .result() } } -private enum WalletTransactionAmountParser { - static func parse(_ value: String) throws -> Double { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - for style in [NumberFormatter.Style.currency, .decimal] { - let formatter = NumberFormatter() - formatter.locale = .autoupdatingCurrent - formatter.numberStyle = style - formatter.isLenient = true - if let amount = formatter.number(from: trimmed)?.doubleValue, amount.isFinite { - return amount - } - } - - if let amount = Double(normalizedDecimalString(from: trimmed)), amount.isFinite { - return amount - } - - throw WalletTransactionStoreError.invalidAmount(value) - } - - private static func normalizedDecimalString(from value: String) -> String { - let filtered = value - .unicodeScalars - .filter { CharacterSet(charactersIn: "0123456789,.-").contains($0) } - .map(String.init) - .joined() - - guard let lastDot = filtered.lastIndex(of: ".") else { - return filtered.replacingOccurrences(of: ",", with: ".") - } - - guard let lastComma = filtered.lastIndex(of: ",") else { - return filtered - } - - let decimalSeparator = lastDot > lastComma ? "." : "," - let groupingSeparator = decimalSeparator == "." ? "," : "." - - return filtered - .replacingOccurrences(of: groupingSeparator, with: "") - .replacingOccurrences(of: decimalSeparator, with: ".") - } +private struct PendingWalletTransaction: Encodable { + let amount: String + let merchant: String + let card: String + let createdAt: String } -private enum WalletTransactionStore { - static func insert(amount: Double, type: String, merchant: String) throws { - let databaseURL = try sossoldiDatabaseURL() - guard FileManager.default.fileExists(atPath: databaseURL.path) else { - throw WalletTransactionStoreError.databaseNotFound - } - - var database: OpaquePointer? - guard sqlite3_open_v2(databaseURL.path, &database, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { - let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Unknown SQLite error" - sqlite3_close(database) - throw WalletTransactionStoreError.openFailed(message) - } - defer { sqlite3_close(database) } - - let now = iso8601Now() - let note = merchant.isEmpty ? "Apple Pay transaction" : merchant - let sql = """ - INSERT INTO "transaction" - (date, amount, type, note, idCategory, idBankAccount, idBankAccountTransfer, recurring, idRecurringTransaction, createdAt, updatedAt) - VALUES - (?, ?, ?, ?, NULL, 0, NULL, 0, NULL, ?, ?) - """ +private enum PendingWalletTransactionStore { + private static let fileName = "pending_wallet_transactions.jsonl" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { - let message = String(cString: sqlite3_errmsg(database)) - throw WalletTransactionStoreError.prepareFailed(message) - } - defer { sqlite3_finalize(statement) } + static func append(amount: String, merchant: String, card: String) throws { + let pendingTransaction = PendingWalletTransaction( + amount: amount, + merchant: merchant, + card: card, + createdAt: iso8601Now() + ) - bindText(now, to: statement, at: 1) - sqlite3_bind_double(statement, 2, amount) - bindText(type, to: statement, at: 3) - bindText(note, to: statement, at: 4) - bindText(now, to: statement, at: 5) - bindText(now, to: statement, at: 6) + let data = try JSONEncoder().encode(pendingTransaction) + Data("\n".utf8) + let url = try pendingTransactionsURL() - guard sqlite3_step(statement) == SQLITE_DONE else { - let message = String(cString: sqlite3_errmsg(database)) - throw WalletTransactionStoreError.insertFailed(message) + if FileManager.default.fileExists(atPath: url.path) { + let handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } else { + try data.write(to: url, options: .atomic) } } - private static func sossoldiDatabaseURL() throws -> URL { + private static func pendingTransactionsURL() throws -> URL { let documentsURL = try FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false ) - return documentsURL.appendingPathComponent("sossoldi.db") + return documentsURL.appendingPathComponent(fileName) } private static func iso8601Now() -> String { @@ -142,32 +83,4 @@ private enum WalletTransactionStore { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.string(from: Date()) } - - private static func bindText(_ value: String, to statement: OpaquePointer?, at index: Int32) { - let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - sqlite3_bind_text(statement, index, value, -1, transient) - } -} - -private enum WalletTransactionStoreError: LocalizedError { - case invalidAmount(String) - case databaseNotFound - case openFailed(String) - case prepareFailed(String) - case insertFailed(String) - - var errorDescription: String? { - switch self { - case .invalidAmount(let value): - return "Unable to read the Wallet transaction amount: \(value)" - case .databaseNotFound: - return "Open Sossoldi once before running this shortcut." - case .openFailed(let message): - return "Unable to open the Sossoldi database: \(message)" - case .prepareFailed(let message): - return "Unable to prepare the Wallet transaction insert: \(message)" - case .insertFailed(let message): - return "Unable to add the Wallet transaction: \(message)" - } - } } diff --git a/lib/pages/structure.dart b/lib/pages/structure.dart index 95f0fccf..34dc96fc 100644 --- a/lib/pages/structure.dart +++ b/lib/pages/structure.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/settings_provider.dart'; import '../providers/transactions_provider.dart'; +import '../services/wallet/pending_wallet_transaction_importer.dart'; import '../ui/device.dart'; import 'graphs/graphs_page.dart'; import 'dashboard/dashboard_page.dart'; @@ -43,6 +44,7 @@ class _StructureState extends ConsumerState { super.initState(); _resumeObserver = _AppResumeObserver(_refreshTransactions); WidgetsBinding.instance.addObserver(_resumeObserver); + _refreshTransactions(); } @override @@ -51,7 +53,8 @@ class _StructureState extends ConsumerState { super.dispose(); } - void _refreshTransactions() { + void _refreshTransactions() async { + await PendingWalletTransactionImporter.importPending(ref); ref.read(transactionsProvider.notifier).filterTransactions(); } diff --git a/lib/services/wallet/pending_wallet_transaction_importer.dart b/lib/services/wallet/pending_wallet_transaction_importer.dart new file mode 100644 index 00000000..4986a7f2 --- /dev/null +++ b/lib/services/wallet/pending_wallet_transaction_importer.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +import '../../model/transaction.dart'; +import '../database/repositories/transactions_repository.dart'; + +class PendingWalletTransactionImporter { + static const _fileName = 'pending_wallet_transactions.jsonl'; + + static Future importPending(WidgetRef ref) async { + final file = await _pendingFile(); + if (!await file.exists()) return 0; + + final lines = await file.readAsLines(); + final remainingLines = []; + var importedCount = 0; + + for (final line in lines.where((line) => line.trim().isNotEmpty)) { + try { + final pendingTransaction = _PendingWalletTransaction.fromJson( + jsonDecode(line) as Map, + ); + await ref + .read(transactionsRepositoryProvider) + .insert(pendingTransaction.toTransaction()); + importedCount++; + } catch (_) { + remainingLines.add(line); + } + } + + if (remainingLines.isEmpty) { + await file.delete(); + } else { + await file.writeAsString('${remainingLines.join('\n')}\n'); + } + + return importedCount; + } + + static Future _pendingFile() async { + final directory = await getApplicationDocumentsDirectory(); + return File(path.join(directory.path, _fileName)); + } +} + +class _PendingWalletTransaction { + _PendingWalletTransaction({ + required this.amount, + required this.merchant, + required this.card, + required this.createdAt, + }); + + final String amount; + final String merchant; + final String card; + final DateTime createdAt; + + static _PendingWalletTransaction fromJson(Map json) { + return _PendingWalletTransaction( + amount: json['amount'] as String, + merchant: json['merchant'] as String? ?? '', + card: json['card'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + Transaction toTransaction() { + final parsedAmount = _parseAmount(amount); + return Transaction( + date: createdAt, + amount: parsedAmount.abs(), + type: parsedAmount < 0 ? TransactionType.income : TransactionType.expense, + note: merchant.isEmpty ? 'Apple Pay transaction' : merchant, + idBankAccount: 0, + idCategory: null, + recurring: false, + ); + } + + static num _parseAmount(String value) { + final trimmed = value.trim(); + final locale = PlatformDispatcher.instance.locale.toString(); + final formatters = [ + NumberFormat.currency(locale: locale), + NumberFormat.decimalPattern(locale), + ]; + + for (final formatter in formatters) { + try { + return formatter.parse(trimmed); + } catch (_) { + // Try the next formatter, then the normalized fallback. + } + } + + return num.parse(_normalizedDecimalString(trimmed)); + } + + static String _normalizedDecimalString(String value) { + final filtered = value.replaceAll(RegExp(r'[^0-9,.-]'), ''); + final lastDot = filtered.lastIndexOf('.'); + final lastComma = filtered.lastIndexOf(','); + + if (lastDot == -1) { + return filtered.replaceAll(',', '.'); + } + if (lastComma == -1) { + return filtered; + } + + final decimalSeparator = lastDot > lastComma ? '.' : ','; + final groupingSeparator = decimalSeparator == '.' ? ',' : '.'; + + return filtered + .replaceAll(groupingSeparator, '') + .replaceAll(decimalSeparator, '.'); + } +} From ced604560ff8b337749a31f60413b440fd2684df Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Thu, 10 Sep 2026 08:49:16 +0200 Subject: [PATCH 7/8] Handle unassigned transactions in accounts stat page --- .../widgets/accounts_pie_chart.dart | 16 ++-- .../transactions/widgets/accounts_tab.dart | 75 +++++++++++++++---- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/lib/pages/transactions/widgets/accounts_pie_chart.dart b/lib/pages/transactions/widgets/accounts_pie_chart.dart index 711fb693..4a808d1b 100644 --- a/lib/pages/transactions/widgets/accounts_pie_chart.dart +++ b/lib/pages/transactions/widgets/accounts_pie_chart.dart @@ -2,13 +2,12 @@ import 'package:fl_chart/fl_chart.dart'; import "package:flutter/material.dart"; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../constants/constants.dart'; import '../../../constants/style.dart'; import '../../../providers/transactions_provider.dart'; import '../../../ui/widgets/rounded_icon.dart'; -import '../../../model/bank_account.dart'; import '../../../providers/currency_provider.dart'; import '../../../ui/device.dart'; +import 'accounts_tab.dart'; class AccountsPieChart extends ConsumerWidget { const AccountsPieChart({ @@ -18,7 +17,7 @@ class AccountsPieChart extends ConsumerWidget { super.key, }); - final List accounts; + final List accounts; final Map amounts; final double total; @@ -37,11 +36,11 @@ class AccountsPieChart extends ConsumerWidget { centerSpaceRadius: 70, sectionsSpace: 0, borderData: FlBorderData(show: false), - sections: List.generate(amounts.values.length, (i) { + sections: List.generate(accounts.length, (i) { final isTouched = (i == selectedIndex); final radius = isTouched ? 30.0 : 25.0; return PieChartSectionData( - color: accountColorList[accounts[i].color], + color: accounts[i].color, value: 360 * amounts[accounts[i].id]!, radius: radius, showTitle: false, @@ -69,11 +68,8 @@ class AccountsPieChart extends ConsumerWidget { children: [ if (selectedIndex != -1) RoundedIcon( - icon: - accountIconList[accounts[selectedIndex].symbol] ?? - Icons.swap_horiz_rounded, - backgroundColor: - accountColorList[accounts[selectedIndex].color], + icon: accounts[selectedIndex].icon, + backgroundColor: accounts[selectedIndex].color, padding: const EdgeInsets.all(Sizes.sm), ), Text( diff --git a/lib/pages/transactions/widgets/accounts_tab.dart b/lib/pages/transactions/widgets/accounts_tab.dart index 7c0e6c3c..171deecf 100644 --- a/lib/pages/transactions/widgets/accounts_tab.dart +++ b/lib/pages/transactions/widgets/accounts_tab.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../constants/constants.dart'; +import '../../../constants/style.dart'; import '../../../ui/widgets/default_container.dart'; import '../../../ui/widgets/transaction_type_button.dart'; import '../../../model/bank_account.dart'; @@ -15,6 +16,8 @@ import 'panel_list_tile.dart'; class AccountsTab extends ConsumerWidget { const AccountsTab({super.key}); + static const int _unassignedAccountId = 0; + @override Widget build(BuildContext context, WidgetRef ref) { final accounts = ref.watch(accountsProvider); @@ -85,18 +88,14 @@ class AccountsTab extends ConsumerWidget { const TransactionTypeButton(), accounts.when( data: (data) { - List accountIncomeList = data - .where( - (account) => - accountToAmountIncome.containsKey(account.id), - ) - .toList(); - List accountExpenseList = data - .where( - (account) => - accountToAmountExpense.containsKey(account.id), - ) - .toList(); + final accountIncomeList = _buildAccountEntries( + accounts: data, + amounts: accountToAmountIncome, + ); + final accountExpenseList = _buildAccountEntries( + accounts: data, + amounts: accountToAmountExpense, + ); return transactionType == TransactionType.income ? accountIncomeList.isEmpty ? const SizedBox( @@ -134,6 +133,50 @@ class AccountsTab extends ConsumerWidget { ), ); } + + List _buildAccountEntries({ + required List accounts, + required Map amounts, + }) { + final entries = accounts + .where((account) => amounts.containsKey(account.id)) + .map( + (account) => AccountEntry( + id: account.id!, + name: account.name, + icon: accountIconList[account.symbol], + color: accountColorList[account.color], + ), + ) + .toList(); + + if (amounts.containsKey(_unassignedAccountId)) { + entries.add( + const AccountEntry( + id: _unassignedAccountId, + name: 'Unassigned', + icon: Icons.account_balance_wallet_outlined, + color: grey2, + ), + ); + } + + return entries; + } +} + +class AccountEntry { + const AccountEntry({ + required this.id, + required this.name, + required this.icon, + required this.color, + }); + + final int id; + final String name; + final IconData? icon; + final Color color; } class AccountSection extends StatelessWidget { @@ -145,7 +188,7 @@ class AccountSection extends StatelessWidget { super.key, }); - final List accountList; + final List accountList; final Map amounts; final double total; final Map> transactions; @@ -163,11 +206,11 @@ class AccountSection extends StatelessWidget { separatorBuilder: (context, index) => const SizedBox(height: Sizes.sm), itemBuilder: (context, index) { - BankAccount account = accountList[index]; + final account = accountList[index]; return PanelListTile( name: account.name, - color: accountColorList[account.color], - icon: accountIconList[account.symbol], + color: account.color, + icon: account.icon, transactions: transactions[account.id] ?? [], amount: amounts[account.id] ?? 0, percent: (amounts[account.id] ?? 0) / total * 100, From 384a17b87970250148b8001c5cfbfdd349e0686c Mon Sep 17 00:00:00 2001 From: "Mike V." Date: Thu, 10 Sep 2026 08:59:00 +0200 Subject: [PATCH 8/8] Handle unassigned transactions in categories stat page --- lib/providers/categories_provider.dart | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/lib/providers/categories_provider.dart b/lib/providers/categories_provider.dart index 4f54ebe1..d74a041d 100644 --- a/lib/providers/categories_provider.dart +++ b/lib/providers/categories_provider.dart @@ -367,6 +367,7 @@ class ParentCategoryWithSubcategoriesData { Future> categoryWithSubcategoriesData( Ref ref, ) async { + const uncategorizedCategoryId = 0; final trnscType = ref.watch(selectedTransactionTypeProvider); final categories = ref.watch(categoriesProvider).value ?? []; final parentCategories = ref.watch(allParentCategoriesProvider).value ?? []; @@ -419,5 +420,33 @@ Future> categoryWithSubcategoriesData( } } + final uncategorizedTransactions = transactions + .where((trnsc) => trnsc.type == trnscType && trnsc.idCategory == null) + .toList(); + final uncategorizedTotal = uncategorizedTransactions.fold( + 0, + (previousValue, trnsc) => previousValue + trnsc.amount, + ); + + if (uncategorizedTotal != 0) { + result.add( + ParentCategoryWithSubcategoriesData( + parentCategory: CategoryTransaction( + id: uncategorizedCategoryId, + name: 'Uncategorized', + type: trnscType.categoryType!, + symbol: 'question_mark', + color: 0, + order: result.length, + ), + subcategories: const {}, + transactions: uncategorizedTransactions, + total: trnscType == TransactionType.expense + ? -uncategorizedTotal + : uncategorizedTotal, + ), + ); + } + return result; }