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/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..b5983b8 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,24 @@
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 */; };
+ 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 */; };
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 +272,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 +331,22 @@
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 = ""; };
+ 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 = ""; };
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 +378,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 +503,11 @@
536A2A142A59C47400D81E02 /* Wiggle.swift */,
536A2A162A5A7C5D00D81E02 /* OffsetHelper.swift */,
533D1C3B2AE7BB6900894764 /* DynamicType.swift */,
+ C1A2B3D4E5F6A7B8C9D0E1F2 /* Constants.swift */,
+ D1A2B3D4E5F6A7B8C9D0E1A0 /* Secrets.swift */,
+ C1A2B3D4E5F6A7B8C9D0E1F3 /* GeminiService.swift */,
+ C1A2B3D4E5F6A7B8C9D0E1F4 /* ImagePickerView.swift */,
+ A1B2C3D4E5F6A7B8C9D0E1F7 /* SpeechRecognizer.swift */,
);
path = Utilities;
sourceTree = "";
@@ -550,6 +568,7 @@
53B822A92A6D50CC000FC7C9 /* NewTransactionIntent.swift */,
536FA6C02A7E05C600C52490 /* GetInsightsIntent.swift */,
536FA6C22A7F941B00C52490 /* BudgetInsightsIntent.swift */,
+ E1F2A3B4C5D6E7F8A9B0C1D3 /* QuickLogIntent.swift */,
);
path = Actions;
sourceTree = "";
@@ -557,7 +576,6 @@
5383D85C287D9A0100D1B9BA /* Frameworks */ = {
isa = PBXGroup;
children = (
- 536715EC28E1E9A3008461F2 /* StoreKit.framework */,
5383D85D287D9A0100D1B9BA /* CloudKit.framework */,
53B1D18B288ED7E400E28062 /* WidgetKit.framework */,
53B1D18D288ED7E500E28062 /* SwiftUI.framework */,
@@ -589,6 +607,7 @@
isa = PBXGroup;
children = (
533D1C3D2AE8023B00894764 /* PencilShape.swift */,
+ D1E2F3A4B5C6D7E8F9A0B1C3 /* DonutSegment.swift */,
53B907982AE817EA0001F496 /* DonutSemicircle.swift */,
53B9079A2AE818710001F496 /* RoundedTriangle.swift */,
53B9079E2AE818B90001F496 /* Ring.swift */,
@@ -633,6 +652,7 @@
53D6BDD32AF73CA100F5728E /* SettingsFeatureLabView.swift */,
53D6BDD52AF73CE400F5728E /* SettingsUpcomingView.swift */,
53D6BDE02AF73FAB00F5728E /* SettingsSubviewModifier.swift */,
+ E1F2A3B4C5D6E7F8A9B0C1D5 /* SettingsShortcutView.swift */,
);
path = "Settings Subviews";
sourceTree = "";
@@ -912,6 +932,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 +1012,13 @@
53A4147928B6625D008C30E7 /* AppDelegate.swift in Sources */,
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 */,
+ E1F2A3B4C5D6E7F8A9B0C1D4 /* SettingsShortcutView.swift in Sources */,
+ A1B2C3D4E5F6A7B8C9D0E1F6 /* SpeechRecognizer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1087,7 +1115,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 +1127,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 +1147,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 +1159,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 +1178,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 +1190,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 +1205,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 +1217,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 +1354,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 +1371,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 +1395,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 +1412,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 +1434,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 +1446,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 +1465,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 +1477,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..d26743d
--- /dev/null
+++ b/app/dime/Utilities/Constants.swift
@@ -0,0 +1,13 @@
+//
+// Constants.swift
+// dime
+//
+
+import Foundation
+
+enum Constants {
+ // 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
+}
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