From 9769d15571e04417122afad0e5b89c89bbd59fc6 Mon Sep 17 00:00:00 2001 From: Vanxun Hank Date: Thu, 2 Apr 2026 15:02:22 +0800 Subject: [PATCH 1/2] feat: add OCR receipt scanning, voice logging, donut chart, and free signing support - OCR receipt scanning via Gemini API with camera/photo library - Voice transaction logging via long-press on + button (SpeechRecognizer + Gemini parsing) - Batch transaction import for both OCR and voice (multiple transactions at once) - Replace horizontal bar chart with donut pie chart in Insights view - Quick Log shortcut action and Settings shortcut page - Fix donut chart animation and uncategorized transaction calculation - Change Bundle ID to com.vanxun.dime for personal signing - Remove iCloud/CloudKit/Push Notifications for free Apple ID compatibility - Switch Core Data from NSPersistentCloudKitContainer to NSPersistentContainer Co-Authored-By: Claude Opus 4.6 --- app/BudgetIntent/BudgetIntent.entitlements | 14 +- .../BudgetIntentUI.entitlements | 14 +- app/ExpenditureWidget/BudgetWidget.swift | 4 +- app/ExpenditureWidget/InsightsWidget.swift | 8 +- app/ExpenditureWidget/LockBudgetWidget.swift | 4 +- app/ExpenditureWidget/MainBudgetWidget.swift | 4 +- app/ExpenditureWidget/NewExpenseWidget.swift | 2 +- .../RecentTransactionsWidget.swift | 6 +- .../TemplateTransactionWidget.swift | 4 +- app/ExpenditureWidgetExtension.entitlements | 14 +- app/dime.xcodeproj/project.pbxproj | 64 ++- app/dime/AppDelegate.swift | 97 +++- .../Components/Transactions/NumberPad.swift | 8 +- app/dime/ContentView.swift | 26 +- app/dime/Data/DataController.swift | 70 +-- app/dime/Info.plist | 14 +- .../Actions/BudgetInsightsIntent.swift | 4 +- .../Shortcuts/Actions/GetInsightsIntent.swift | 4 +- .../Actions/NewTransactionIntent.swift | 4 +- .../Shortcuts/Actions/QuickLogIntent.swift | 166 ++++++ app/dime/Shortcuts/ShortcutsProvider.swift | 5 + app/dime/Utilities/Constants.swift | 10 + app/dime/Utilities/GeminiService.swift | 339 ++++++++++++ app/dime/Utilities/ImagePickerView.swift | 48 ++ app/dime/Utilities/KeyboardHeightHelper.swift | 2 +- app/dime/Utilities/SpeechRecognizer.swift | 144 +++++ app/dime/Views/BudgetView.swift | 46 +- app/dime/Views/CategoryView.swift | 8 +- app/dime/Views/CustomTabBar.swift | 150 ++++- app/dime/Views/HomeView.swift | 31 ++ app/dime/Views/InsightsView.swift | 276 ++++++---- app/dime/Views/LogView.swift | 77 +-- app/dime/Views/NewBudgetView.swift | 8 +- .../SettingsAppIconView.swift | 2 +- .../SettingsAppearanceView.swift | 2 +- .../SettingsCurrencyView.swift | 2 +- .../Settings Subviews/SettingsEraseView.swift | 2 +- .../SettingsFeatureLabView.swift | 12 +- .../SettingsHapticsView.swift | 2 +- .../SettingsNotificationView.swift | 39 +- .../SettingsNumberEntryView.swift | 4 +- .../SettingsShortcutView.swift | 166 ++++++ .../SettingsUpcomingView.swift | 10 +- .../SettingsWeekStartView.swift | 4 +- app/dime/Views/Settings/SettingsView.swift | 43 +- app/dime/Views/Shapes/DonutSegment.swift | 41 ++ .../Views/SingleTransactionPhotoView.swift | 2 +- app/dime/Views/TemplateTransactionView.swift | 16 +- app/dime/Views/TransactionView.swift | 520 +++++++++++++++++- app/dime/Views/UnlockManager.swift | 2 +- app/dime/Views/UpdateSheet.swift | 2 +- app/dime/dime.entitlements | 14 +- 52 files changed, 2114 insertions(+), 446 deletions(-) create mode 100644 app/dime/Shortcuts/Actions/QuickLogIntent.swift create mode 100644 app/dime/Utilities/Constants.swift create mode 100644 app/dime/Utilities/GeminiService.swift create mode 100644 app/dime/Utilities/ImagePickerView.swift create mode 100644 app/dime/Utilities/SpeechRecognizer.swift create mode 100644 app/dime/Views/Settings/Settings Subviews/SettingsShortcutView.swift create mode 100644 app/dime/Views/Shapes/DonutSegment.swift diff --git a/app/BudgetIntent/BudgetIntent.entitlements b/app/BudgetIntent/BudgetIntent.entitlements index c4bb69e..e7f0db9 100644 --- a/app/BudgetIntent/BudgetIntent.entitlements +++ b/app/BudgetIntent/BudgetIntent.entitlements @@ -2,21 +2,9 @@ - aps-environment - development - com.apple.developer.icloud-container-identifiers - - iCloud.com.rafaelsoh.dime - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.rafaelsoh.dime com.apple.security.application-groups - group.com.rafaelsoh.dime + group.com.vanxun.dime diff --git a/app/BudgetIntentUI/BudgetIntentUI.entitlements b/app/BudgetIntentUI/BudgetIntentUI.entitlements index c4bb69e..e7f0db9 100644 --- a/app/BudgetIntentUI/BudgetIntentUI.entitlements +++ b/app/BudgetIntentUI/BudgetIntentUI.entitlements @@ -2,21 +2,9 @@ - aps-environment - development - com.apple.developer.icloud-container-identifiers - - iCloud.com.rafaelsoh.dime - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.rafaelsoh.dime com.apple.security.application-groups - group.com.rafaelsoh.dime + group.com.vanxun.dime diff --git a/app/ExpenditureWidget/BudgetWidget.swift b/app/ExpenditureWidget/BudgetWidget.swift index 2084924..fd72e85 100644 --- a/app/ExpenditureWidget/BudgetWidget.swift +++ b/app/ExpenditureWidget/BudgetWidget.swift @@ -382,12 +382,12 @@ struct BudgetWidgetEntryView: View { } struct WidgetBudgetDollarView: View { - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var amount: Double var red: Bool - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } diff --git a/app/ExpenditureWidget/InsightsWidget.swift b/app/ExpenditureWidget/InsightsWidget.swift index 7091560..a8e0995 100644 --- a/app/ExpenditureWidget/InsightsWidget.swift +++ b/app/ExpenditureWidget/InsightsWidget.swift @@ -70,7 +70,7 @@ struct InsightsProvider: IntentTimelineProvider { // calendar initialization var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 var dictionary = [Date: Double]() @@ -300,13 +300,13 @@ struct InsightsWidgetEntryView: View { let monthNumberArray = [1, 4, 7, 10] let monthNames: [Int: String] = [1: "Jan", 4: "Apr", 7: "Jul", 10: "Oct"] - @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstDayOfMonth: Int = 1 - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstDayOfMonth: Int = 1 + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var dollarText: String { if entry.amount < 10000 && showCents { diff --git a/app/ExpenditureWidget/LockBudgetWidget.swift b/app/ExpenditureWidget/LockBudgetWidget.swift index 3555cd4..17569cb 100644 --- a/app/ExpenditureWidget/LockBudgetWidget.swift +++ b/app/ExpenditureWidget/LockBudgetWidget.swift @@ -160,12 +160,12 @@ struct LockBudgetWidgetEntryView: View { return String(localized: "\(Int(round((entry.totalSpent / entry.budget.budgetAmount) * 100)))% spent") } - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var body: some View { switch widgetFamily { diff --git a/app/ExpenditureWidget/MainBudgetWidget.swift b/app/ExpenditureWidget/MainBudgetWidget.swift index ad62635..bf92a53 100644 --- a/app/ExpenditureWidget/MainBudgetWidget.swift +++ b/app/ExpenditureWidget/MainBudgetWidget.swift @@ -155,12 +155,12 @@ struct MainBudgetWidgetEntryView: View { return size > systemSmallWidgetText.widthOfRoundedString(size: 10, weight: .semibold) } - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var body: some View { switch widgetFamily { diff --git a/app/ExpenditureWidget/NewExpenseWidget.swift b/app/ExpenditureWidget/NewExpenseWidget.swift index 9d88a3a..def31f4 100644 --- a/app/ExpenditureWidget/NewExpenseWidget.swift +++ b/app/ExpenditureWidget/NewExpenseWidget.swift @@ -60,7 +60,7 @@ struct NewExpenseWidgetEntry: TimelineEntry { struct NewExpenseWidgetEntryView: View { let entry: NewExpenseProvider.Entry - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } diff --git a/app/ExpenditureWidget/RecentTransactionsWidget.swift b/app/ExpenditureWidget/RecentTransactionsWidget.swift index 795fb9e..6272a3f 100644 --- a/app/ExpenditureWidget/RecentTransactionsWidget.swift +++ b/app/ExpenditureWidget/RecentTransactionsWidget.swift @@ -137,12 +137,12 @@ struct ExpenditureWidgetEntryView: View { @Environment(\.widgetFamily) var widgetFamily let entry: Provider.Entry - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var inlineSubtitleText: String { switch entry.duration { @@ -596,7 +596,7 @@ struct RecentTransactionsDollarView: View { var net: Bool var bigger: Bool = false - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } diff --git a/app/ExpenditureWidget/TemplateTransactionWidget.swift b/app/ExpenditureWidget/TemplateTransactionWidget.swift index 050a514..b309b5f 100644 --- a/app/ExpenditureWidget/TemplateTransactionWidget.swift +++ b/app/ExpenditureWidget/TemplateTransactionWidget.swift @@ -92,8 +92,8 @@ // // let entry: TemplateTransactionWidgetProvider.Entry // -// @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true -// @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! +// @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true +// @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! // var currencySymbol: String { // return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! // } diff --git a/app/ExpenditureWidgetExtension.entitlements b/app/ExpenditureWidgetExtension.entitlements index c4bb69e..e7f0db9 100644 --- a/app/ExpenditureWidgetExtension.entitlements +++ b/app/ExpenditureWidgetExtension.entitlements @@ -2,21 +2,9 @@ - aps-environment - development - com.apple.developer.icloud-container-identifiers - - iCloud.com.rafaelsoh.dime - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.rafaelsoh.dime com.apple.security.application-groups - group.com.rafaelsoh.dime + group.com.vanxun.dime diff --git a/app/dime.xcodeproj/project.pbxproj b/app/dime.xcodeproj/project.pbxproj index 4598761..34e420e 100644 --- a/app/dime.xcodeproj/project.pbxproj +++ b/app/dime.xcodeproj/project.pbxproj @@ -69,7 +69,6 @@ 5358E9192AC885E3003E8CA9 /* AlertToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5358E9182AC885E3003E8CA9 /* AlertToast.swift */; }; 53620B8B28CAE3520058F216 /* LockBudgetWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53620B8A28CAE3520058F216 /* LockBudgetWidget.swift */; }; 5362CF322A92750800244744 /* ImportDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5362CF312A92750800244744 /* ImportDataView.swift */; }; - 536715ED28E1E9A3008461F2 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 536715EC28E1E9A3008461F2 /* StoreKit.framework */; }; 536A2A152A59C47400D81E02 /* Wiggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536A2A142A59C47400D81E02 /* Wiggle.swift */; }; 536A2A172A5A7C5D00D81E02 /* OffsetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536A2A162A5A7C5D00D81E02 /* OffsetHelper.swift */; }; 536CC386293106180081E401 /* LineGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536CC385293106180081E401 /* LineGraph.swift */; }; @@ -148,16 +147,23 @@ 53E832E72B0B067B000CBBA0 /* TransactionCategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E832E62B0B067B000CBBA0 /* TransactionCategoryPicker.swift */; }; 53EB930E28A65A570026BE28 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327D2B5287C6AFB00F76ADF /* Color.swift */; }; 53EB930F28A65B250026BE28 /* FontExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53B1D1A4289383B100E28062 /* FontExtension.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F6 /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F7 /* SpeechRecognizer.swift */; }; AE5B3D792AEE99E000AB364E /* NumberPad.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE5B3D782AEE99E000AB364E /* NumberPad.swift */; }; AEADEF6F2AE94DA3006EB614 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEADEF6E2AE94DA3006EB614 /* Toast.swift */; }; AEADEF712AE94DA8006EB614 /* BackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEADEF702AE94DA8006EB614 /* BackButton.swift */; }; AEADEF742AE94DC3006EB614 /* ToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEADEF722AE94DC3006EB614 /* ToolbarButton.swift */; }; AEADEF752AE94DC3006EB614 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEADEF732AE94DC3006EB614 /* Toolbar.swift */; }; AECE7F9E2AED1A6800B57267 /* SuggestedTransactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AECE7F9D2AED1A6800B57267 /* SuggestedTransactions.swift */; }; + C1A2B3D4E5F6A7B8C9D0E1F5 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */; }; + C1A2B3D4E5F6A7B8C9D0E1F6 /* GeminiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */; }; + C1A2B3D4E5F6A7B8C9D0E1F7 /* ImagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */; }; + D1E2F3A4B5C6D7E8F9A0B1C2 /* DonutSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6D7E8F9A0B1C3 /* DonutSegment.swift */; }; D63B204A2D8728C300CAFB20 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D63B20462D8728C300CAFB20 /* Localizable.strings */; }; D63B204B2D8728C300CAFB20 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D63B20482D8728C300CAFB20 /* Localizable.stringsdict */; }; D6A4DE202D61744400F7F751 /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4DE1F2D61744400F7F751 /* SwiftUIIntrospect */; }; D6A4DE242D61744400F7F751 /* SwiftUIIntrospect-Static in Frameworks */ = {isa = PBXBuildFile; productRef = D6A4DE232D61744400F7F751 /* SwiftUIIntrospect-Static */; }; + E1F2A3B4C5D6E7F8A9B0C1D2 /* QuickLogIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F2A3B4C5D6E7F8A9B0C1D3 /* QuickLogIntent.swift */; }; + E1F2A3B4C5D6E7F8A9B0C1D4 /* SettingsShortcutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F2A3B4C5D6E7F8A9B0C1D5 /* SettingsShortcutView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -265,7 +271,6 @@ 5358E9182AC885E3003E8CA9 /* AlertToast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertToast.swift; sourceTree = ""; }; 53620B8A28CAE3520058F216 /* LockBudgetWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockBudgetWidget.swift; sourceTree = ""; }; 5362CF312A92750800244744 /* ImportDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportDataView.swift; sourceTree = ""; }; - 536715EC28E1E9A3008461F2 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 536A2A142A59C47400D81E02 /* Wiggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wiggle.swift; sourceTree = ""; }; 536A2A162A5A7C5D00D81E02 /* OffsetHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetHelper.swift; sourceTree = ""; }; 536CC385293106180081E401 /* LineGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineGraph.swift; sourceTree = ""; }; @@ -325,14 +330,21 @@ 53E832E42B0A2CAF000CBBA0 /* InsightsSummaryBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightsSummaryBlock.swift; sourceTree = ""; }; 53E832E62B0B067B000CBBA0 /* TransactionCategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionCategoryPicker.swift; sourceTree = ""; }; 53EB931028A65F670026BE28 /* ExpenditureWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ExpenditureWidgetExtension.entitlements; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F7 /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; AE5B3D782AEE99E000AB364E /* NumberPad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberPad.swift; sourceTree = ""; }; AEADEF6E2AE94DA3006EB614 /* Toast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; AEADEF702AE94DA8006EB614 /* BackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackButton.swift; sourceTree = ""; }; AEADEF722AE94DC3006EB614 /* ToolbarButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToolbarButton.swift; sourceTree = ""; }; AEADEF732AE94DC3006EB614 /* Toolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; AECE7F9D2AED1A6800B57267 /* SuggestedTransactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedTransactions.swift; sourceTree = ""; }; + C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiService.swift; sourceTree = ""; }; + C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerView.swift; sourceTree = ""; }; + D1E2F3A4B5C6D7E8F9A0B1C3 /* DonutSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonutSegment.swift; sourceTree = ""; }; D63B20472D8728C300CAFB20 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizations/en.lproj/Localizable.strings; sourceTree = ""; }; D63B20492D8728C300CAFB20 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = Localizations/en.lproj/Localizable.stringsdict; sourceTree = ""; }; + E1F2A3B4C5D6E7F8A9B0C1D3 /* QuickLogIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickLogIntent.swift; sourceTree = ""; }; + E1F2A3B4C5D6E7F8A9B0C1D5 /* SettingsShortcutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsShortcutView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -364,7 +376,6 @@ 5327D2E6287C6CC300F76ADF /* Popovers in Frameworks */, 538D10752A5AB7E7008F1AFB /* IsScrolling in Frameworks */, 533A38F42AA49E5900F66957 /* Alamofire in Frameworks */, - 536715ED28E1E9A3008461F2 /* StoreKit.framework in Frameworks */, 53B1D1A8289A60D500E28062 /* CrookedText in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -490,6 +501,10 @@ 536A2A142A59C47400D81E02 /* Wiggle.swift */, 536A2A162A5A7C5D00D81E02 /* OffsetHelper.swift */, 533D1C3B2AE7BB6900894764 /* DynamicType.swift */, + C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */, + C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */, + C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */, + A1B2C3D4E5F6A7B8C9D0E1F7 /* SpeechRecognizer.swift */, ); path = Utilities; sourceTree = ""; @@ -550,6 +565,7 @@ 53B822A92A6D50CC000FC7C9 /* NewTransactionIntent.swift */, 536FA6C02A7E05C600C52490 /* GetInsightsIntent.swift */, 536FA6C22A7F941B00C52490 /* BudgetInsightsIntent.swift */, + E1F2A3B4C5D6E7F8A9B0C1D3 /* QuickLogIntent.swift */, ); path = Actions; sourceTree = ""; @@ -557,7 +573,6 @@ 5383D85C287D9A0100D1B9BA /* Frameworks */ = { isa = PBXGroup; children = ( - 536715EC28E1E9A3008461F2 /* StoreKit.framework */, 5383D85D287D9A0100D1B9BA /* CloudKit.framework */, 53B1D18B288ED7E400E28062 /* WidgetKit.framework */, 53B1D18D288ED7E500E28062 /* SwiftUI.framework */, @@ -589,6 +604,7 @@ isa = PBXGroup; children = ( 533D1C3D2AE8023B00894764 /* PencilShape.swift */, + D1E2F3A4B5C6D7E8F9A0B1C3 /* DonutSegment.swift */, 53B907982AE817EA0001F496 /* DonutSemicircle.swift */, 53B9079A2AE818710001F496 /* RoundedTriangle.swift */, 53B9079E2AE818B90001F496 /* Ring.swift */, @@ -633,6 +649,7 @@ 53D6BDD32AF73CA100F5728E /* SettingsFeatureLabView.swift */, 53D6BDD52AF73CE400F5728E /* SettingsUpcomingView.swift */, 53D6BDE02AF73FAB00F5728E /* SettingsSubviewModifier.swift */, + E1F2A3B4C5D6E7F8A9B0C1D5 /* SettingsShortcutView.swift */, ); path = "Settings Subviews"; sourceTree = ""; @@ -912,6 +929,7 @@ 5327D2D9287C6B5F00F76ADF /* HomeView.swift in Sources */, 53BC2A422A8B21930019E4A8 /* MainModel.xcdatamodeld in Sources */, 53D6BDCC2AF73BC800F5728E /* SettingsCurrencyView.swift in Sources */, + D1E2F3A4B5C6D7E8F9A0B1C2 /* DonutSegment.swift in Sources */, 53B907992AE817EA0001F496 /* DonutSemicircle.swift in Sources */, 53B9079F2AE818B90001F496 /* Ring.swift in Sources */, 5358E9192AC885E3003E8CA9 /* AlertToast.swift in Sources */, @@ -991,6 +1009,12 @@ 53A4147928B6625D008C30E7 /* AppDelegate.swift in Sources */, 5327D2DD287C6B5F00F76ADF /* InsightsView.swift in Sources */, 53A839BD28D35738006F227C /* SKProduct-LocalizedPrice.swift in Sources */, + C1A2B3D4E5F6A7B8C9D0E1F5 /* Constants.swift in Sources */, + C1A2B3D4E5F6A7B8C9D0E1F6 /* GeminiService.swift in Sources */, + C1A2B3D4E5F6A7B8C9D0E1F7 /* ImagePickerView.swift in Sources */, + E1F2A3B4C5D6E7F8A9B0C1D2 /* QuickLogIntent.swift in Sources */, + E1F2A3B4C5D6E7F8A9B0C1D4 /* SettingsShortcutView.swift in Sources */, + A1B2C3D4E5F6A7B8C9D0E1F6 /* SpeechRecognizer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1087,7 +1111,7 @@ CODE_SIGN_ENTITLEMENTS = BudgetIntent/BudgetIntent.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BudgetIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BudgetIntent; @@ -1099,7 +1123,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime.BudgetIntent; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime.BudgetIntent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1119,7 +1143,7 @@ CODE_SIGN_ENTITLEMENTS = BudgetIntent/BudgetIntent.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BudgetIntent/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BudgetIntent; @@ -1131,7 +1155,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime.BudgetIntent; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime.BudgetIntent; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1150,7 +1174,7 @@ CODE_SIGN_ENTITLEMENTS = BudgetIntentUI/BudgetIntentUI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BudgetIntentUI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BudgetIntentUI; @@ -1162,7 +1186,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime.BudgetIntentUI; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime.BudgetIntentUI; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1177,7 +1201,7 @@ CODE_SIGN_ENTITLEMENTS = BudgetIntentUI/BudgetIntentUI.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BudgetIntentUI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BudgetIntentUI; @@ -1189,7 +1213,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime.BudgetIntentUI; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime.BudgetIntentUI; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1326,7 +1350,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"dime/Preview Content\""; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = dime/Info.plist; @@ -1343,7 +1367,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1367,7 +1391,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"dime/Preview Content\""; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = dime/Info.plist; @@ -1384,7 +1408,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -1406,7 +1430,7 @@ CODE_SIGN_ENTITLEMENTS = ExpenditureWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ExpenditureWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ExpenditureWidgetExtension; @@ -1418,7 +1442,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime.ExpenditureWidget; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime.ExpenditureWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; @@ -1437,7 +1461,7 @@ CODE_SIGN_ENTITLEMENTS = ExpenditureWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5UNNTHMF44; + DEVELOPMENT_TEAM = WJ989CRRQJ; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ExpenditureWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ExpenditureWidgetExtension; @@ -1449,7 +1473,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 2.1.4; - PRODUCT_BUNDLE_IDENTIFIER = com.rafaelsoh.dime.ExpenditureWidget; + PRODUCT_BUNDLE_IDENTIFIER = com.vanxun.dime.ExpenditureWidget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/app/dime/AppDelegate.swift b/app/dime/AppDelegate.swift index 2114c3a..1fba573 100644 --- a/app/dime/AppDelegate.swift +++ b/app/dime/AppDelegate.swift @@ -6,8 +6,51 @@ // import SwiftUI +import UserNotifications + +class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let center = UNUserNotificationCenter.current() + center.delegate = self + + // Register notification categories with action buttons + + // Daily reminder category with "Quick Log" action + let quickLogAction = UNNotificationAction( + identifier: "QUICK_LOG_ACTION", + title: String(localized: "Quick Log"), + options: [.foreground] + ) + + let reminderCategory = UNNotificationCategory( + identifier: "DAILY_REMINDER", + actions: [quickLogAction], + intentIdentifiers: [], + options: [] + ) + + // Categorize transaction category with "Categorize Now" action + let categorizeAction = UNNotificationAction( + identifier: "CATEGORIZE_ACTION", + title: String(localized: "Categorize Now"), + options: [.foreground] + ) + + let categorizeCategory = UNNotificationCategory( + identifier: "CATEGORIZE_TRANSACTION", + actions: [categorizeAction], + intentIdentifiers: [], + options: [] + ) + + center.setNotificationCategories([reminderCategory, categorizeCategory]) + + return true + } -class AppDelegate: NSObject, UIApplicationDelegate { func application( _: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, @@ -17,4 +60,56 @@ class AppDelegate: NSObject, UIApplicationDelegate { sceneConfiguration.delegateClass = SceneDelegate.self return sceneConfiguration } + + // MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let categoryIdentifier = response.notification.request.content.categoryIdentifier + let actionIdentifier = response.actionIdentifier + + switch actionIdentifier { + case "QUICK_LOG_ACTION": + // "Quick Log" button on daily reminder → open new expense + if let url = URL(string: "dimeapp://newExpense") { + DispatchQueue.main.async { + UIApplication.shared.open(url) + } + } + + case "CATEGORIZE_ACTION": + // "Categorize Now" button on categorize notification → open transaction edit + openCategorizeURL(from: response) + + case UNNotificationDefaultActionIdentifier: + // Default tap on notification body + if categoryIdentifier == "CATEGORIZE_TRANSACTION" { + openCategorizeURL(from: response) + } else if categoryIdentifier == "DAILY_REMINDER" { + if let url = URL(string: "dimeapp://newExpense") { + DispatchQueue.main.async { + UIApplication.shared.open(url) + } + } + } + + default: + break + } + + completionHandler() + } + + private func openCategorizeURL(from response: UNNotificationResponse) { + if let transactionID = response.notification.request.content.userInfo["transactionID"] as? String { + if let url = URL(string: "dimeapp://categorize?id=\(transactionID)") { + DispatchQueue.main.async { + UIApplication.shared.open(url) + } + } + } + } } diff --git a/app/dime/Components/Transactions/NumberPad.swift b/app/dime/Components/Transactions/NumberPad.swift index ab0d3bb..91d1d4e 100644 --- a/app/dime/Components/Transactions/NumberPad.swift +++ b/app/dime/Components/Transactions/NumberPad.swift @@ -20,8 +20,8 @@ struct NumberPad: View { var showingNotePicker: Bool = false var submit: () -> Void - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var numberEntryType: Int = 1 - @AppStorage("haptics", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 1 + @AppStorage("haptics", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var hapticType: Int = 1 var numPadNumbers = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] @@ -241,7 +241,7 @@ struct NumberPadTextView: View { @Binding var isEditingDecimal: Bool @Binding var decimalValuesAssigned: AssignedDecimal - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -250,7 +250,7 @@ struct NumberPadTextView: View { // return splitDouble(price) // } - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var numberEntryType: Int = 1 + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 1 public var amount: String { if numberEntryType == 1 { diff --git a/app/dime/ContentView.swift b/app/dime/ContentView.swift index dddefe3..8bcd4f2 100644 --- a/app/dime/ContentView.swift +++ b/app/dime/ContentView.swift @@ -11,34 +11,34 @@ struct ContentView: View { @EnvironmentObject var appLockVM: AppLockViewModel @EnvironmentObject var dataController: DataController - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.scenePhase) var scenePhase - @AppStorage("showNotifications", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showNotifications: Bool = false - @AppStorage("notificationsEnabled", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var notificationsEnabled: Bool = true + @AppStorage("showNotifications", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showNotifications: Bool = false + @AppStorage("notificationsEnabled", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var notificationsEnabled: Bool = true - @AppStorage("firstLaunch", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstLaunch: Bool = true + @AppStorage("firstLaunch", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstLaunch: Bool = true // adds category orders - @AppStorage("dataMigration1", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var dataMigration1: Bool = true + @AppStorage("dataMigration1", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var dataMigration1: Bool = true // converts category colors to hex codes - @AppStorage("dataMigration2", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var dataMigration2: Bool = true + @AppStorage("dataMigration2", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var dataMigration2: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! @State var showIntro: Bool = false @State var showUpdate: Bool = false var center = UNUserNotificationCenter.current() - @AppStorage("topEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var savedTopEdge: Double = 30 - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var savedBottomEdge: Double = 15 + @AppStorage("topEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var savedTopEdge: Double = 30 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var savedBottomEdge: Double = 15 // updateSheetShowing - @AppStorage("previousVersion", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var previousVersionString: String = "Version \(UIApplication.appVersion ?? "") (\(UIApplication.buildNumber ?? ""))" + @AppStorage("previousVersion", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var previousVersionString: String = "Version \(UIApplication.appVersion ?? "") (\(UIApplication.buildNumber ?? ""))" - @AppStorage("showUpdateSheet", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showUpdateSheet: Bool = true + @AppStorage("showUpdateSheet", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showUpdateSheet: Bool = true var body: some View { GeometryReader { proxy in @@ -61,7 +61,7 @@ struct ContentView: View { } .ignoresSafeArea(.keyboard) .onAppear { -// UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set(false, forKey: "newTransactionAdded") +// UserDefaults(suiteName: "group.com.vanxun.dime")!.set(false, forKey: "newTransactionAdded") // WidgetCenter.shared.reloadTimelines(ofKind: "TemplateTransactions") if appLockVM.isAppLockEnabled { @@ -69,7 +69,7 @@ struct ContentView: View { } let defaults = - UserDefaults(suiteName: "group.com.rafaelsoh.dime") ?? UserDefaults.standard + UserDefaults(suiteName: "group.com.vanxun.dime") ?? UserDefaults.standard if defaults.object(forKey: "firstDayOfMonth") == nil { defaults.set(1, forKey: "firstDayOfMonth") diff --git a/app/dime/Data/DataController.swift b/app/dime/Data/DataController.swift index 88878d2..d7f246e 100644 --- a/app/dime/Data/DataController.swift +++ b/app/dime/Data/DataController.swift @@ -30,31 +30,15 @@ enum CustomError: Swift.Error, CustomLocalizedStringResourceConvertible { class DataController: ObservableObject { static let shared = DataController() - var container = NSPersistentCloudKitContainer(name: "MainModel") + var container = NSPersistentContainer(name: "MainModel") init() { let description = NSPersistentStoreDescription() description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true - description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) - description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) -// let keyValueStore = NSUbiquitousKeyValueStore.default -// -// if keyValueStore.object(forKey: "icloud_sync") == nil { -// keyValueStore.set(true, forKey: "icloud_sync") -// } -// -// if !keyValueStore.bool(forKey: "icloud_sync") { -// description.cloudKitContainerOptions = nil -// } else { -// description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.rafaelsoh.dime") -// } - - description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.rafaelsoh.dime") - - let groupID = "group.com.rafaelsoh.dime" + let groupID = "group.com.vanxun.dime" if let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) { description.url = url.appendingPathComponent("Main.sqlite") @@ -70,20 +54,6 @@ class DataController: ObservableObject { self.container.viewContext.automaticallyMergesChangesFromParent = true } - -// #if DEBUG -// do { -// // Use the container to initialize the development schema. -// try container.initializeCloudKitSchema(options: []) -// } catch { -// // Handle any errors. -// } -// #endif -//// do { -//// try container.initializeCloudKitSchema() -//// } catch { -//// print(error) -//// } } // internal variables @@ -100,11 +70,11 @@ class DataController: ObservableObject { var addedTransaction: Bool { get { - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.bool(forKey: "newTransactionAdded") + UserDefaults(suiteName: "group.com.vanxun.dime")!.bool(forKey: "newTransactionAdded") } set { - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set(newValue, forKey: "newTransactionAdded") + UserDefaults(suiteName: "group.com.vanxun.dime")!.set(newValue, forKey: "newTransactionAdded") } } @@ -324,7 +294,7 @@ class DataController: ObservableObject { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 switch type { @@ -488,6 +458,22 @@ class DataController: ObservableObject { } } + @available(iOS 16, *) + func findTransaction(withId id: UUID) throws -> Transaction { + let request: NSFetchRequest = Transaction.fetchRequest() + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "id = %@", id as CVarArg) + + do { + guard let foundTransaction = try container.viewContext.fetch(request).first else { + throw CustomError.notFound + } + return foundTransaction + } catch { + throw CustomError.notFound + } + } + func getAllBudgets() -> [Budget] { let request: NSFetchRequest = Budget.fetchRequest() request.sortDescriptors = [NSSortDescriptor(key: "dateCreated", ascending: true)] @@ -647,7 +633,7 @@ class DataController: ObservableObject { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 let dateCapPredicate = NSPredicate(format: "%K <= %@", #keyPath(Transaction.date), Date.now as CVarArg) @@ -695,7 +681,7 @@ class DataController: ObservableObject { let thisWeek = calendar.date(from: dateComponents)! startPredicate = NSPredicate(format: "%K >= %@", #keyPath(Transaction.date), thisWeek as CVarArg) } else if type == 3 { - let startOfMonth = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstDayOfMonth") + let startOfMonth = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstDayOfMonth") let thisMonth = getStartOfMonth(startDay: startOfMonth) startPredicate = NSPredicate(format: "%K >= %@", #keyPath(Transaction.date), thisMonth as CVarArg) @@ -1199,7 +1185,7 @@ class DataController: ObservableObject { // calendar initialization var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 var dictionary = [Date: Double]() @@ -1373,7 +1359,7 @@ class DataController: ObservableObject { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 let startPredicate = NSPredicate(format: "%K >= %@", #keyPath(Transaction.date), date as CVarArg) @@ -1504,7 +1490,7 @@ class DataController: ObservableObject { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 let endPredicate = NSPredicate(format: "%K < %@", #keyPath(Transaction.date), Date.now as CVarArg) @@ -1525,7 +1511,7 @@ class DataController: ObservableObject { startPredicate = NSPredicate(format: "%K >= %@", #keyPath(Transaction.date), startDate as CVarArg) case .month: - let startOfMonth = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstDayOfMonth") + let startOfMonth = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstDayOfMonth") startDate = getStartOfMonth(startDay: startOfMonth) @@ -1553,7 +1539,7 @@ class DataController: ObservableObject { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 switch type { diff --git a/app/dime/Info.plist b/app/dime/Info.plist index 610f2cf..927cabb 100644 --- a/app/dime/Info.plist +++ b/app/dime/Info.plist @@ -8,7 +8,7 @@ CFBundleTypeRole Editor CFBundleURLName - com.rafaelsoh.dime + com.vanxun.dime CFBundleURLSchemes dimeapp @@ -41,9 +41,13 @@ dimeapp://newExpense - UIBackgroundModes - - remote-notification - + NSCameraUsageDescription + Dime uses the camera to scan receipts and bills for automatic expense entry. + NSPhotoLibraryUsageDescription + Dime accesses your photos to scan receipts and bills for automatic expense entry. + NSSpeechRecognitionUsageDescription + Dime uses speech recognition to log transactions by voice. + NSMicrophoneUsageDescription + Dime needs microphone access for voice transaction logging. diff --git a/app/dime/Shortcuts/Actions/BudgetInsightsIntent.swift b/app/dime/Shortcuts/Actions/BudgetInsightsIntent.swift index e60c289..f55effa 100644 --- a/app/dime/Shortcuts/Actions/BudgetInsightsIntent.swift +++ b/app/dime/Shortcuts/Actions/BudgetInsightsIntent.swift @@ -92,9 +92,9 @@ struct ShortcutBudgetView: View { let amount: Double let type: Int - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var budgetType: String { switch type { diff --git a/app/dime/Shortcuts/Actions/GetInsightsIntent.swift b/app/dime/Shortcuts/Actions/GetInsightsIntent.swift index a8913bf..fa63579 100644 --- a/app/dime/Shortcuts/Actions/GetInsightsIntent.swift +++ b/app/dime/Shortcuts/Actions/GetInsightsIntent.swift @@ -139,9 +139,9 @@ struct ShortcutInsightsView: View { let type: ShortcutsInsightsType let timeframe: ShortcutsInsightsTimeFrame - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var leftText: String { switch type { diff --git a/app/dime/Shortcuts/Actions/NewTransactionIntent.swift b/app/dime/Shortcuts/Actions/NewTransactionIntent.swift index 030e83b..c4a5528 100644 --- a/app/dime/Shortcuts/Actions/NewTransactionIntent.swift +++ b/app/dime/Shortcuts/Actions/NewTransactionIntent.swift @@ -202,9 +202,9 @@ extension RepeatType: AppEnum { struct ShortcutTransactionView: View { let transaction: Transaction - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var transactionAmountString: String { let numberFormatter = NumberFormatter() diff --git a/app/dime/Shortcuts/Actions/QuickLogIntent.swift b/app/dime/Shortcuts/Actions/QuickLogIntent.swift new file mode 100644 index 0000000..e61b663 --- /dev/null +++ b/app/dime/Shortcuts/Actions/QuickLogIntent.swift @@ -0,0 +1,166 @@ +// +// QuickLogIntent.swift +// dime +// +// Created by Claude on 12/2/26. +// + +import AppIntents +import Foundation +import SwiftUI +import UserNotifications + +@available(iOS 16.4, *) +struct QuickLogIntent: AppIntent { + static var title: LocalizedStringResource = "Quick Log Transaction" + + static var description = + IntentDescription("Quickly log a transaction amount. Categorize it later.") + + @Parameter(title: "Type", description: "Income or expense", requestValueDialog: IntentDialog("Is this an income or expense?")) + var income: TransactionType + + @Parameter(title: "Amount", description: "Transaction amount", controlStyle: .field, inclusiveRange: (lowerBound: 0.01, upperBound: 100_000_000), requestValueDialog: IntentDialog("How much was the transaction?")) + var amount: Double + + @Parameter(title: "Note") + var note: String? + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView { + if amount == 0 { + throw $amount.needsValueError() + } + + let dataController = DataController.shared + let isIncome = (income == .income) + + let transaction = dataController.newTransaction( + note: note ?? "", + category: nil, + income: isIncome, + amount: amount, + date: Date.now, + repeatType: 0, + repeatCoefficient: 1, + delay: false + ) + + // Send local notification to categorize later + sendCategorizeNotification(for: transaction) + + let amountStr = formatAmount(amount) + return .result(dialog: "\(amountStr) logged! Tap the notification to categorize.") { + QuickLogConfirmationView(amount: amount, isIncome: isIncome, note: note) + } + } + + private func sendCategorizeNotification(for transaction: Transaction) { + let content = UNMutableNotificationContent() + + let amountStr = formatAmount(transaction.amount) + + content.title = "\(amountStr) recorded" + content.subtitle = "Tap to categorize this transaction" + content.sound = .default + content.categoryIdentifier = "CATEGORIZE_TRANSACTION" + + if let id = transaction.id { + content.userInfo = ["transactionID": id.uuidString] + } + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: "categorize-\(transaction.id?.uuidString ?? UUID().uuidString)", + content: content, + trigger: trigger + ) + + UNUserNotificationCenter.current().add(request) + } + + private func formatAmount(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = UserDefaults(suiteName: "group.com.vanxun.dime")?.string(forKey: "currency") + ?? Locale.current.currencyCode ?? "USD" + + let showCents = UserDefaults(suiteName: "group.com.vanxun.dime")?.bool(forKey: "showCents") ?? true + if showCents { + formatter.maximumFractionDigits = 2 + } else { + formatter.maximumFractionDigits = 0 + } + + return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + } + + static var parameterSummary: some ParameterSummary { + Summary("Quick log \(\.$income) of \(\.$amount)") { + \.$note + } + } +} + +@available(iOS 16.4, *) +struct QuickLogConfirmationView: View { + let amount: Double + let isIncome: Bool + let note: String? + + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! + + var amountString: String { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .currency + numberFormatter.currencyCode = currency + + if showCents { + numberFormatter.maximumFractionDigits = 2 + } else { + numberFormatter.maximumFractionDigits = 0 + } + + return numberFormatter.string(from: NSNumber(value: amount)) ?? "$0" + } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "bolt.circle.fill") + .font(.system(size: 30)) + .foregroundColor(.orange) + .frame(width: 35, height: 35, alignment: .center) + + VStack(alignment: .leading) { + Text(note?.isEmpty == false ? note! : "Uncategorized") + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundColor(Color.PrimaryText) + .lineLimit(1) + + Text("Tap notification to categorize") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(Color.SubtitleText) + .lineLimit(1) + } + Spacer() + if isIncome { + Text("+\(amountString)") + .font(.system(size: 19, weight: .medium, design: .rounded)) + .foregroundColor(Color.IncomeGreen) + .minimumScaleFactor(0.7) + .lineLimit(1) + .layoutPriority(1) + } else { + Text("-\(amountString)") + .font(.system(size: 19, weight: .medium, design: .rounded)) + .foregroundColor(Color.PrimaryText) + .minimumScaleFactor(0.7) + .lineLimit(1) + .layoutPriority(1) + } + } + .padding(.vertical, 20) + .padding(.horizontal, 20) + } +} diff --git a/app/dime/Shortcuts/ShortcutsProvider.swift b/app/dime/Shortcuts/ShortcutsProvider.swift index 345219a..6a45bc1 100644 --- a/app/dime/Shortcuts/ShortcutsProvider.swift +++ b/app/dime/Shortcuts/ShortcutsProvider.swift @@ -26,5 +26,10 @@ struct DimeShortcuts: AppShortcutsProvider { phrases: ["Extract leftover amount for your budgets in \(.applicationName)"], systemImageName: "circle.grid.2x2.fill" ) + AppShortcut( + intent: QuickLogIntent(), + phrases: ["Quick log in \(.applicationName)"], + systemImageName: "bolt.fill" + ) } } diff --git a/app/dime/Utilities/Constants.swift b/app/dime/Utilities/Constants.swift new file mode 100644 index 0000000..4f91e69 --- /dev/null +++ b/app/dime/Utilities/Constants.swift @@ -0,0 +1,10 @@ +// +// Constants.swift +// dime +// + +import Foundation + +enum Constants { + static let geminiAPIKey = "AIzaSyCMSraiAXcbZQZGiEVHHmhGPQx1CntCt4E" +} diff --git a/app/dime/Utilities/GeminiService.swift b/app/dime/Utilities/GeminiService.swift new file mode 100644 index 0000000..0f4867c --- /dev/null +++ b/app/dime/Utilities/GeminiService.swift @@ -0,0 +1,339 @@ +// +// GeminiService.swift +// dime +// + +import Foundation +import UIKit + +// MARK: - Receipt Scan Result + +struct ReceiptScanResult: Codable { + let amount: Double? + let merchant: String? + let date: String? + let category: String? + let isIncome: Bool? +} + +// MARK: - Gemini API Response Models + +struct GeminiResponse: Codable { + let candidates: [GeminiCandidate]? +} + +struct GeminiCandidate: Codable { + let content: GeminiContent? +} + +struct GeminiContent: Codable { + let parts: [GeminiPart]? +} + +struct GeminiPart: Codable { + let text: String? +} + +// MARK: - Error Types + +enum GeminiError: LocalizedError { + case imageConversionFailed + case missingAPIKey + case invalidURL + case requestFailed(Int) + case noContent + case parsingFailed + + var errorDescription: String? { + switch self { + case .imageConversionFailed: return "Could not process the image" + case .missingAPIKey: return "Gemini API key not set" + case .invalidURL: return "Invalid API URL" + case .requestFailed(let code): return "API request failed (\(code))" + case .noContent: return "No content in response" + case .parsingFailed: return "Could not parse receipt data" + } + } +} + +// MARK: - Gemini Service + +class GeminiService { + static func scanReceipt(image: UIImage) async throws -> ReceiptScanResult { + // Downscale large images to reduce payload + let processedImage = resizeImageIfNeeded(image, maxDimension: 1024) + + guard let imageData = processedImage.jpegData(compressionQuality: 0.7) else { + throw GeminiError.imageConversionFailed + } + let base64String = imageData.base64EncodedString() + + let apiKey = Constants.geminiAPIKey + guard !apiKey.isEmpty, apiKey != "YOUR_GEMINI_API_KEY_HERE" else { + throw GeminiError.missingAPIKey + } + + let urlString = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + guard let url = URL(string: urlString) else { + throw GeminiError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(apiKey, forHTTPHeaderField: "x-goog-api-key") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + + let prompt = """ + Analyze this receipt/bill image and extract the following information. + Return a JSON object with these fields: + - "amount": the total amount as a number (just the number, no currency symbol) + - "merchant": the store/merchant name as a string + - "date": the transaction date in "yyyy-MM-dd" format, or null if not visible + - "category": suggest one category from: Food, Transport, Groceries, Shopping, Entertainment, Healthcare, Utilities, Subscriptions, Rent, Education, or Other + - "isIncome": false for expenses, true for income (most receipts are expenses) + + If you cannot determine a field, set it to null. + Return ONLY the JSON object, no other text. + """ + + let body: [String: Any] = [ + "contents": [[ + "parts": [ + ["text": prompt], + [ + "inline_data": [ + "mime_type": "image/jpeg", + "data": base64String, + ] + ], + ] + ]], + "generationConfig": [ + "responseMimeType": "application/json" + ], + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GeminiError.requestFailed(0) + } + + guard httpResponse.statusCode == 200 else { + throw GeminiError.requestFailed(httpResponse.statusCode) + } + + let geminiResponse = try JSONDecoder().decode(GeminiResponse.self, from: data) + + guard let text = geminiResponse.candidates?.first?.content?.parts?.first?.text else { + throw GeminiError.noContent + } + + guard let jsonData = text.data(using: .utf8) else { + throw GeminiError.parsingFailed + } + + let result = try JSONDecoder().decode(ReceiptScanResult.self, from: jsonData) + return result + } + + static func scanBatchTransactions(image: UIImage) async throws -> [ReceiptScanResult] { + let processedImage = resizeImageIfNeeded(image, maxDimension: 1024) + + guard let imageData = processedImage.jpegData(compressionQuality: 0.7) else { + throw GeminiError.imageConversionFailed + } + let base64String = imageData.base64EncodedString() + + let apiKey = Constants.geminiAPIKey + guard !apiKey.isEmpty, apiKey != "YOUR_GEMINI_API_KEY_HERE" else { + throw GeminiError.missingAPIKey + } + + let urlString = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + guard let url = URL(string: urlString) else { + throw GeminiError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(apiKey, forHTTPHeaderField: "x-goog-api-key") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + + let prompt = """ + Analyze this screenshot of bank/payment transaction records. + Extract ALL visible transactions and return a JSON array. + Each element should have these fields: + - "amount": the transaction amount as a number (just the number, no currency symbol) + - "merchant": the merchant/payee name as a string (clean up the name, remove prefixes like "SALES:" or codes) + - "date": the transaction date in "yyyy-MM-dd" format, or null if not visible + - "category": suggest one category from: Food, Transport, Groceries, Shopping, Entertainment, Healthcare, Utilities, Subscriptions, Rent, Education, or Other + - "isIncome": false for expenses, true for income + + If the image contains only a single receipt/bill, return an array with one element. + If you cannot determine a field, set it to null. + Return ONLY the JSON array, no other text. + """ + + let body: [String: Any] = [ + "contents": [[ + "parts": [ + ["text": prompt], + [ + "inline_data": [ + "mime_type": "image/jpeg", + "data": base64String, + ] + ], + ] + ]], + "generationConfig": [ + "responseMimeType": "application/json" + ], + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GeminiError.requestFailed(0) + } + + guard httpResponse.statusCode == 200 else { + throw GeminiError.requestFailed(httpResponse.statusCode) + } + + let geminiResponse = try JSONDecoder().decode(GeminiResponse.self, from: data) + + guard let text = geminiResponse.candidates?.first?.content?.parts?.first?.text else { + throw GeminiError.noContent + } + + guard let jsonData = text.data(using: .utf8) else { + throw GeminiError.parsingFailed + } + + // Try to parse as array first, then fall back to single object + if let results = try? JSONDecoder().decode([ReceiptScanResult].self, from: jsonData) { + return results + } else if let single = try? JSONDecoder().decode(ReceiptScanResult.self, from: jsonData) { + return [single] + } + + throw GeminiError.parsingFailed + } + + static func parseVoiceInput(text: String, categoryNames: [String]) async throws -> [ReceiptScanResult] { + let apiKey = Constants.geminiAPIKey + guard !apiKey.isEmpty, apiKey != "YOUR_GEMINI_API_KEY_HERE" else { + throw GeminiError.missingAPIKey + } + + let urlString = + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent" + guard let url = URL(string: urlString) else { + throw GeminiError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(apiKey, forHTTPHeaderField: "x-goog-api-key") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 30 + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let todayStr = dateFormatter.string(from: Date.now) + + let categoriesStr = categoryNames.joined(separator: ", ") + + let prompt = """ + You are a transaction parser. Parse the following voice input into one or more transactions. + Today's date is \(todayStr). + + Voice input: "\(text)" + + Available categories: [\(categoriesStr)] + + Rules: + - Return a JSON array of transactions (even if there is only one). + - Each transaction object has these fields: + - "amount": number (required, the transaction amount) + - "merchant": string or null (a short note/description for the transaction) + - "date": string in "yyyy-MM-dd" format or null. Convert relative dates: "today" = \(todayStr), "yesterday" = yesterday's date, etc. If no date mentioned, use null. + - "category": string or null. Match to one of the available categories. If no match, use null. + - "isIncome": boolean. true for income, false for expense. Default is false (expense). + - If the input mentions multiple items with amounts (e.g. "lunch 45, taxi 12, coffee 18"), create separate transactions for each. + - Extract the amount from numbers in the input. + - Use the descriptive words as the merchant/note. + - Return ONLY the JSON array, no other text. + """ + + let body: [String: Any] = [ + "contents": [[ + "parts": [ + ["text": prompt] + ] + ]], + "generationConfig": [ + "responseMimeType": "application/json" + ], + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw GeminiError.requestFailed(0) + } + + guard httpResponse.statusCode == 200 else { + throw GeminiError.requestFailed(httpResponse.statusCode) + } + + let geminiResponse = try JSONDecoder().decode(GeminiResponse.self, from: data) + + guard let responseText = geminiResponse.candidates?.first?.content?.parts?.first?.text else { + throw GeminiError.noContent + } + + guard let jsonData = responseText.data(using: .utf8) else { + throw GeminiError.parsingFailed + } + + // Try to parse as array first, then fall back to single object + if let results = try? JSONDecoder().decode([ReceiptScanResult].self, from: jsonData) { + return results + } else if let single = try? JSONDecoder().decode(ReceiptScanResult.self, from: jsonData) { + return [single] + } + + throw GeminiError.parsingFailed + } + + private static func resizeImageIfNeeded(_ image: UIImage, maxDimension: CGFloat) -> UIImage { + let size = image.size + guard max(size.width, size.height) > maxDimension else { return image } + + let scale: CGFloat + if size.width > size.height { + scale = maxDimension / size.width + } else { + scale = maxDimension / size.height + } + + let newSize = CGSize(width: size.width * scale, height: size.height * scale) + let renderer = UIGraphicsImageRenderer(size: newSize) + return renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + } +} diff --git a/app/dime/Utilities/ImagePickerView.swift b/app/dime/Utilities/ImagePickerView.swift new file mode 100644 index 0000000..78e4bd8 --- /dev/null +++ b/app/dime/Utilities/ImagePickerView.swift @@ -0,0 +1,48 @@ +// +// ImagePickerView.swift +// dime +// + +import SwiftUI +import UIKit + +struct ImagePickerView: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Binding var isPresented: Bool + var sourceType: UIImagePickerController.SourceType + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + let parent: ImagePickerView + + init(_ parent: ImagePickerView) { + self.parent = parent + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + } + parent.isPresented = false + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.isPresented = false + } + } +} diff --git a/app/dime/Utilities/KeyboardHeightHelper.swift b/app/dime/Utilities/KeyboardHeightHelper.swift index 624ff77..7132bc4 100644 --- a/app/dime/Utilities/KeyboardHeightHelper.swift +++ b/app/dime/Utilities/KeyboardHeightHelper.swift @@ -77,7 +77,7 @@ extension UIApplication { } struct KeyboardAwareModifier: ViewModifier { - @AppStorage("keyboard", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var savedKeyboardHeight: Double = .init(UIScreen.main.bounds.height / 2.5) + @AppStorage("keyboard", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var savedKeyboardHeight: Double = .init(UIScreen.main.bounds.height / 2.5) var showToolbar: Bool // @State private var keyboardHeight: CGFloat = 250 diff --git a/app/dime/Utilities/SpeechRecognizer.swift b/app/dime/Utilities/SpeechRecognizer.swift new file mode 100644 index 0000000..108f5e0 --- /dev/null +++ b/app/dime/Utilities/SpeechRecognizer.swift @@ -0,0 +1,144 @@ +// +// SpeechRecognizer.swift +// dime +// + +import AVFoundation +import Foundation +import Speech + +class SpeechRecognizer: ObservableObject { + @Published var transcript = "" + @Published var isRecording = false + @Published var error: String? + + private var audioEngine = AVAudioEngine() + private var recognitionTask: SFSpeechRecognitionTask? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var timeoutTimer: Timer? + + private let maxDuration: TimeInterval = 30 + + func requestPermission(completion: @escaping (Bool) -> Void) { + SFSpeechRecognizer.requestAuthorization { authStatus in + DispatchQueue.main.async { + switch authStatus { + case .authorized: + AVAudioSession.sharedInstance().requestRecordPermission { allowed in + DispatchQueue.main.async { + completion(allowed) + } + } + default: + completion(false) + } + } + } + } + + func startRecording() { + guard !isRecording else { return } + + // Reset state + transcript = "" + error = nil + + let recognizer = SFSpeechRecognizer(locale: Locale.current) + guard let recognizer = recognizer, recognizer.isAvailable else { + error = "Speech recognition not available" + return + } + + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + self.error = "Audio session setup failed" + return + } + + recognitionRequest = SFSpeechAudioBufferRecognitionRequest() + guard let recognitionRequest = recognitionRequest else { + error = "Could not create recognition request" + return + } + recognitionRequest.shouldReportPartialResults = true + + let inputNode = audioEngine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + + inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { + [weak self] buffer, _ in + self?.recognitionRequest?.append(buffer) + } + + recognitionTask = recognizer.recognitionTask(with: recognitionRequest) { + [weak self] result, error in + guard let self = self else { return } + + if let result = result { + DispatchQueue.main.async { + self.transcript = result.bestTranscription.formattedString + } + + if result.isFinal { + DispatchQueue.main.async { + self.stopRecording() + } + } + } + + if let error = error { + DispatchQueue.main.async { + // Don't overwrite transcript if we already have one + if self.transcript.isEmpty { + self.error = error.localizedDescription + } + self.stopRecording() + } + } + } + + audioEngine.prepare() + do { + try audioEngine.start() + DispatchQueue.main.async { + self.isRecording = true + } + + // Auto-stop after max duration + timeoutTimer = Timer.scheduledTimer(withTimeInterval: maxDuration, repeats: false) { + [weak self] _ in + DispatchQueue.main.async { + self?.stopRecording() + } + } + } catch { + self.error = "Audio engine failed to start" + cleanupResources() + } + } + + func stopRecording() { + guard isRecording else { return } + + timeoutTimer?.invalidate() + timeoutTimer = nil + + recognitionRequest?.endAudio() + cleanupResources() + + isRecording = false + } + + private func cleanupResources() { + audioEngine.stop() + audioEngine.inputNode.removeTap(onBus: 0) + recognitionTask?.cancel() + recognitionTask = nil + recognitionRequest = nil + + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } +} diff --git a/app/dime/Views/BudgetView.swift b/app/dime/Views/BudgetView.swift index d79a43f..510d0bb 100644 --- a/app/dime/Views/BudgetView.swift +++ b/app/dime/Views/BudgetView.swift @@ -71,7 +71,7 @@ struct ActualBudgetView: View { GridItem(.flexible(), spacing: 15) ] - @AppStorage("budgetViewStyle", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var budgetRows: Bool = false + @AppStorage("budgetViewStyle", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var budgetRows: Bool = false @Namespace var animation @@ -285,7 +285,7 @@ struct MainBudgetView: View { } } - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -599,7 +599,7 @@ struct SingleBudgetView: View { } } - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -930,7 +930,7 @@ struct AnimatedBudgetBarGraph: View { var color: Color var percent: Double - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true @State var showBar: Bool = false var body: some View { @@ -967,14 +967,14 @@ struct AnimatedBudgetBarGraph: View { } struct BudgetDollarView: View { - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var amount: Double var red: Bool var scale: Int var size: CGFloat - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -1008,9 +1008,9 @@ struct BudgetDollarView: View { struct DetailedBudgetDollarView: View { var amount: Double - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -1036,9 +1036,9 @@ struct DetailedBudgetDifferenceDollarView: View { var amount: Double var red: Bool - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -1068,7 +1068,7 @@ struct DeleteBudgetAlert: View { let toDelete: Budget @Environment(\.colorScheme) var systemColorScheme - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -1152,7 +1152,7 @@ struct DeleteMainBudgetAlert: View { let toDelete: MainBudget @Environment(\.colorScheme) var systemColorScheme - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -1427,7 +1427,7 @@ struct TimeBudgetView: View { } } - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -1677,7 +1677,7 @@ struct TimeBudgetView: View { struct FilteredCategoryDayBudgetView: View { @FetchRequest private var transactions: FetchedResults @Binding var totalSpent: Double - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -1686,9 +1686,9 @@ struct FilteredCategoryDayBudgetView: View { @Environment(\.managedObjectContext) var moc @EnvironmentObject var dataController: DataController - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true - @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var swapTimeLabel: Bool = false - @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true + @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var swapTimeLabel: Bool = false + @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showExpenseOrIncomeSign: Bool = true var body: some View { @@ -1814,7 +1814,7 @@ struct FilteredBudgetView: View { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 if type == 1 { @@ -1921,7 +1921,7 @@ struct TimeMainBudgetView: View { } } - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -2162,7 +2162,7 @@ struct AnimatedHorizontalBarGraphBudget: View { let category: Category @State var showBar: Bool = false - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true var body: some View { HStack(spacing: 0) { @@ -2188,7 +2188,7 @@ struct AnimatedHorizontalBarGraphBudget: View { struct AnimatedHorizontalBarGraphMainBudget: View { @State var showBar: Bool = false - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true var body: some View { HStack(spacing: 0) { @@ -2220,7 +2220,7 @@ struct AnimatedCurvedBarGraphBudget: View { let color: String @State var percent: Double = 0 - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true var body: some View { DonutSemicircle(percent: percent, cornerRadius: cornerRadius, width: width) @@ -2252,7 +2252,7 @@ struct AnimatedCurvedBarGraphMainBudget: View { @State var percent: Double = 0 - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true var body: some View { DonutSemicircle(percent: percent, cornerRadius: cornerRadius, width: width) diff --git a/app/dime/Views/CategoryView.swift b/app/dime/Views/CategoryView.swift index f2a0011..108d4b8 100644 --- a/app/dime/Views/CategoryView.swift +++ b/app/dime/Views/CategoryView.swift @@ -147,9 +147,9 @@ struct CategoryListView: View { @Environment(\.colorScheme) var systemColorScheme @EnvironmentObject var dataController: DataController - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 - @AppStorage("categorySuggestions", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showSuggestions: Bool = true + @AppStorage("categorySuggestions", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showSuggestions: Bool = true @State var suggestionsToast = false @State private var offset: CGFloat = 0 @@ -1527,7 +1527,7 @@ struct DeleteCategoryAlert: View { @Binding var deleted: Bool @Environment(\.colorScheme) var systemColorScheme - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -1907,7 +1907,7 @@ struct ColourPickerView: View { GridItem(.fixed(40)) ] - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme diff --git a/app/dime/Views/CustomTabBar.swift b/app/dime/Views/CustomTabBar.swift index d1724cb..8090a93 100644 --- a/app/dime/Views/CustomTabBar.swift +++ b/app/dime/Views/CustomTabBar.swift @@ -18,17 +18,24 @@ struct CustomTabBar: View { @State var checkingFace: Bool = false @FetchRequest(sortDescriptors: []) private var transactions: FetchedResults + @FetchRequest(sortDescriptors: []) private var categories: FetchedResults @State var count = 0 @Binding var counter: Int var launchAdd: Bool - @AppStorage("confetti", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var confetti: Bool = false - @AppStorage("firstTransactionViewLaunch", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstLaunch: Bool = true + @AppStorage("confetti", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var confetti: Bool = false + @AppStorage("firstTransactionViewLaunch", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstLaunch: Bool = true @State var animate = false + // Voice input + @StateObject private var speechRecognizer = SpeechRecognizer() + @State private var isProcessingVoice = false + @State private var voiceResults: [ReceiptScanResult]? = nil + @State private var longPressTriggeredAt: Date? = nil + private var isZoomed: Bool { UIScreen.main.scale != UIScreen.main.nativeScale } @@ -50,17 +57,38 @@ struct CustomTabBar: View { .opacity(self.animate ? 0 : 1) .scaleEffect(self.animate ? 1 : 0.6) - Button { - let impactMed = UIImpactFeedbackGenerator(style: .light) - impactMed.impactOccurred() - - addTransaction = true - - } label: { - Image(systemName: "plus") - } - .buttonStyle(MyButtonStyle()) - .padding(15) + Image(systemName: speechRecognizer.isRecording ? "mic.fill" : "plus") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(speechRecognizer.isRecording ? .white : Color.LightIcon) + .frame(width: 65, height: 38) + .background( + speechRecognizer.isRecording ? Color.red : (isProcessingVoice ? Color.IncomeGreen : Color.DarkBackground), + in: RoundedRectangle(cornerRadius: 13, style: .continuous) + ) + .scaleEffect(speechRecognizer.isRecording ? 1.1 : 1.0) + .animation( + speechRecognizer.isRecording ? + .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, + value: speechRecognizer.isRecording + ) + .padding(15) + .contentShape(Rectangle()) + .onTapGesture { + // Debounce: ignore tap within 1s of long press trigger + if let t = longPressTriggeredAt, Date().timeIntervalSince(t) < 1.0 { + return + } + if speechRecognizer.isRecording { + speechRecognizer.stopRecording() + } else if !isProcessingVoice { + let impactMed = UIImpactFeedbackGenerator(style: .light) + impactMed.impactOccurred() + addTransaction = true + } + } + .onLongPressGesture(minimumDuration: 0.5) { + startVoiceRecording() + } } .onAppear { if transactions.isEmpty { @@ -69,7 +97,7 @@ struct CustomTabBar: View { } } } - .accessibilityLabel("Add New Transaction") + .accessibilityLabel(speechRecognizer.isRecording ? "Stop voice input" : "Add New Transaction") TabButton(image: "Budget", zoomed: isZoomed, currentTab: $currentTab) @@ -79,6 +107,45 @@ struct CustomTabBar: View { .padding(.bottom, bottomEdge - 10) .frame(maxWidth: .infinity) .background(Color.PrimaryBackground) + .overlay(alignment: .top) { + if speechRecognizer.isRecording { + HStack(spacing: 6) { + Circle() + .fill(Color.red) + .frame(width: 8, height: 8) + .opacity(speechRecognizer.isRecording ? 1.0 : 0.3) + .animation( + .easeInOut(duration: 0.6).repeatForever(autoreverses: true), + value: speechRecognizer.isRecording + ) + Text("Recording...") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundColor(.red) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + .offset(y: -12) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + if isProcessingVoice { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(0.7) + Text("Parsing...") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundColor(Color.IncomeGreen) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.ultraThinMaterial, in: Capsule()) + .offset(y: -12) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: speechRecognizer.isRecording) + .animation(.easeInOut(duration: 0.3), value: isProcessingVoice) .fullScreenCover(isPresented: $addTransaction, onDismiss: { if confetti { if count != transactions.count { @@ -90,8 +157,10 @@ struct CustomTabBar: View { firstLaunch = false } + voiceResults = nil + }, content: { - TransactionView(toEdit: nil) + TransactionView(toEdit: nil, voiceResults: voiceResults) }) .onChange(of: launchAdd) { _ in addTransaction = true @@ -108,6 +177,11 @@ struct CustomTabBar: View { self.animate = true } } + .onChange(of: speechRecognizer.isRecording) { recording in + if !recording && !speechRecognizer.transcript.isEmpty { + processVoiceTranscript(speechRecognizer.transcript) + } + } .onOpenURL { url in guard url.host == "newExpense" @@ -119,6 +193,52 @@ struct CustomTabBar: View { addTransaction = true } } + + private func startVoiceRecording() { + longPressTriggeredAt = Date() + + let impactMed = UIImpactFeedbackGenerator(style: .heavy) + impactMed.impactOccurred() + + speechRecognizer.requestPermission { granted in + if granted { + speechRecognizer.startRecording() + } + } + } + + private func processVoiceTranscript(_ text: String) { + withAnimation { + isProcessingVoice = true + } + + let categoryNames = categories.map { $0.wrappedName } + + Task { + do { + let results = try await GeminiService.parseVoiceInput(text: text, categoryNames: categoryNames) + + await MainActor.run { + withAnimation { + isProcessingVoice = false + } + + voiceResults = results + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + addTransaction = true + } + } + } catch { + await MainActor.run { + withAnimation { + isProcessingVoice = false + } + // On error, still open TransactionView normally + addTransaction = true + } + } + } + } } struct MyButtonStyle: ButtonStyle { diff --git a/app/dime/Views/HomeView.swift b/app/dime/Views/HomeView.swift index 4962984..ca72f62 100644 --- a/app/dime/Views/HomeView.swift +++ b/app/dime/Views/HomeView.swift @@ -43,6 +43,7 @@ struct HomeView: View { @State var fromURL2: Bool = false @State var fromURL3: Bool = false @State var fromURL4: Bool = false + @State var pendingCategorizeID: String? = nil @State var launchAdd: Bool = false @State var launchSearch: Bool = false @@ -114,6 +115,11 @@ struct HomeView: View { fromURL3 = true } else if url.host == "budget" { fromURL4 = true + } else if url.host == "categorize" { + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let idString = components.queryItems?.first(where: { $0.name == "id" })?.value { + pendingCategorizeID = idString + } } } } @@ -169,6 +175,19 @@ struct HomeView: View { if appLockVM.isAppLockEnabled && fromURL4 { currentTab = "Budget" } + + if appLockVM.isAppLockEnabled, let catID = pendingCategorizeID { + if #available(iOS 16, *) { + if let uuid = UUID(uuidString: catID), + let transaction = try? dataController.findTransaction(withId: uuid) { + currentTab = "Log" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + transactionManager.toEdit = transaction + } + } + } + pendingCategorizeID = nil + } } .onOpenURL { url in if url.host == "search" { @@ -177,6 +196,18 @@ struct HomeView: View { currentTab = "Insights" } else if url.host == "budget" { currentTab = "Budget" + } else if url.host == "categorize" { + if #available(iOS 16, *) { + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let idString = components.queryItems?.first(where: { $0.name == "id" })?.value, + let uuid = UUID(uuidString: idString), + let transaction = try? dataController.findTransaction(withId: uuid) { + currentTab = "Log" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + transactionManager.toEdit = transaction + } + } + } } } } diff --git a/app/dime/Views/InsightsView.swift b/app/dime/Views/InsightsView.swift index 8e5b1f6..ff65c18 100644 --- a/app/dime/Views/InsightsView.swift +++ b/app/dime/Views/InsightsView.swift @@ -14,7 +14,7 @@ struct InsightsView: View { @FetchRequest(sortDescriptors: []) private var transactions: FetchedResults @State private var showTimeMenu = false - @AppStorage("chartTimeFrame", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var chartType = 1 + @AppStorage("chartTimeFrame", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var chartType = 1 private var didSave = NotificationCenter.default.publisher(for: .NSManagedObjectContextDidSave) @State private var refreshID = UUID() @@ -126,12 +126,12 @@ struct HorizontalPieChartView: View { @FetchRequest private var allCategories: FetchedResults @FetchRequest private var transactions: FetchedResults - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var income: Bool var date: Date @@ -139,7 +139,9 @@ struct HorizontalPieChartView: View { var holdingTotal = 0.0 transactions.forEach { transaction in - holdingTotal += transaction.amount + if transaction.category != nil { + holdingTotal += transaction.amount + } } return holdingTotal @@ -154,6 +156,19 @@ struct HorizontalPieChartView: View { @Environment(\.dynamicTypeSize) var dynamicTypeSize + @State private var animationProgress: Double = 0 + + var segmentAngles: [(start: Double, end: Double)] { + var angles = [(start: Double, end: Double)]() + var currentAngle: Double = 0 + for cat in categories { + let sweepAngle = cat.percent * 360.0 + angles.append((start: currentAngle, end: currentAngle + sweepAngle)) + currentAngle += sweepAngle + } + return angles + } + var fontSize: CGFloat { switch dynamicTypeSize { case .xSmall: @@ -215,115 +230,150 @@ struct HorizontalPieChartView: View { .font(.system(.callout, design: .rounded).weight(.semibold)) .foregroundColor(Color.SubtitleText) - GeometryReader { proxy in - HStack(spacing: proxy.size.width * 0.015) { - ForEach(categories) { category in - if category.percent < 0.005 { - EmptyView() - } else { - AnimatedHorizontalBarGraph(category: category, index: categories.firstIndex(of: category) ?? 0) - .frame(width: (proxy.size.width * (1.0 - (0.015 * Double(categories.count - 1)))) * category.percent) - .onTapGesture { - withAnimation(.easeInOut) { - if categoryFilter == category.category { - selectedDate = nil - categoryFilterMode = false - categoryFilter = nil - } else { - selectedDate = nil - categoryFilterMode = true - categoryFilter = category.category - chosenAmount = category.percent * total - chosenName = category.category.wrappedName - } + HStack(alignment: .top, spacing: 16) { + // Donut chart + ZStack { + ForEach(Array(categories.enumerated()), id: \.element.id) { index, category in + if index < segmentAngles.count { + let angles = segmentAngles[index] + let gap: Double = categories.count > 1 ? 1.0 : 0.0 + let color = category.category.income + ? Color(hex: Color.colorArray[index]) + : Color(hex: category.category.wrappedColour) + + let sweep = (angles.end - angles.start) * animationProgress + DonutSegment( + startAngle: angles.start + gap / 2, + endAngle: angles.start + sweep - gap / 2, + thickness: 22 + ) + .fill(color) + .opacity(categoryFilterMode ? (categoryFilter == category.category ? 1.0 : 0.4) : 1.0) + .onTapGesture { + withAnimation(.easeInOut) { + if categoryFilter == category.category { + selectedDate = nil + categoryFilterMode = false + categoryFilter = nil + } else { + selectedDate = nil + categoryFilterMode = true + categoryFilter = category.category + chosenAmount = category.percent * total + chosenName = category.category.wrappedName } } - .opacity(categoryFilterMode ? (categoryFilter == category.category ? 1 : 0.5) : 1) - .overlay { - if categoryFilterMode && categoryFilter == category.category { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(Color.DarkBackground, lineWidth: 1.5) - } + } + } + } + } + .frame(width: 120, height: 120) + + // Legend + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(categories.enumerated()), id: \.element.id) { index, category in + let color = category.category.income + ? Color(hex: Color.colorArray[index]) + : Color(hex: category.category.wrappedColour) + + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(color) + .frame(width: 10, height: 10) + + Text("\(category.category.wrappedName) (\(category.percent * 100, specifier: "%.1f")%)") + .font(.system(.caption, design: .rounded).weight(.medium)) + .foregroundColor(Color.PrimaryText) + .lineLimit(1) + + Spacer() + + Text("\(currencySymbol)\(category.amount, specifier: (showCents && category.amount < 100) ? "%.2f" : "%.0f")") + .font(.system(.caption, design: .rounded).weight(.medium)) + .foregroundColor(Color.SubtitleText) + .lineLimit(1) + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut) { + if categoryFilter == category.category { + selectedDate = nil + categoryFilterMode = false + categoryFilter = nil + } else { + selectedDate = nil + categoryFilterMode = true + categoryFilter = category.category + chosenAmount = category.percent * total + chosenName = category.category.wrappedName } + } } } + + Divider() + + HStack(spacing: 8) { + Text("Total:") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + + Spacer() + + Text("\(currencySymbol)\(total, specifier: (showCents && total < 100) ? "%.2f" : "%.0f")") + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + } } - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(height: 17) .padding(.bottom, 10) - } - - ScrollView(showsIndicators: false) { + } else { + // Filtered category detail row VStack(spacing: 10) { ForEach(categories, id: \.self) { category in - if !categoryFilterMode || categoryFilter == category.category { - let boxColor = category.category.income ? Color(hex: Color.colorArray[categories.firstIndex(of: category) ?? 0]) : Color(hex: category.category.wrappedColour) - + if categoryFilter == category.category { HStack(spacing: 10) { - Text(category.category.fullName) .font(.system(.title3, design: .rounded).weight(.semibold)) .foregroundColor(Color.PrimaryText) .frame(maxWidth: .infinity, alignment: .leading) Text("\(currencySymbol)\(category.amount, specifier: (showCents && category.amount < 100) ? "%.2f" : "%.0f")") - .font(.system(categoryFilterMode && categoryFilter == category.category ? .title3 : .body, design: .rounded).weight(.medium)) + .font(.system(.title3, design: .rounded).weight(.medium)) .foregroundColor(Color.SubtitleText) .lineLimit(1) .layoutPriority(1) - if categoryFilterMode && categoryFilter == category.category { - Button { - withAnimation(.easeInOut) { - selectedDate = nil - categoryFilterMode = false - categoryFilter = nil - } - } label: { - Image(systemName: "xmark") - .font(.system(.footnote, design: .rounded).weight(.bold)) - .foregroundColor(Color.SubtitleText) - .padding(5) - .background(Color.SecondaryBackground, in: Circle()) - } - - } else { - - Text("\(category.percent * 100, specifier: "%.0f")%") - .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundColor(boxColor) - .padding(.vertical, 3) - .frame(width: percentWidth) - .background(boxColor.opacity(0.23), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - .padding(.vertical, categoryFilterMode && categoryFilter == category.category ? 10 : 5) - .padding(.horizontal, categoryFilterMode && categoryFilter == category.category ? 10 : 0) - .background(RoundedRectangle(cornerRadius: 12).fill(categoryFilterMode && categoryFilter == category.category ? Color.TertiaryBackground : Color.PrimaryBackground)) - .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(categoryFilterMode && categoryFilter == category.category ? Color.Outline : Color.clear, lineWidth: 1.3)) - .fixedSize(horizontal: false, vertical: true) - .contentShape(Rectangle()) - .drawingGroup() - .onTapGesture { - withAnimation(.easeInOut) { - if !categoryFilterMode { + Button { + withAnimation(.easeInOut) { selectedDate = nil - categoryFilterMode = true - categoryFilter = category.category - chosenAmount = category.percent * total - chosenName = category.category.wrappedName + categoryFilterMode = false + categoryFilter = nil } + } label: { + Image(systemName: "xmark") + .font(.system(.footnote, design: .rounded).weight(.bold)) + .foregroundColor(Color.SubtitleText) + .padding(5) + .background(Color.SecondaryBackground, in: Circle()) } } - + .padding(.vertical, 10) + .padding(.horizontal, 10) + .background(RoundedRectangle(cornerRadius: 12).fill(Color.TertiaryBackground)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Color.Outline, lineWidth: 1.3)) + .fixedSize(horizontal: false, vertical: true) } - } } } } .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .onAppear { + withAnimation(.easeInOut(duration: 0.8)) { + animationProgress = 1.0 + } + } } } @@ -349,7 +399,7 @@ struct HorizontalPieChartView: View { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 switch type { @@ -407,7 +457,7 @@ struct FilteredCategoryInsightsView: View { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 switch type { @@ -453,15 +503,15 @@ struct FilteredCategoryInsightsView: View { struct FilteredDateInsightsView: View { @FetchRequest private var transactions: FetchedResults - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var swapTimeLabel: Bool = false - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var swapTimeLabel: Bool = false + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showExpenseOrIncomeSign: Bool = true @@ -522,7 +572,7 @@ struct FilteredInsightsView: View { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer(forKey: "firstWeekday") + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer(forKey: "firstWeekday") calendar.minimumDaysInFirstWeek = 4 if type == 1 { @@ -573,7 +623,7 @@ struct SingleGraphView: View { @Binding var categoryFilterMode: Bool @Binding var selectedDate: Date? - @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var incomeTracking: Bool = true + @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var incomeTracking: Bool = true let language = Locale.current.languageCode var selectedDateString: String { @@ -599,10 +649,10 @@ struct SingleGraphView: View { @State var selectedDateAmount: Double = 0 var currencySymbol: String - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var showCents: Bool - @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstDayOfMonth: Int = 1 + @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstDayOfMonth: Int = 1 var dateString: String { let dateFormatter = DateFormatter() @@ -922,12 +972,12 @@ struct WeekGraphView: View { SortDescriptor(\.day) ]) private var transactions: FetchedResults - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true @State var categoryFilterMode = false @State var categoryFilter: Category? @@ -936,7 +986,7 @@ struct WeekGraphView: View { var startOfCurrentWeek: Date { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")?.integer(forKey: "firstWeekday") ?? 0 + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")?.integer(forKey: "firstWeekday") ?? 0 calendar.minimumDaysInFirstWeek = 4 let dateComponents = calendar.dateComponents([.weekOfYear, .yearForWeekOfYear], from: Date.now) @@ -950,7 +1000,7 @@ struct WeekGraphView: View { return Date.now } else { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")?.integer(forKey: "firstWeekday") ?? 0 + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")?.integer(forKey: "firstWeekday") ?? 0 calendar.minimumDaysInFirstWeek = 4 if let date = transactions[0].day { @@ -997,8 +1047,8 @@ struct WeekGraphView: View { @State var chosenCategoryName = "" @State var chosenCategoryAmount = 0.0 - @AppStorage("insightsViewIncomeFiltering", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var income: Bool = true - @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var incomeTracking: Bool = true + @AppStorage("insightsViewIncomeFiltering", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var income: Bool = true + @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var incomeTracking: Bool = true // @Environment(\.dynamicTypeMultiplier) var multiplier @@ -1166,7 +1216,7 @@ struct AverageLineView: View { var getMax: Int var average: Double -// @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true +// @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true // @State var showLine: Bool = false // @State var offset: CGFloat @@ -1346,14 +1396,14 @@ struct MonthGraphView: View { SortDescriptor(\.day) ]) private var transactions: FetchedResults - @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstDayOfMonth: Int = 1 + @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstDayOfMonth: Int = 1 - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true @State var categoryFilterMode = false @State var categoryFilter: Category? @@ -1418,8 +1468,8 @@ struct MonthGraphView: View { @State var chosenCategoryName = "" @State var chosenCategoryAmount = 0.0 - @AppStorage("insightsViewIncomeFiltering", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var income: Bool = true - @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var incomeTracking: Bool = true + @AppStorage("insightsViewIncomeFiltering", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var income: Bool = true + @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var incomeTracking: Bool = true // @Environment(\.dynamicTypeMultiplier) var multiplier @@ -1604,7 +1654,7 @@ struct MonthGraphView: View { } struct SingleMonthBarGraphView: View { - @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstDayOfMonth: Int = 1 + @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstDayOfMonth: Int = 1 @Binding var selectedDate: Date? @Binding var categoryFilterMode: Bool @@ -1729,12 +1779,12 @@ struct YearGraphView: View { SortDescriptor(\.day) ]) private var transactions: FetchedResults - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true @State var categoryFilterMode = false @State var categoryFilter: Category? @@ -1797,8 +1847,8 @@ struct YearGraphView: View { @State var chosenCategoryName = "" @State var chosenCategoryAmount = 0.0 - @AppStorage("insightsViewIncomeFiltering", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var income: Bool = true - @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var incomeTracking: Bool = true + @AppStorage("insightsViewIncomeFiltering", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var income: Bool = true + @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var incomeTracking: Bool = true // // @Environment(\.dynamicTypeMultiplier) var multiplier @@ -2113,9 +2163,9 @@ struct ChartTimePickerView: View { @State var timeframe = ChartTimeFrame.week @Binding var showMenu: Bool - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 - @AppStorage("chartTimeFrame", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var chartType = 1 + @AppStorage("chartTimeFrame", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var chartType = 1 @Environment(\.colorScheme) var systemColorScheme @@ -2190,7 +2240,7 @@ struct ChartTimePickerView: View { struct AnimatedBarGraph: View { var index: Int - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true @State var showBar: Bool = false var body: some View { @@ -2216,7 +2266,7 @@ struct AnimatedBarGraph: View { } struct AnimatedHorizontalBarGraph: View { - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: Bool = true + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true var category: PowerCategory var index: Int diff --git a/app/dime/Views/LogView.swift b/app/dime/Views/LogView.swift index 5da5075..6c978f9 100644 --- a/app/dime/Views/LogView.swift +++ b/app/dime/Views/LogView.swift @@ -5,7 +5,6 @@ // Created by Rafael Soh on 19/5/22. // -import CloudKitSyncMonitor import CoreData import Foundation import SwiftUIIntrospect @@ -13,8 +12,6 @@ import Popovers import SwiftUI struct LogView: View { - @ObservedObject var syncMonitor = SyncMonitor.shared - @State var updatedRecurring = false @FetchRequest(sortDescriptors: []) private var transactions: FetchedResults @@ -22,11 +19,11 @@ struct LogView: View { @EnvironmentObject var dataController: DataController @Environment(\.managedObjectContext) var moc - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true var topEdge: CGFloat - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -39,7 +36,7 @@ struct LogView: View { // top bar @State var navBarText = "" @State var showMenu = false - @AppStorage("logTimeFrame", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var logTimeFrame = 2 + @AppStorage("logTimeFrame", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var logTimeFrame = 2 let subtitleText = ["today", "this week", "this month", "this year"] // show filter menu @@ -266,35 +263,21 @@ struct LogView: View { .fullScreenCover(isPresented: $searchMode) { SearchView() } - .onChange(of: syncMonitor.syncStateSummary) { newState in - if newState == .succeeded && !updatedRecurring { - dataController.updateRecurringTransactions() - updatedRecurring = true - - DispatchQueue.main.asyncAfter(deadline: .now() + 60) { - updatedRecurring = false - } - } - } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - if syncMonitor.syncStateSummary == .succeeded && !updatedRecurring { + if !updatedRecurring { dataController.updateRecurringTransactions() updatedRecurring = true DispatchQueue.main.asyncAfter(deadline: .now() + 60) { updatedRecurring = false } - } else if !NSUbiquitousKeyValueStore.default.bool(forKey: "icloud_sync") { - dataController.updateRecurringTransactions() } } .onChange(of: launchSearch) { _ in searchMode = true } .onAppear { - if !NSUbiquitousKeyValueStore.default.bool(forKey: "icloud_sync") { - dataController.updateRecurringTransactions() - } + dataController.updateRecurringTransactions() } // .animation(.spring(duration: 0.5), value: released) // .animation(.spring(response: 0.4, dampingFraction: 0.6), value: pullStatus) @@ -352,9 +335,9 @@ struct NumberView: AnimatableModifier { let netTotal: Bool let positive: Bool - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -414,10 +397,10 @@ struct LogInsightsView: View { @State var showMenu1 = false let subtitleText = ["today", "this week", "this month", "this year", "all time"] - @AppStorage("logInsightsTimeFrame", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var timeframe = 2 - @AppStorage("logInsightsType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var insightsType = 1 + @AppStorage("logInsightsTimeFrame", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var timeframe = 2 + @AppStorage("logInsightsType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var insightsType = 1 - @AppStorage("logViewLineGraph", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var lineGraph: Bool = false + @AppStorage("logViewLineGraph", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var lineGraph: Bool = false var netTotal: (value: Double, positive: Bool) { dataController.getLogViewTotalNet(type: timeframe) @@ -426,7 +409,7 @@ struct LogInsightsView: View { var range: Int { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")?.integer(forKey: "firstWeekday") ?? 0 + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")?.integer(forKey: "firstWeekday") ?? 0 calendar.minimumDaysInFirstWeek = 4 if timeframe == 3 { @@ -764,7 +747,7 @@ struct TimePickerView: View { @Binding var timeframe: Int @State var holdingTimeframe = 0 - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme @@ -837,7 +820,7 @@ struct FilterPickerView: View { @Binding var filterType: FilterType @Binding var showMenu: Bool - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme @Environment(\.dynamicTypeSize) var dynamicTypeSize @@ -911,8 +894,8 @@ struct TransactionsList: View { var month: Date var income: Bool - @AppStorage("showUpcomingTransactions", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showUpcoming: Bool = true - @AppStorage("showUpcomingTransactionsWhenUpcoming", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showSoon: Bool = false + @AppStorage("showUpcomingTransactions", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showUpcoming: Bool = true + @AppStorage("showUpcomingTransactionsWhenUpcoming", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showSoon: Bool = false @EnvironmentObject var dataController: DataController @@ -954,17 +937,17 @@ struct TransactionsList: View { struct ListView: View { @SectionedFetchRequest var transactions: SectionedFetchResults - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showExpenseOrIncomeSign: Bool = true - @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var swapTimeLabel: Bool = false + @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var swapTimeLabel: Bool = false @EnvironmentObject var toastPresenter: OverallToastPresenter @@ -1093,17 +1076,17 @@ struct FutureListView: View { } } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showExpenseOrIncomeSign: Bool = true - @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var swapTimeLabel: Bool = false + @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var swapTimeLabel: Bool = false var totalString: String { let numberFormatter = NumberFormatter() @@ -1511,7 +1494,7 @@ struct DeleteTransactionAlert: View { @Environment(\.colorScheme) var systemColorScheme - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -1704,16 +1687,16 @@ struct FilteredDateView: View { var date: Date - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } - @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var swapTimeLabel: Bool = false + @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var swapTimeLabel: Bool = false - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showExpenseOrIncomeSign: Bool = true var body: some View { @@ -2057,7 +2040,7 @@ struct WeekStepperView: View { return Date.now } else { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")?.integer(forKey: "firstWeekday") ?? 0 + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")?.integer(forKey: "firstWeekday") ?? 0 calendar.minimumDaysInFirstWeek = 4 let date = transactions[0].day ?? Date.now @@ -2120,7 +2103,7 @@ struct WeekStepperView: View { .onAppear { var calendar = Calendar(identifier: .gregorian) - calendar.firstWeekday = UserDefaults(suiteName: "group.com.rafaelsoh.dime")?.integer(forKey: "firstWeekday") ?? 0 + calendar.firstWeekday = UserDefaults(suiteName: "group.com.vanxun.dime")?.integer(forKey: "firstWeekday") ?? 0 calendar.minimumDaysInFirstWeek = 4 let date = transactionsReversed[0].day ?? Date.now diff --git a/app/dime/Views/NewBudgetView.swift b/app/dime/Views/NewBudgetView.swift index 902100b..8850afa 100644 --- a/app/dime/Views/NewBudgetView.swift +++ b/app/dime/Views/NewBudgetView.swift @@ -28,8 +28,8 @@ struct InstructionHeadings { struct BrandNewBudgetView: View { @FetchRequest private var categories: FetchedResults - @AppStorage("firstWeekday", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstWeekday: Int = 1 - @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstDayOfMonth: Int = 1 + @AppStorage("firstWeekday", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstWeekday: Int = 1 + @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstDayOfMonth: Int = 1 @Environment(\.managedObjectContext) var moc @EnvironmentObject var dataController: DataController @@ -84,7 +84,7 @@ struct BrandNewBudgetView: View { // return (amount as NSString).doubleValue // } - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var numberEntryType: Int = 1 + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 1 @State private var price: Double = 0 @State private var category: Category? @@ -92,7 +92,7 @@ struct BrandNewBudgetView: View { @State var decimalValuesAssigned: AssignedDecimal = .none @State private var priceString: String = "0" - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsAppIconView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsAppIconView.swift index fbb066b..6b32400 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsAppIconView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsAppIconView.swift @@ -16,7 +16,7 @@ struct AppIconBundle: Hashable { } struct SettingsAppIconView: View { - @AppStorage("activeIcon", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("activeIcon", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var activeIcon: String = "AppIcon" @Environment(\.presentationMode) var presentationMode: Binding diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsAppearanceView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsAppearanceView.swift index 8426238..0ed9833 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsAppearanceView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsAppearanceView.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI struct SettingsAppearanceView: View { - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.presentationMode) var presentationMode: Binding diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsCurrencyView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsCurrencyView.swift index be748ab..76fcfaf 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsCurrencyView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsCurrencyView.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI struct SettingsCurrencyView: View { - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currencyCode: String = Locale.current.currencyCode! @Environment(\.presentationMode) var presentationMode: Binding @Environment(\.dynamicTypeSize) var dynamicTypeSize diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsEraseView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsEraseView.swift index 7beace8..441d069 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsEraseView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsEraseView.swift @@ -61,7 +61,7 @@ struct DeleteAllAlert: View { @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var systemColorScheme - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsFeatureLabView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsFeatureLabView.swift index b3fe94c..d8649a3 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsFeatureLabView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsFeatureLabView.swift @@ -11,22 +11,22 @@ import SwiftUI struct SettingsGoofyView: View { @Environment(\.presentationMode) var presentationMode: Binding - @AppStorage("confetti", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var confetti: + @AppStorage("confetti", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var confetti: Bool = false - // @AppStorage("chromatic", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var chromatic: Bool = false + // @AppStorage("chromatic", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var chromatic: Bool = false - @AppStorage("logViewLineGraph", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("logViewLineGraph", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var lineGraph: Bool = false - @AppStorage("budgetViewStyle", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("budgetViewStyle", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var budgetRows: Bool = false - @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("swapTimeLabel", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var swapTimeLabel: Bool = false @AppStorage( - "showTransactionRecommendations", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + "showTransactionRecommendations", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showRecommendations: Bool = false @Namespace var animation diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsHapticsView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsHapticsView.swift index ac0273f..150a38c 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsHapticsView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsHapticsView.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI struct SettingsHapticsView: View { - @AppStorage("haptics", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("haptics", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var hapticType: Int = 1 @Environment(\.presentationMode) var presentationMode: Binding diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsNotificationView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsNotificationView.swift index 3d85240..4a67ba7 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsNotificationView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsNotificationView.swift @@ -11,9 +11,9 @@ import SwiftUI struct SettingsNotificationsView: View { @Environment(\.presentationMode) var presentationMode: Binding - @AppStorage("showNotifications", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showNotifications", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showNotifications: Bool = false - @AppStorage("notificationsEnabled", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("notificationsEnabled", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var notificationsEnabled: Bool = true @State var option = 1 @State var customTime = Date.now @@ -190,15 +190,15 @@ struct SettingsNotificationsView: View { .padding(.horizontal, 15) .background(Color.SettingsBackground, in: RoundedRectangle(cornerRadius: 9)) .onChange(of: option) { newValue in - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( option, forKey: "notificationOption") if newValue == 3 { let components = Calendar.current.dateComponents([.hour, .minute], from: customTime) - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( components.hour!, forKey: "customHour") - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( components.minute!, forKey: "customMinute") } @@ -207,28 +207,28 @@ struct SettingsNotificationsView: View { .onChange(of: customTime) { _ in let components = Calendar.current.dateComponents([.hour, .minute], from: customTime) - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( components.hour!, forKey: "customHour") - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( components.minute!, forKey: "customMinute") newNotification() } .onAppear { - if UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.object( + if UserDefaults(suiteName: "group.com.vanxun.dime")!.object( forKey: "notificationOption") != nil { - option = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer( + option = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer( forKey: "notificationOption") } - if UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.object(forKey: "customHour") + if UserDefaults(suiteName: "group.com.vanxun.dime")!.object(forKey: "customHour") != nil - && UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.object(forKey: "customMinute") + && UserDefaults(suiteName: "group.com.vanxun.dime")!.object(forKey: "customMinute") != nil { var components = DateComponents() - components.hour = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer( + components.hour = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer( forKey: "customHour") - components.minute = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer( + components.minute = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer( forKey: "customMinute") customTime = Calendar.current.date(from: components)! } @@ -246,14 +246,15 @@ func newNotification() { content.title = String(localized: "Keep the streak going!") content.subtitle = String(localized: "Remember to input your expenses today.") content.sound = UNNotificationSound.default + content.categoryIdentifier = "DAILY_REMINDER" // show this notification five seconds from now var components = DateComponents() var option = 1 - if UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.object(forKey: "notificationOption") + if UserDefaults(suiteName: "group.com.vanxun.dime")!.object(forKey: "notificationOption") != nil { - option = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer( + option = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer( forKey: "notificationOption") } @@ -264,11 +265,11 @@ func newNotification() { components.hour = 20 components.minute = 0 } else { - if UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.object(forKey: "customHour") != nil, - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.object(forKey: "customMinute") != nil { - components.hour = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer( + if UserDefaults(suiteName: "group.com.vanxun.dime")!.object(forKey: "customHour") != nil, + UserDefaults(suiteName: "group.com.vanxun.dime")!.object(forKey: "customMinute") != nil { + components.hour = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer( forKey: "customHour") - components.minute = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.integer( + components.minute = UserDefaults(suiteName: "group.com.vanxun.dime")!.integer( forKey: "customMinute") } else { components.hour = 8 diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsNumberEntryView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsNumberEntryView.swift index 3ff4653..a284362 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsNumberEntryView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsNumberEntryView.swift @@ -9,11 +9,11 @@ import Foundation import SwiftUI struct SettingsNumberEntryView: View { - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 1 @Environment(\.presentationMode) var presentationMode: Binding @Environment(\.colorScheme) var colorScheme - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! private var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsShortcutView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsShortcutView.swift new file mode 100644 index 0000000..492bf1c --- /dev/null +++ b/app/dime/Views/Settings/Settings Subviews/SettingsShortcutView.swift @@ -0,0 +1,166 @@ +// +// SettingsShortcutView.swift +// dime +// +// Created by Claude on 12/2/26. +// + +import AppIntents +import Foundation +import SwiftUI + +struct SettingsShortcutView: View { + @Environment(\.presentationMode) var presentationMode: Binding + + var body: some View { + VStack(spacing: 0) { + // Header + Text("Quick Log") + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + Button { + self.presentationMode.wrappedValue.dismiss() + } label: { + SettingsBackButton() + } + } + .padding(.bottom, 30) + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + // Hero card + VStack(spacing: 16) { + Image(systemName: "bolt.circle.fill") + .font(.system(size: 56)) + .foregroundColor(.orange) + .padding(.top, 8) + + Text("Quick Log Shortcut") + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + + Text("Add this shortcut to quickly log transactions after a payment. Dime will save the amount and remind you to categorize it later.") + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(Color.SubtitleText) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + } + .padding(.vertical, 20) + .padding(.horizontal, 15) + .frame(maxWidth: .infinity) + .background(Color.SettingsBackground, in: RoundedRectangle(cornerRadius: 12)) + + // Steps card + VStack(alignment: .leading, spacing: 16) { + Text("HOW IT WORKS") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundColor(Color.SubtitleText) + + StepRow(number: "1", icon: "hand.tap.fill", text: "Tap \"Add to Shortcuts\" below") + StepRow(number: "2", icon: "bolt.fill", text: "Select \"Quick Log Transaction\" in Shortcuts") + StepRow(number: "3", icon: "square.grid.2x2.fill", text: "Add it to Home Screen or set as Automation") + } + .padding(.vertical, 16) + .padding(.horizontal, 15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.SettingsBackground, in: RoundedRectangle(cornerRadius: 12)) + + // Usage tips card + VStack(alignment: .leading, spacing: 16) { + Text("AFTER SETUP") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundColor(Color.SubtitleText) + + HStack(spacing: 12) { + Image(systemName: "dollarsign.circle.fill") + .font(.system(.body, design: .rounded)) + .foregroundColor(.green) + .frame(width: 24) + Text("Run the shortcut after a payment — enter only the amount") + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(Color.PrimaryText) + } + + HStack(spacing: 12) { + Image(systemName: "bell.badge.fill") + .font(.system(.body, design: .rounded)) + .foregroundColor(.blue) + .frame(width: 24) + Text("A notification appears — tap it to categorize the transaction") + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(Color.PrimaryText) + } + + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(.body, design: .rounded)) + .foregroundColor(.orange) + .frame(width: 24) + Text("Done! No more forgotten expenses") + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(Color.PrimaryText) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.SettingsBackground, in: RoundedRectangle(cornerRadius: 12)) + + Spacer().frame(height: 10) + + // Add to Shortcuts button + if #available(iOS 16.4, *) { + ShortcutsLink() + .shortcutsLinkStyle(.automaticOutline) + .frame(maxWidth: .infinity) + .frame(height: 50) + } else { + Button { + // Fallback: open Shortcuts app + if let url = URL(string: "shortcuts://") { + UIApplication.shared.open(url) + } + } label: { + Text("Open Shortcuts App") + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color.black, in: RoundedRectangle(cornerRadius: 12)) + } + } + + Spacer().frame(height: 80) + } + } + } + .modifier(SettingsSubviewModifier()) + } +} + +private struct StepRow: View { + let number: String + let icon: String + let text: String + + var body: some View { + HStack(spacing: 12) { + Text(number) + .font(.system(.footnote, design: .rounded).weight(.bold)) + .foregroundColor(.white) + .frame(width: 24, height: 24) + .background(Color.DarkIcon.opacity(0.6), in: Circle()) + + Image(systemName: icon) + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(Color.DarkIcon.opacity(0.7)) + .frame(width: 20) + + Text(text) + .font(.system(.subheadline, design: .rounded)) + .foregroundColor(Color.PrimaryText) + } + } +} diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsUpcomingView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsUpcomingView.swift index 3e566cf..907b4dc 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsUpcomingView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsUpcomingView.swift @@ -55,12 +55,12 @@ struct SettingsUpcomingView: View { showFuture.toggle() } .onChange(of: showFuture) { newValue in - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( newValue, forKey: "showUpcomingTransactions") if !newValue { showSoon = false - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( false, forKey: "showUpcomingTransactionsWhenUpcoming") } } @@ -90,7 +90,7 @@ struct SettingsUpcomingView: View { showSoon.toggle() } .onChange(of: showSoon) { newValue in - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( newValue, forKey: "showUpcomingTransactionsWhenUpcoming") } } @@ -117,9 +117,9 @@ struct SettingsUpcomingView: View { } .modifier(SettingsSubviewModifier()) .onAppear { - showFuture = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.bool( + showFuture = UserDefaults(suiteName: "group.com.vanxun.dime")!.bool( forKey: "showUpcomingTransactions") - showSoon = UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.bool( + showSoon = UserDefaults(suiteName: "group.com.vanxun.dime")!.bool( forKey: "showUpcomingTransactionsWhenUpcoming") } } diff --git a/app/dime/Views/Settings/Settings Subviews/SettingsWeekStartView.swift b/app/dime/Views/Settings/Settings Subviews/SettingsWeekStartView.swift index 7f6d7eb..f3776d9 100644 --- a/app/dime/Views/Settings/Settings Subviews/SettingsWeekStartView.swift +++ b/app/dime/Views/Settings/Settings Subviews/SettingsWeekStartView.swift @@ -9,9 +9,9 @@ import Foundation import SwiftUI struct SettingsWeekStartView: View { - @AppStorage("firstWeekday", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("firstWeekday", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstWeekday: Int = 1 - @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("firstDayOfMonth", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstDayOfMonth: Int = 1 @Environment(\.presentationMode) var presentationMode: Binding diff --git a/app/dime/Views/Settings/SettingsView.swift b/app/dime/Views/Settings/SettingsView.swift index 7eebdd3..e5675e2 100644 --- a/app/dime/Views/Settings/SettingsView.swift +++ b/app/dime/Views/Settings/SettingsView.swift @@ -16,7 +16,7 @@ import WidgetKit struct SettingsView: View { @Environment(\.dynamicTypeSize) var dynamicTypeSize - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 var colourSchemeString: String { if colourScheme == 1 { @@ -28,7 +28,7 @@ struct SettingsView: View { } } - @AppStorage("activeIcon", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("activeIcon", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var activeIcon: String = "AppIcon" var appIconString: String { if activeIcon == "AppIcon1" { @@ -42,7 +42,7 @@ struct SettingsView: View { } } - @AppStorage("firstWeekday", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("firstWeekday", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstWeekday: Int = 1 var firstWeekdayString: String { if firstWeekday == 1 { @@ -52,9 +52,9 @@ struct SettingsView: View { } } - @AppStorage("showNotifications", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showNotifications", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showNotifications: Bool = false - @AppStorage("notificationOption", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("notificationOption", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var option: Int = 1 var notificationString: String { if showNotifications { @@ -86,7 +86,7 @@ struct SettingsView: View { let featureRequestEmail = SupportEmail( toAddress: "rafasohhh@gmail.com", subject: "Feature Request") - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 2 var numberEntryString: String { @@ -97,23 +97,23 @@ struct SettingsView: View { } } - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("animated", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var animated: + @AppStorage("animated", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var animated: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! - @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("incomeTracking", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var incomeTracking: Bool = true - @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("showExpenseOrIncomeSign", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showExpenseOrIncomeSign: Bool = true @AppStorage( - "showUpcomingTransactions", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + "showUpcomingTransactions", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showUpcoming: Bool = true var upcomingString: String { @@ -124,7 +124,7 @@ struct SettingsView: View { } } - @AppStorage("haptics", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("haptics", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var hapticType: Int = 1 var hapticString: String { @@ -180,6 +180,11 @@ struct SettingsView: View { optionalText: notificationString) } + NavigationLink(destination: SettingsShortcutView()) { + SettingsRowView( + systemImage: "bolt.square.fill", title: "Quick Log", colour: 115) + } + NavigationLink(destination: SettingsCurrencyView()) { SettingsRowView( systemImage: "coloncurrencysign.square.fill", title: "Currency", colour: 103, @@ -217,9 +222,9 @@ struct SettingsView: View { incomeTracking.toggle() if !incomeTracking { - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( false, forKey: "insightsViewIncomeFiltering") - UserDefaults(suiteName: "group.com.rafaelsoh.dime")!.set( + UserDefaults(suiteName: "group.com.vanxun.dime")!.set( 3, forKey: "logInsightsType") } }) @@ -609,7 +614,7 @@ struct TipJarAlert: View { @State private var offset: CGFloat = 0 - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State var opacity = 0.0 @@ -824,11 +829,11 @@ struct ProductView: View { } func getText(_ string: String) -> String { - if string == "com.rafaelsoh.dime.smalltip" { + if string == "com.vanxun.dime.smalltip" { return String(localized: "☕ Coffee-Sized Tip") - } else if string == "com.rafaelsoh.dime.mediumtip" { + } else if string == "com.vanxun.dime.mediumtip" { return String(localized: "🌮 Taco-Sized Tip") - } else if string == "com.rafaelsoh.dime.largetip" { + } else if string == "com.vanxun.dime.largetip" { return String(localized: "🍕 Pizza-Sized Tip") } else { return "" diff --git a/app/dime/Views/Shapes/DonutSegment.swift b/app/dime/Views/Shapes/DonutSegment.swift new file mode 100644 index 0000000..cc95872 --- /dev/null +++ b/app/dime/Views/Shapes/DonutSegment.swift @@ -0,0 +1,41 @@ +// +// DonutSegment.swift +// dime +// + +import Foundation +import SwiftUI + +struct DonutSegment: Shape { + var startAngle: Double + var endAngle: Double + var thickness: CGFloat + + var animatableData: AnimatablePair { + get { AnimatablePair(startAngle, endAngle) } + set { + startAngle = newValue.first + endAngle = newValue.second + } + } + + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let outerRadius = min(rect.width, rect.height) / 2 + let innerRadius = outerRadius - thickness + + var path = Path() + path.addArc( + center: center, radius: outerRadius, + startAngle: .degrees(startAngle - 90), + endAngle: .degrees(endAngle - 90), + clockwise: false) + path.addArc( + center: center, radius: innerRadius, + startAngle: .degrees(endAngle - 90), + endAngle: .degrees(startAngle - 90), + clockwise: true) + path.closeSubpath() + return path + } +} diff --git a/app/dime/Views/SingleTransactionPhotoView.swift b/app/dime/Views/SingleTransactionPhotoView.swift index 6581db2..2e95115 100644 --- a/app/dime/Views/SingleTransactionPhotoView.swift +++ b/app/dime/Views/SingleTransactionPhotoView.swift @@ -18,7 +18,7 @@ struct SingleDayPhotoView: View { let swapTimeLabel: Bool let future: Bool - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme diff --git a/app/dime/Views/TemplateTransactionView.swift b/app/dime/Views/TemplateTransactionView.swift index bca7b73..5d29905 100644 --- a/app/dime/Views/TemplateTransactionView.swift +++ b/app/dime/Views/TemplateTransactionView.swift @@ -16,11 +16,11 @@ struct TemplateTransactionView: View { @EnvironmentObject var dataController: DataController @Environment(\.dismiss) var dismiss - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var numberEntryType: Int = 1 + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 1 @Environment(\.colorScheme) var colorScheme - @AppStorage("topEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var topEdge: Double = 30 + @AppStorage("topEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var topEdge: Double = 30 @State private var note = "" @State var category: Category? @@ -71,7 +71,7 @@ struct TemplateTransactionView: View { @State var showCategoryPicker = false @State var showCategorySheet = false - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -85,7 +85,7 @@ struct TemplateTransactionView: View { @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper() - @AppStorage("firstTransactionViewLaunch", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var firstLaunch: Bool = true + @AppStorage("firstTransactionViewLaunch", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstLaunch: Bool = true // edit mode let toEdit: TemplateTransaction? @@ -95,7 +95,7 @@ struct TemplateTransactionView: View { @State var toDelete: TemplateTransaction? @State var deleteMode = false - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -142,7 +142,7 @@ struct TemplateTransactionView: View { } } - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var colourScheme: Int = 0 + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme @@ -948,9 +948,9 @@ struct SettingsQuickAddWidgetView: View { } struct SettingsQuickAddWidgetDraggingView: View { - @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var showCents: Bool = true + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showCents: Bool = true - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! diff --git a/app/dime/Views/TransactionView.swift b/app/dime/Views/TransactionView.swift index 5945aaf..3258bc2 100644 --- a/app/dime/Views/TransactionView.swift +++ b/app/dime/Views/TransactionView.swift @@ -25,7 +25,7 @@ struct TransactionView: View { UIAccessibility.isBoldTextEnabled } - @AppStorage("topEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var topEdge: + @AppStorage("topEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var topEdge: Double = 20 @State private var note = "" @@ -47,7 +47,7 @@ struct TransactionView: View { @State var showCategoryPicker = false @State var showCategorySheet = false - @AppStorage("currency", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var currency: String = Locale.current.currencyCode! + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var currency: String = Locale.current.currencyCode! var currencySymbol: String { return Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! } @@ -60,6 +60,15 @@ struct TransactionView: View { @State var toastTitle = "" @State var toastImage = "" + // receipt scanning + @State private var showImageSourcePicker = false + @State private var showCamera = false + @State private var showPhotoLibrary = false + @State private var selectedImage: UIImage? + @State private var isScanning = false + @State private var batchResults: [ReceiptScanResult] = [] + @State private var showBatchImport = false + // shaking category error @State var categoryButtonTextColor = Color.SubtitleText @State var categoryButtonBackgroundColor = Color.clear @@ -69,18 +78,19 @@ struct TransactionView: View { @ObservedObject var keyboardHeightHelper = KeyboardHeightHelper() @AppStorage( - "firstTransactionViewLaunch", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + "firstTransactionViewLaunch", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var firstLaunch: Bool = true // edit mode let toEdit: Transaction? + let voiceResults: [ReceiptScanResult]? // delete mode @State var toDelete: Transaction? @State var deleteMode = false - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -138,7 +148,7 @@ struct TransactionView: View { } } - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme @@ -168,7 +178,7 @@ struct TransactionView: View { @State var textFieldFocused: Bool = false @AppStorage( - "showTransactionRecommendations", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + "showTransactionRecommendations", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var showRecommendations: Bool = false var suggestedTransactions: [Transaction] { @@ -218,7 +228,7 @@ struct TransactionView: View { } @State private var price: Double = 0 - @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("numberEntryType", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var numberEntryType: Int = 1 @State var isEditingDecimal = false @State var decimalValuesAssigned: AssignedDecimal = .none @@ -229,7 +239,24 @@ struct TransactionView: View { VStack(spacing: 8) { // income/expense picker VStack { - if showToast { + if isScanning { + HStack(spacing: 6.5) { + ProgressView() + .tint(Color.SubtitleText) + + Text("Scanning Receipt...") + .font(.system(.body, design: .rounded).weight(.semibold)) + .lineLimit(1) + .foregroundColor(Color.SubtitleText) + } + .padding(8) + .background( + Color.SecondaryBackground, + in: RoundedRectangle(cornerRadius: 9, style: .continuous) + ) + .transition(AnyTransition.opacity.combined(with: .move(edge: .top))) + .frame(maxWidth: dynamicTypeSize > .xLarge ? 250 : 200) + } else if showToast { HStack(spacing: 6.5) { Image(systemName: toastImage) .font(.system(.subheadline, design: .rounded).weight(.semibold)) @@ -333,6 +360,25 @@ struct TransactionView: View { .accessibilityLabel("delete transaction") } + if toEdit == nil { + Button { + showImageSourcePicker = true + } label: { + Image(systemName: "doc.text.viewfinder") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .foregroundColor(isScanning ? Color.IncomeGreen : Color.SubtitleText) + .padding(7) + .background( + isScanning ? Color.IncomeGreen.opacity(0.23) : Color.SecondaryBackground, + in: Circle() + ) + .contentShape(Circle()) + } + .disabled(isScanning) + .accessibilityLabel("scan receipt") + } + Button { showRecurring = true } label: { @@ -904,6 +950,10 @@ struct TransactionView: View { animateIcon = true } } + + if let results = voiceResults, !results.isEmpty { + applyVoiceResults(results) + } } } .sheet(isPresented: $showCategorySheet) { @@ -925,6 +975,198 @@ struct TransactionView: View { swipingOffset = capsuleWidth } } + .confirmationDialog("Scan Receipt", isPresented: $showImageSourcePicker) { + if UIImagePickerController.isSourceTypeAvailable(.camera) { + Button("Take Photo") { + showCamera = true + } + } + Button("Choose from Library") { + showPhotoLibrary = true + } + Button("Cancel", role: .cancel) {} + } + .sheet(isPresented: $showCamera) { + ImagePickerView(image: $selectedImage, isPresented: $showCamera, sourceType: .camera) + .ignoresSafeArea() + } + .sheet(isPresented: $showPhotoLibrary) { + ImagePickerView(image: $selectedImage, isPresented: $showPhotoLibrary, sourceType: .photoLibrary) + .ignoresSafeArea() + } + .onChange(of: selectedImage) { newImage in + guard let image = newImage else { return } + withAnimation { + isScanning = true + } + + Task { + do { + let results = try await GeminiService.scanBatchTransactions(image: image) + + await MainActor.run { + withAnimation { + isScanning = false + } + selectedImage = nil + + if results.count == 1 { + // Single transaction: auto-fill form fields (existing behavior) + let result = results[0] + + if let amount = result.amount, amount > 0 { + price = amount + if numberEntryType == 2 { + if amount.truncatingRemainder(dividingBy: 1) > 0 { + isEditingDecimal = true + decimalValuesAssigned = .second + } + } + } + + if let merchant = result.merchant, !merchant.isEmpty { + note = String(merchant.prefix(50)) + } + + if let dateString = result.date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + if let parsedDate = formatter.date(from: dateString) { + date = parsedDate + } + } + + if let isIncome = result.isIncome { + if isIncome != income { + withAnimation(.easeIn(duration: 0.15)) { + income = isIncome + swipingOffset = isIncome ? capsuleWidth : 0 + } + } + } + + if let categoryName = result.category { + let cats = income ? incomeCategories : expenseCategories + if let match = cats.first(where: { + $0.wrappedName.lowercased() == categoryName.lowercased() + }) { + category = match + } else if let match = cats.first(where: { + $0.wrappedName.lowercased().contains(categoryName.lowercased()) + || categoryName.lowercased().contains($0.wrappedName.lowercased()) + }) { + category = match + } + } + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } else if results.count > 1 { + // Multiple transactions: show batch import sheet + batchResults = results + showBatchImport = true + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } + } + } catch { + await MainActor.run { + withAnimation { + isScanning = false + } + selectedImage = nil + + if let geminiError = error as? GeminiError, + case .missingAPIKey = geminiError + { + toastImage = "key.fill" + toastTitle = "Set API Key" + } else { + toastImage = "exclamationmark.triangle" + toastTitle = "Scan Failed" + } + showToast = true + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.error) + } + } + } + } + .sheet(isPresented: $showBatchImport) { + BatchImportSheet( + results: batchResults, + expenseCategories: Array(expenseCategories), + incomeCategories: Array(incomeCategories), + onDismiss: { + showBatchImport = false + dismiss() + } + ) + } + } + + // MARK: - Voice Input + + func applyVoiceResults(_ results: [ReceiptScanResult]) { + if results.count == 1 { + let result = results[0] + + if let amount = result.amount, amount > 0 { + price = amount + if numberEntryType == 2 { + if amount.truncatingRemainder(dividingBy: 1) > 0 { + isEditingDecimal = true + decimalValuesAssigned = .second + } + } + } + + if let merchant = result.merchant, !merchant.isEmpty { + note = String(merchant.prefix(50)) + } + + if let dateString = result.date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + if let parsedDate = formatter.date(from: dateString) { + date = parsedDate + } + } + + if let isIncome = result.isIncome { + if isIncome != income { + withAnimation(.easeIn(duration: 0.15)) { + income = isIncome + swipingOffset = isIncome ? capsuleWidth : 0 + } + } + } + + if let categoryName = result.category { + let cats = income ? incomeCategories : expenseCategories + if let match = cats.first(where: { + $0.wrappedName.lowercased() == categoryName.lowercased() + }) { + category = match + } else if let match = cats.first(where: { + $0.wrappedName.lowercased().contains(categoryName.lowercased()) + || categoryName.lowercased().contains($0.wrappedName.lowercased()) + }) { + category = match + } + } + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } else if results.count > 1 { + batchResults = results + showBatchImport = true + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } } func isDateToday(date: Date) -> Bool { @@ -1102,7 +1344,7 @@ struct TransactionView: View { dismiss() } - init(toEdit: Transaction? = nil) { + init(toEdit: Transaction? = nil, voiceResults: [ReceiptScanResult]? = nil) { if let transaction = toEdit { _note = State(initialValue: transaction.wrappedNote) @@ -1119,6 +1361,7 @@ struct TransactionView: View { _date = State(initialValue: transaction.date ?? Date.now) } self.toEdit = toEdit + self.voiceResults = voiceResults } init(category: Category? = nil) { @@ -1128,6 +1371,7 @@ struct TransactionView: View { } toEdit = nil + voiceResults = nil } } @@ -1470,7 +1714,7 @@ struct RecurringPickerView: View { let stringArray = ["none", "daily", "weekly", "monthly"] let stringArray2 = ["", "days", "weeks", "months"] - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State private var offset: CGFloat = 0 @@ -1478,7 +1722,7 @@ struct RecurringPickerView: View { @State var holdingType = 0 @State var holdingCoefficient = 0 - @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) + @AppStorage("colourScheme", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var colourScheme: Int = 0 @Environment(\.colorScheme) var systemColorScheme @@ -1792,3 +2036,257 @@ struct ButtonView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } } + +// MARK: - Batch Import Sheet + +struct BatchImportSheet: View { + let results: [ReceiptScanResult] + let expenseCategories: [Category] + let incomeCategories: [Category] + let onDismiss: () -> Void + + @Environment(\.managedObjectContext) var moc + @EnvironmentObject var dataController: DataController + @Environment(\.dismiss) var dismiss + + @AppStorage("currency", store: UserDefaults(suiteName: "group.com.vanxun.dime")) + var currency: String = Locale.current.currencyCode! + var currencySymbol: String { + Locale.current.localizedCurrencySymbol(forCurrencyCode: currency)! + } + + @AppStorage("showCents", store: UserDefaults(suiteName: "group.com.vanxun.dime")) + var showCents: Bool = true + + @State private var selected: [Bool] = [] + + var selectedResults: [ReceiptScanResult] { + results.enumerated().compactMap { index, result in + (index < selected.count && selected[index]) ? result : nil + } + } + + var selectedCount: Int { + selectedResults.count + } + + var selectedTotal: Double { + selectedResults.compactMap { $0.amount }.reduce(0, +) + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + ForEach(Array(results.enumerated()), id: \.offset) { index, result in + if index < selected.count { + HStack(spacing: 12) { + Image(systemName: selected[index] ? "checkmark.circle.fill" : "circle") + .font(.system(.title3, design: .rounded)) + .foregroundColor(selected[index] ? Color.IncomeGreen : Color.SubtitleText) + .onTapGesture { + selected[index].toggle() + } + + VStack(alignment: .leading, spacing: 2) { + Text(result.merchant ?? "Unknown") + .font(.system(.body, design: .rounded).weight(.medium)) + .foregroundColor(Color.PrimaryText) + .lineLimit(1) + + HStack(spacing: 6) { + if let dateStr = result.date { + Text(formatDisplayDate(dateStr)) + .font(.system(.caption, design: .rounded)) + .foregroundColor(Color.SubtitleText) + } + + if let cat = result.category { + Text(cat) + .font(.system(.caption, design: .rounded).weight(.medium)) + .foregroundColor(Color.SubtitleText) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.SecondaryBackground, in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + } + } + + Spacer() + + Text("\(currencySymbol)\(result.amount ?? 0, specifier: showCents ? "%.2f" : "%.0f")") + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + } + .padding(.vertical, 10) + .padding(.horizontal, 16) + .contentShape(Rectangle()) + .onTapGesture { + selected[index].toggle() + } + + if index < results.count - 1 { + Divider() + .padding(.leading, 50) + } + } + } + } + } + + // Bottom bar + VStack(spacing: 10) { + Divider() + + HStack { + Text("Total:") + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + Spacer() + Text("\(currencySymbol)\(selectedTotal, specifier: showCents ? "%.2f" : "%.0f")") + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundColor(Color.PrimaryText) + } + .padding(.horizontal, 16) + + HStack(spacing: 12) { + Button { + importIndividually() + } label: { + Text("Import \(selectedCount) Items") + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(selectedCount > 0 ? Color.DarkBackground : Color.SubtitleText, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .disabled(selectedCount == 0) + + Button { + importAsMerged() + } label: { + Text("Merge as 1") + .font(.system(.body, design: .rounded).weight(.semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(selectedCount > 0 ? Color.DarkBackground : Color.SubtitleText, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .disabled(selectedCount == 0) + } + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + .navigationTitle("Scanned \(results.count) Transactions") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(allSelected ? "Deselect All" : "Select All") { + let newValue = !allSelected + for i in selected.indices { + selected[i] = newValue + } + } + .font(.system(.subheadline, design: .rounded)) + } + } + } + .onAppear { + selected = Array(repeating: true, count: results.count) + } + } + + var allSelected: Bool { + selected.allSatisfy { $0 } + } + + private func matchCategory(name: String?, isIncome: Bool) -> Category? { + guard let categoryName = name else { return nil } + let cats = isIncome ? incomeCategories : expenseCategories + if let match = cats.first(where: { + $0.wrappedName.lowercased() == categoryName.lowercased() + }) { + return match + } + if let match = cats.first(where: { + $0.wrappedName.lowercased().contains(categoryName.lowercased()) + || categoryName.lowercased().contains($0.wrappedName.lowercased()) + }) { + return match + } + return nil + } + + private func parseDate(_ dateString: String?) -> Date { + guard let ds = dateString else { return Date.now } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: ds) ?? Date.now + } + + private func formatDisplayDate(_ dateString: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + guard let d = formatter.date(from: dateString) else { return dateString } + let display = DateFormatter() + display.dateFormat = "MM/dd" + return display.string(from: d) + } + + private func importIndividually() { + for result in selectedResults { + let isIncome = result.isIncome ?? false + let cat = matchCategory(name: result.category, isIncome: isIncome) + let txDate = parseDate(result.date) + + _ = dataController.newTransaction( + note: result.merchant ?? "", + category: cat, + income: isIncome, + amount: result.amount ?? 0, + date: txDate, + repeatType: 0, + repeatCoefficient: 1, + delay: false + ) + } + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + onDismiss() + } + + private func importAsMerged() { + let firstResult = selectedResults.first + let isIncome = firstResult?.isIncome ?? false + let cat = matchCategory(name: firstResult?.category, isIncome: isIncome) + + let merchants = selectedResults.compactMap { $0.merchant } + let mergedNote = merchants.isEmpty + ? "Merged Transaction" + : String(merchants.joined(separator: ", ").prefix(50)) + + _ = dataController.newTransaction( + note: mergedNote, + category: cat, + income: isIncome, + amount: selectedTotal, + date: Date.now, + repeatType: 0, + repeatCoefficient: 1, + delay: false + ) + + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + onDismiss() + } +} diff --git a/app/dime/Views/UnlockManager.swift b/app/dime/Views/UnlockManager.swift index ee611ec..a49fbdb 100644 --- a/app/dime/Views/UnlockManager.swift +++ b/app/dime/Views/UnlockManager.swift @@ -93,7 +93,7 @@ class UnlockManager: NSObject, ObservableObject, SKPaymentTransactionObserver, S self.dataController = dataController // Prepare to look for our unlock product. - let productIDs = Set(["com.rafaelsoh.dime.smalltip", "com.rafaelsoh.dime.mediumtip", "com.rafaelsoh.dime.largetip"]) + let productIDs = Set(["com.vanxun.dime.smalltip", "com.vanxun.dime.mediumtip", "com.vanxun.dime.largetip"]) request = SKProductsRequest(productIdentifiers: productIDs) // This is required because we inherit from NSObject. diff --git a/app/dime/Views/UpdateSheet.swift b/app/dime/Views/UpdateSheet.swift index ebb63f7..42e4d2c 100644 --- a/app/dime/Views/UpdateSheet.swift +++ b/app/dime/Views/UpdateSheet.swift @@ -14,7 +14,7 @@ struct UpdateAlert: View { @State private var offset: CGFloat = 0 - @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.rafaelsoh.dime")) var bottomEdge: Double = 15 + @AppStorage("bottomEdge", store: UserDefaults(suiteName: "group.com.vanxun.dime")) var bottomEdge: Double = 15 @State var opacity = 0.0 diff --git a/app/dime/dime.entitlements b/app/dime/dime.entitlements index c4bb69e..e7f0db9 100644 --- a/app/dime/dime.entitlements +++ b/app/dime/dime.entitlements @@ -2,21 +2,9 @@ - aps-environment - development - com.apple.developer.icloud-container-identifiers - - iCloud.com.rafaelsoh.dime - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.rafaelsoh.dime com.apple.security.application-groups - group.com.rafaelsoh.dime + group.com.vanxun.dime From d30f2173c3b3b5d7465069f346e5b41ca0f28e37 Mon Sep 17 00:00:00 2001 From: Vanxun Hank Date: Thu, 2 Apr 2026 15:25:51 +0800 Subject: [PATCH 2/2] security: move Gemini API key to Secrets.swift (gitignored) Constants.swift now reads the key from Secrets.swift which is not tracked by git. New clones need to create their own Secrets.swift. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 ++++ app/dime.xcodeproj/project.pbxproj | 4 ++++ app/dime/Utilities/Constants.swift | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9e172bd..364712f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ xcuserdata/ ## MacOS .DS_Store + +## Secrets +Secrets.xcconfig +Secrets.swift diff --git a/app/dime.xcodeproj/project.pbxproj b/app/dime.xcodeproj/project.pbxproj index 34e420e..b5983b8 100644 --- a/app/dime.xcodeproj/project.pbxproj +++ b/app/dime.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ AEADEF752AE94DC3006EB614 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEADEF732AE94DC3006EB614 /* Toolbar.swift */; }; AECE7F9E2AED1A6800B57267 /* SuggestedTransactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AECE7F9D2AED1A6800B57267 /* SuggestedTransactions.swift */; }; C1A2B3D4E5F6A7B8C9D0E1F5 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */; }; + D1A2B3D4E5F6A7B8C9D0E1A1 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A2B3D4E5F6A7B8C9D0E1A0 /* Secrets.swift */; }; C1A2B3D4E5F6A7B8C9D0E1F6 /* GeminiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */; }; C1A2B3D4E5F6A7B8C9D0E1F7 /* ImagePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */; }; D1E2F3A4B5C6D7E8F9A0B1C2 /* DonutSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6D7E8F9A0B1C3 /* DonutSegment.swift */; }; @@ -338,6 +339,7 @@ AEADEF732AE94DC3006EB614 /* Toolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; AECE7F9D2AED1A6800B57267 /* SuggestedTransactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedTransactions.swift; sourceTree = ""; }; C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + D1A2B3D4E5F6A7B8C9D0E1A0 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiService.swift; sourceTree = ""; }; C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerView.swift; sourceTree = ""; }; D1E2F3A4B5C6D7E8F9A0B1C3 /* DonutSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonutSegment.swift; sourceTree = ""; }; @@ -502,6 +504,7 @@ 536A2A162A5A7C5D00D81E02 /* OffsetHelper.swift */, 533D1C3B2AE7BB6900894764 /* DynamicType.swift */, C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */, + D1A2B3D4E5F6A7B8C9D0E1A0 /* Secrets.swift */, C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */, C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */, A1B2C3D4E5F6A7B8C9D0E1F7 /* SpeechRecognizer.swift */, @@ -1010,6 +1013,7 @@ 5327D2DD287C6B5F00F76ADF /* InsightsView.swift in Sources */, 53A839BD28D35738006F227C /* SKProduct-LocalizedPrice.swift in Sources */, C1A2B3D4E5F6A7B8C9D0E1F5 /* Constants.swift in Sources */, + D1A2B3D4E5F6A7B8C9D0E1A1 /* Secrets.swift in Sources */, C1A2B3D4E5F6A7B8C9D0E1F6 /* GeminiService.swift in Sources */, C1A2B3D4E5F6A7B8C9D0E1F7 /* ImagePickerView.swift in Sources */, E1F2A3B4C5D6E7F8A9B0C1D2 /* QuickLogIntent.swift in Sources */, diff --git a/app/dime/Utilities/Constants.swift b/app/dime/Utilities/Constants.swift index 4f91e69..d26743d 100644 --- a/app/dime/Utilities/Constants.swift +++ b/app/dime/Utilities/Constants.swift @@ -6,5 +6,8 @@ import Foundation enum Constants { - static let geminiAPIKey = "AIzaSyCMSraiAXcbZQZGiEVHHmhGPQx1CntCt4E" + // API key is loaded from Secrets.swift (not tracked by git) + // Create app/dime/Utilities/Secrets.swift with: + // enum Secrets { static let geminiAPIKey = "YOUR_KEY_HERE" } + static let geminiAPIKey = Secrets.geminiAPIKey }