├── README
└── Screenshots
│ ├── GitHubCharts_v4.png
│ ├── GitHubHeader_v4.png
│ ├── GitHubCurrencies_v4.png
│ └── GitHubFiltersAndExport_v4.png
├── financecontrol
├── Assets.xcassets
│ ├── Contents.json
│ ├── Nord
│ │ ├── Contents.json
│ │ ├── NordBlue.colorset
│ │ │ └── Contents.json
│ │ ├── NordBlueDark.colorset
│ │ │ └── Contents.json
│ │ ├── NordGreen.colorset
│ │ │ └── Contents.json
│ │ ├── NordRed.colorset
│ │ │ └── Contents.json
│ │ ├── NordRedDark.colorset
│ │ │ └── Contents.json
│ │ ├── NordTeal.colorset
│ │ │ └── Contents.json
│ │ ├── NordTealDark.colorset
│ │ │ └── Contents.json
│ │ ├── NordGreenDark.colorset
│ │ │ └── Contents.json
│ │ ├── NordOrange.colorset
│ │ │ └── Contents.json
│ │ ├── NordOrangeDark.colorset
│ │ │ └── Contents.json
│ │ ├── NordPurple.colorset
│ │ │ └── Contents.json
│ │ ├── NordPurpleDark.colorset
│ │ │ └── Contents.json
│ │ ├── NordYellow.colorset
│ │ │ └── Contents.json
│ │ └── NordYellowDark.colorset
│ │ │ └── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── AppIcon_Image.imageset
│ │ └── Contents.json
├── ViewModel
│ ├── Protocols
│ │ ├── ViewModelProtocol.swift
│ │ └── SpendingViewModelProtocol.swift
│ └── SearchViewModel.swift
├── View
│ ├── Universal
│ │ ├── ListHorizontalScroll
│ │ │ └── ListHorizontalScrollRepresentable.swift
│ │ ├── Shapes
│ │ │ └── Line.swift
│ │ ├── UniversalViews.swift
│ │ ├── Custom Alert
│ │ │ ├── CustomAlertViewExtension.swift
│ │ │ ├── CustomAlertType.swift
│ │ │ ├── CustomAlertManager.swift
│ │ │ ├── CustomAlertViewModel.swift
│ │ │ ├── CustomAlertData.swift
│ │ │ └── CustomAlertView.swift
│ │ └── CustomContentUnavailableView.swift
│ ├── Stats
│ │ ├── ViewModel
│ │ │ ├── StatsViewModel.swift
│ │ │ ├── StatsRowViewModel.swift
│ │ │ ├── StatsSearchViewModel.swift
│ │ │ └── PieChartViewModel.swift
│ │ └── Views
│ │ │ ├── PieChartCompleteView.swift
│ │ │ ├── PieChartCenterView.swift
│ │ │ ├── EditReturnViewModel.swift
│ │ │ ├── Filters
│ │ │ ├── FiltersReturnsView.swift
│ │ │ ├── FiltersCurrenciesView.swift
│ │ │ └── FiltersCategoriesView.swift
│ │ │ ├── StatsListView.swift
│ │ │ └── PieChartLegendView.swift
│ ├── Settings
│ │ ├── Views
│ │ │ ├── CustomShareSheet.swift
│ │ │ ├── Category
│ │ │ │ ├── ShadowedCategoriesView.swift
│ │ │ │ ├── ShadowedCategoriesRow.swift
│ │ │ │ ├── CategoryRow.swift
│ │ │ │ └── AddCategoryView.swift
│ │ │ ├── Currency
│ │ │ │ ├── NewCurrencyRow.swift
│ │ │ │ ├── CurrencyRow.swift
│ │ │ │ └── AddCurrencyView.swift
│ │ │ ├── IconRow.swift
│ │ │ ├── SettingsFormattingView.swift
│ │ │ └── ColorAndIconView.swift
│ │ └── ViewModel
│ │ │ ├── ICloudSyncViewModel.swift
│ │ │ └── IconRowViewModel.swift
│ ├── Onboarding
│ │ └── OnboardingHeaderView.swift
│ ├── Home
│ │ ├── Views
│ │ │ └── BarChartBar.swift
│ │ └── ViewModels
│ │ │ └── BarChartViewModel.swift
│ ├── Spending
│ │ └── Views
│ │ │ └── ReturnRow.swift
│ └── Selectors
│ │ └── CustomColorSelector.swift
├── Protocols
│ └── ToSafeUnsafeObject.swift
├── financecontrolApp.swift
├── Data
│ ├── SocialNetworkModel.swift
│ ├── CoreData
│ │ ├── Entities
│ │ │ ├── ReturnEntity
│ │ │ │ ├── ReturnEntity+CoreDataProperties.swift
│ │ │ │ └── ReturnsModel.swift
│ │ │ └── CategoryEntity
│ │ │ │ ├── CategoryEntity+CoreDataClass.swift
│ │ │ │ └── CategoriesModel.swift
│ │ ├── DataManager.swift
│ │ └── DataContainer.xcdatamodeld
│ │ │ └── SpendingsModel.xcdatamodel
│ │ │ └── contents
│ └── Currency.swift
├── Utils
│ ├── PrivacyMonitor.swift
│ ├── Colors.swift
│ ├── Formatters.swift
│ ├── InputUtils.swift
│ ├── LaunchActions.swift
│ ├── CloudKitKVSManager.swift
│ ├── HapticManager.swift
│ ├── CustomIcon.swift
│ ├── NetworkMonitor.swift
│ ├── Keychain.swift
│ └── WidgetsManager.swift
├── Extensions
│ ├── AnyTransition+Extensions.swift
│ ├── Transition+Extensions.swift
│ ├── Color+Extensions.swift
│ ├── UDKey.swift
│ ├── Date+Extensions.swift
│ ├── URL+Extensions.swift
│ ├── NumbersViewModifier.swift
│ ├── OtherExtensions.swift
│ └── TimeZone+Extensions.swift
├── financecontrol.entitlements
└── Error
│ ├── CoreDataErrors.swift
│ ├── RatesErrors.swift
│ └── ErrorHandler.swift
├── financecontrolWidget
├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ ├── SquirrelLogoImage.imageset
│ │ └── Contents.json
│ ├── UpperWidgetColor.colorset
│ │ └── Contents.json
│ ├── WidgetBackground.colorset
│ │ └── Contents.json
│ └── BottomWidgetColor.colorset
│ │ └── Contents.json
├── financecontrolWidgetExtension.entitlements
├── Widget
│ ├── AccessorySumWidget.swift
│ ├── AccessoryCircularAddExpenseWidget.swift
│ ├── SmallSumWidget.swift
│ └── WeeklySpendingsWidget.swift
├── financecontrolWidgetBundle.swift
├── View
│ ├── AccessorySumWidgetView.swift
│ ├── AccessoryCircularAddExpenseView.swift
│ ├── AccessoryRectangularSumWidgetView.swift
│ ├── AccessoryCircularSumWidgetView.swift
│ ├── SumWidgetView.swift
│ └── WeeklySpendingsTodaySumView.swift
└── Timeline
│ ├── AddExpenseWidgetProvider.swift
│ └── SumWidgetTimelineProvider.swift
├── Squirrel.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── WorkspaceSettings.xcsettings
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── xcshareddata
│ └── xcschemes
│ ├── Squirrel_Release.xcscheme
│ ├── Squirrel_RTL.xcscheme
│ ├── Squirrel_Debug.xcscheme
│ └── Squirrel_CoreDataDebug.xcscheme
├── PrivacyInfo.xcprivacy
├── Vars.swift
├── README.md
├── financecontrolTests
├── CoreDataTests
│ └── CoreDataModelTests.swift
└── FallbackRatesTests.swift
└── .gitignore
/README/Screenshots/GitHubCharts_v4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubCharts_v4.png
--------------------------------------------------------------------------------
/README/Screenshots/GitHubHeader_v4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubHeader_v4.png
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/README/Screenshots/GitHubCurrencies_v4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubCurrencies_v4.png
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/README/Screenshots/GitHubFiltersAndExport_v4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubFiltersAndExport_v4.png
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/financecontrol/ViewModel/Protocols/ViewModelProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewModelProtocol.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/11/23.
6 | //
7 |
8 | import Foundation
9 |
10 | typealias ViewModel = ObservableObject
11 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | },
11 | "properties" : {
12 | "localizable" : true
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "iOS_AppIcon_Default.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/ListHorizontalScroll/ListHorizontalScrollRepresentable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ListHorizontalScrollRepresentable.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/03/25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | protocol ListHorizontalScrollRepresentable {
11 | var label: Text { get }
12 | var foregroundColor: Color { get }
13 | }
14 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Shapes/Line.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Line.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/03.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct Line: Shape {
11 | func path(in rect: CGRect) -> Path {
12 | var path = Path()
13 | path.move(to: CGPoint(x: 0, y: 0))
14 | path.addLine(to: CGPoint(x: rect.width, y: 0))
15 | return path
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/ViewModel/StatsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatsViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/04.
6 | //
7 |
8 | import Foundation
9 |
10 | final class StatsViewModel: ViewModel {
11 | @Published
12 | var entityToEdit: SpendingEntity? = nil
13 | @Published
14 | var entityToAddReturn: SpendingEntity? = nil
15 | @Published
16 | var edit: Bool = false
17 |
18 | init() {}
19 | }
20 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordBlue.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xC1",
9 | "green" : "0xA1",
10 | "red" : "0x81"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordBlueDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xA4",
9 | "green" : "0x7A",
10 | "red" : "0x51"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordGreen.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.549",
9 | "green" : "0.745",
10 | "red" : "0.639"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordRed.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.416",
9 | "green" : "0.380",
10 | "red" : "0.749"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordRedDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x44",
9 | "green" : "0x3B",
10 | "red" : "0x91"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordTeal.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xBB",
9 | "green" : "0xBC",
10 | "red" : "0x8F"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordTealDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xA2",
9 | "green" : "0xA3",
10 | "red" : "0x66"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Protocols/ToSafeUnsafeObject.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ToSafeUnsafeObject.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/07.
6 | //
7 |
8 | import CoreData
9 |
10 | protocol ToSafeObject {
11 | associatedtype SafeType
12 | func safeObject() throws -> SafeType
13 | }
14 |
15 | protocol ToUnsafeObject {
16 | associatedtype UnsafeType: NSManagedObject
17 | func unsafeObject(in context: NSManagedObjectContext) throws -> UnsafeType
18 | }
19 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordGreenDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x62",
9 | "green" : "0xA7",
10 | "red" : "0x82"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordOrange.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.439",
9 | "green" : "0.529",
10 | "red" : "0.816"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordOrangeDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x3C",
9 | "green" : "0x59",
10 | "red" : "0xB9"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordPurple.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.678",
9 | "green" : "0.557",
10 | "red" : "0.706"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordPurpleDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x8A",
9 | "green" : "0x62",
10 | "red" : "0x93"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordYellow.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.545",
9 | "green" : "0.796",
10 | "red" : "0.922"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/Nord/NordYellowDark.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x2E",
9 | "green" : "0xA2",
10 | "red" : "0xDC"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/financecontrol/financecontrolApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // financecontrolApp.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/06/26.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct financecontrolApp: App {
12 | init() {
13 | launch()
14 | }
15 |
16 | var body: some Scene {
17 | WindowGroup {
18 | ContentView()
19 | .environment(\.managedObjectContext, DataManager.shared.context)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/financecontrolWidget/financecontrolWidgetExtension.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.developer.default-data-protection
6 | NSFileProtectionComplete
7 | com.apple.security.application-groups
8 |
9 | group.dev.squirrelapp.squirrel
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "41422fc659ac624129ff9736794086adf81192e1d0196ff6c690a23d6a134cd3",
3 | "pins" : [
4 | {
5 | "identity" : "applepie",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/PinkXaciD/ApplePie",
8 | "state" : {
9 | "revision" : "815b1914ad5fee3485f6ec3deb1198b4c1447c9b",
10 | "version" : "2.0.0"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/UniversalViews.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UniversalViews.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | func hideKeyboardToolbar(action: @escaping () -> Void) -> ToolbarItemGroup {
11 | return ToolbarItemGroup(placement: .keyboard) {
12 | Spacer()
13 |
14 | Button(action: action) {
15 | Label("Hide keyboard", systemImage: "keyboard.chevron.compact.down")
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyAccessedAPITypes
6 |
7 |
8 | NSPrivacyAccessedAPIType
9 | NSPrivacyAccessedAPICategoryUserDefaults
10 | NSPrivacyAccessedAPITypeReasons
11 |
12 | 1C8F.1
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/financecontrol/Assets.xcassets/AppIcon_Image.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "iOS_AppIcon_Default_Image.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "iOS_AppIcon_Default_Image@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "iOS_AppIcon_Default_Image@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/CustomShareSheet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomShareSheet.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomShareSheet: UIViewControllerRepresentable {
11 | @Binding var url: URL
12 |
13 | func makeUIViewController(context: Context) -> UIActivityViewController {
14 | return UIActivityViewController(activityItems: [url], applicationActivities: nil)
15 | }
16 |
17 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
18 | }
19 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/SquirrelLogoImage.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "SquirrelLogoImageWidget.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "SquirrelLogoImageWidget@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "SquirrelLogoImageWidget@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/financecontrol/Data/SocialNetworkModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SocialNetworkModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2024/10/29.
6 | //
7 |
8 | import Foundation
9 |
10 | struct SocialNetworkModel: Codable, Equatable {
11 | let urlString: String
12 | let name: String
13 | let displayUsername: String
14 |
15 | enum CodingKeys: String, CodingKey {
16 | case name
17 | case urlString = "url"
18 | case displayUsername = "display_username"
19 | }
20 |
21 | func getURL() -> URL? {
22 | URL(string: self.urlString)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Vars.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Vars.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/17.
6 | //
7 |
8 | import Foundation
9 |
10 | struct Vars {
11 | private init() {}
12 |
13 | static let groupName: String = "group.dev.squirrelapp.squirrel"
14 |
15 | static let appIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.squirrelapp.squirrel"
16 |
17 | static let widgetIdentifier: String = appIdentifier + ".squirrelWidget"
18 |
19 | static let iCloudContainerIdentifier: String = "iCloud.dev.squirrelapp.squirrel"
20 |
21 | static let privacyBlur: CGFloat = 10
22 | }
23 |
--------------------------------------------------------------------------------
/financecontrol/ViewModel/Protocols/SpendingViewModelProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SpendingViewModelProtocol.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/11/23.
6 | //
7 |
8 | import Foundation
9 |
10 | protocol SpendingViewModel: ViewModel {
11 | var cdm: CoreDataModel { get }
12 | var rvm: RatesViewModel { get }
13 |
14 | var amount: String { get }
15 | var currency: String { get }
16 | var date: Date { get }
17 | var categoryName: String { get }
18 | var categoryId: UUID { get }
19 | var place: String { get }
20 | var comment: String { get }
21 |
22 | func done()
23 | }
24 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/ViewModel/StatsRowViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatsRowViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/02/19.
6 | //
7 |
8 | import Foundation
9 |
10 | final class StatsRowViewModel: ViewModel {
11 | @Published
12 | var hOffset: CGFloat = 0
13 | @Published
14 | var showLeadingButtons: UUID? = nil
15 | @Published
16 | var showTrailingButtons: UUID? = nil
17 | @Published
18 | var triggerLeadingAction: UUID? = nil
19 | @Published
20 | var triggerTrailingAction: UUID? = nil
21 | @Published
22 | var lastDragged: UUID? = nil
23 |
24 | init() {}
25 | }
26 |
--------------------------------------------------------------------------------
/financecontrol/Utils/PrivacyMonitor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrivacyMonitor.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/12.
6 | //
7 |
8 | import Foundation
9 |
10 | final class PrivacyMonitor: ObservableObject {
11 | @Published private(set) var privacyScreenIsEnabled: Bool
12 | @Published private(set) var hideExpenseSum: Bool
13 |
14 | init(privacyScreenIsEnabled: Bool, hideExpenseSum: Bool) {
15 | self.privacyScreenIsEnabled = privacyScreenIsEnabled
16 | self.hideExpenseSum = hideExpenseSum
17 | }
18 |
19 | func changePrivacyScreenValue(_ newValue: Bool) {
20 | self.privacyScreenIsEnabled = newValue
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/AnyTransition+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AnyTransition+Extensions.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/02/27.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension AnyTransition {
11 | static var maskFromTheBottomWithOpacity: AnyTransition {
12 | .modifier(active: MaskFromTheBottomModifier(isActive: true, withOpacity: true), identity: MaskFromTheBottomModifier(isActive: false, withOpacity: true))
13 | }
14 |
15 | static var maskFromTheBottom: AnyTransition {
16 | .modifier(active: MaskFromTheBottomModifier(isActive: true, withOpacity: false), identity: MaskFromTheBottomModifier(isActive: false, withOpacity: false))
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/Transition+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransitionExtensions.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/03/14.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension AnyTransition {
11 | static var horizontalMoveForward: AnyTransition {
12 | AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))
13 | }
14 |
15 | static var horizontalMoveBackward: AnyTransition {
16 | AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing))
17 | }
18 |
19 | static var moveFromBottom: AnyTransition {
20 | AnyTransition.move(edge: .bottom).combined(with: .opacity)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Custom Alert/CustomAlertViewExtension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomAlertViewExtension.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/02.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension View {
11 | func customAlert() -> some View {
12 | return self
13 | .overlay(alignment: .top) {
14 | CustomAlerts()
15 | }
16 | }
17 | }
18 |
19 | private struct CustomAlerts: View {
20 | @ObservedObject private var manager = CustomAlertManager.shared
21 |
22 | var body: some View {
23 | VStack(spacing: 10) {
24 | ForEach(manager.alerts.reversed()) { alert in
25 | CustomAlertView(data: alert)
26 | }
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/financecontrolWidget/Widget/AccessorySumWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessoryRectangularSumWidget.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/12.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | @available(iOS 16.0, *)
12 | struct AccessorySumWidget: Widget {
13 | let kind: String = "AccessorySumWidget"
14 |
15 | var body: some WidgetConfiguration {
16 | StaticConfiguration(kind: kind, provider: SumWidgetTimelineProvider()) { entry in
17 | AccessorySumWidgetView(entry: entry)
18 | }
19 | .configurationDisplayName("Today's expenses")
20 | .description("Your expenses for today.")
21 | .supportedFamilies([.accessoryRectangular, .accessoryCircular])
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/ViewModel/ICloudSyncViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ICloudSyncViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/01/06.
6 | //
7 |
8 | import Foundation
9 |
10 | final class ICloudSyncViewModel: ObservableObject {
11 | @Published
12 | private(set) var dataStoredInCloudKit: Bool
13 |
14 | init() {
15 | self.dataStoredInCloudKit = false
16 |
17 | Task {
18 | await updateDataStatus()
19 | }
20 | }
21 |
22 | func updateDataStatus() async {
23 | let result = await CloudKitManager.shared.hasDataInCloudKit()
24 |
25 | await MainActor.run { [weak self] in
26 | self?.dataStoredInCloudKit = result
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/ViewModel/StatsSearchViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatsSearchViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/03/05.
6 | //
7 |
8 | import Foundation
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | final class StatsSearchViewModel: SearchViewModel {
14 | override init() {
15 | super.init()
16 | #if DEBUG
17 | let logger = Logger(subsystem: Vars.appIdentifier, category: "StatsSearchViewModel.swift")
18 | logger.debug("ViewModel initialized")
19 | #endif
20 | }
21 |
22 | deinit {
23 | #if DEBUG
24 | let logger = Logger(subsystem: Vars.appIdentifier, category: "StatsSearchViewModel.swift")
25 | logger.debug("ViewModel deinitialized")
26 | #endif
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/financecontrolWidget/Widget/AccessoryCircularAddExpenseWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessoryCircularAddExpenseWidget.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/13.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | @available(iOS 16.0, *)
12 | struct AccessoryCircularAddExpenseWidget: Widget {
13 | let kind: String = "AccessoryCircularAddExpense"
14 |
15 | var body: some WidgetConfiguration {
16 | StaticConfiguration(kind: kind, provider: AddExpenseWidgetProvider()) { entry in
17 | AccessoryCircularAddExpenseView(entry: entry)
18 | }
19 | .configurationDisplayName("Add expense")
20 | .description("Quickly add expense from lock screen.")
21 | .supportedFamilies([.accessoryCircular])
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/financecontrolWidget/Widget/SmallSumWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // financecontrolSmallSumWidget.swift
3 | // financecontrolWidget
4 | //
5 | // Created by PinkXaciD on R 6/01/05.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | //struct SmallSumWidget: Widget {
12 | // let kind: String = "SmallSumWidget"
13 | // let currency: String = UserDefaults.standard.string(forKey: UDKeys.defaultCurrency.rawValue) ?? "JPY"
14 | //
15 | // var body: some WidgetConfiguration {
16 | // StaticConfiguration(kind: kind, provider: SumWidgetTimelineProvider()) { entry in
17 | // SmallSumWidgetView(entry: entry)
18 | // }
19 | // .configurationDisplayName("Today's expenses")
20 | // .description("Your expenses for today.")
21 | // .supportedFamilies([.systemSmall])
22 | // }
23 | //}
24 |
--------------------------------------------------------------------------------
/financecontrol/financecontrol.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | aps-environment
6 | development
7 | com.apple.developer.icloud-container-identifiers
8 |
9 | iCloud.dev.squirrelapp.squirrel
10 |
11 | com.apple.developer.icloud-services
12 |
13 | CloudKit
14 |
15 | com.apple.developer.ubiquity-kvstore-identifier
16 | $(TeamIdentifierPrefix)$(CFBundleIdentifier)
17 | com.apple.security.application-groups
18 |
19 | group.dev.squirrelapp.squirrel
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/financecontrol/Utils/Colors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Colors.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/07/28.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomColor {
11 | static let nordAurora: [String:Color] = [
12 | "nord1" : Color("NordRed"),
13 | "nord2" : Color("NordOrange"),
14 | "nord3" : Color("NordYellow"),
15 | "nord4" : Color("NordGreen"),
16 | "nord5" : Color("NordTeal"),
17 | "nord6" : Color("NordBlue"),
18 | "nord7" : Color("NordPurple"),
19 | "nord8" : Color("NordRedDark"),
20 | "nord9" : Color("NordOrangeDark"),
21 | "nord91" : Color("NordYellowDark"),
22 | "nord92" : Color("NordGreenDark"),
23 | "nord93" : Color("NordTealDark"),
24 | "nord94" : Color("NordBlueDark"),
25 | "nord95" : Color("NordPurpleDark")
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/financecontrol/View/Onboarding/OnboardingHeaderView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OnboardingHeaderView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/09/06.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct OnboardingHeaderView: View {
11 | let header: LocalizedStringKey
12 | let description: LocalizedStringKey
13 |
14 | var body: some View {
15 | HStack {
16 | VStack(alignment: .leading) {
17 | Text(header)
18 | .font(.largeTitle.bold())
19 | .lineLimit(1)
20 |
21 | Text(description)
22 | .font(.footnote)
23 | .foregroundColor(.secondary)
24 | .lineLimit(3)
25 | }
26 |
27 | Spacer()
28 | }
29 | .foregroundColor(.primary)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/financecontrol/Data/CoreData/Entities/ReturnEntity/ReturnEntity+CoreDataProperties.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReturnEntity+CoreDataProperties.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/11/30.
6 | //
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 |
12 |
13 | extension ReturnEntity {
14 |
15 | @nonobjc public class func fetchRequest() -> NSFetchRequest {
16 | return NSFetchRequest(entityName: "ReturnEntity")
17 | }
18 |
19 | @NSManaged public var id: UUID?
20 | @NSManaged public var amount: Double
21 | @NSManaged public var amountUSD: Double
22 | @NSManaged public var name: String?
23 | @NSManaged public var currency: String?
24 | @NSManaged public var date: Date?
25 | @NSManaged public var spending: SpendingEntity?
26 |
27 | }
28 |
29 | extension ReturnEntity : Identifiable {
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Custom Alert/CustomAlertType.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomAlertTypes.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum CustomAlertType: Hashable {
11 | case error, warning, success, info
12 |
13 | var color: Color {
14 | switch self {
15 | case .error:
16 | .red
17 | case .warning:
18 | .yellow
19 | case .success:
20 | .green
21 | case .info:
22 | .blue
23 | }
24 | }
25 |
26 | var haptic: UINotificationFeedbackGenerator.FeedbackType? {
27 | switch self {
28 | case .error:
29 | .error
30 | case .warning:
31 | .warning
32 | case .success:
33 | .success
34 | case .info:
35 | nil
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Squirrel
2 |
3 | 
4 |
5 | [](https://apps.apple.com/jp/app/squirrel-expense-tracker/id6477331498)
6 |
7 | Available for iOS 15 and later.
8 |
9 | ## Why Squirrel?
10 | - Simple and intuitive interface
11 | - No account needed
12 | - All data stored locally, on your device
13 |
14 | ## Main functions:
15 | - ### Simple and clear charts
16 | 
17 |
18 | - ### Track expenses in different currencies with exchange rates updated every hour.
19 | 
20 |
21 | - ### Advanced filters and data export
22 | 
23 |
24 | *This code is available under a GPL v3 license. [Learn more.](LICENSE)*
25 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Custom Alert/CustomAlertManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomAlertManager.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/02.
6 | //
7 |
8 | import SwiftUI
9 |
10 | final class CustomAlertManager: ObservableObject {
11 | static let shared = CustomAlertManager()
12 |
13 | @Published
14 | var alerts: [CustomAlertData]
15 |
16 | private init() {
17 | self.alerts = []
18 | }
19 |
20 | func addAlert(_ alert: CustomAlertData) {
21 | withAnimation(.bouncy) {
22 | self.alerts.append(alert)
23 | }
24 | }
25 |
26 | func removeAlert(_ id: UUID) {
27 | if !self.alerts.isEmpty, let index = self.alerts.firstIndex(where: { $0.id == id }) {
28 | let _ = withAnimation(.bouncy) {
29 | self.alerts.remove(at: index)
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/UpperWidgetColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "display-p3",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xFF",
9 | "green" : "0xFF",
10 | "red" : "0xFF"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "display-p3",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x3C",
27 | "green" : "0x3A",
28 | "red" : "0x3A"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "display-p3",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x00",
9 | "green" : "0x95",
10 | "red" : "0xFF"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "display-p3",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x0A",
27 | "green" : "0x9F",
28 | "red" : "0xFF"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/financecontrolWidget/Assets.xcassets/BottomWidgetColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "display-p3",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xD6",
9 | "green" : "0xD1",
10 | "red" : "0xD1"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "display-p3",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x1E",
27 | "green" : "0x1C",
28 | "red" : "0x1C"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/financecontrol/Utils/Formatters.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Formatters.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/07/13.
6 | //
7 |
8 | import SwiftUI
9 |
10 | func colorIdentifier(color: String) -> Color {
11 | switch color {
12 | case "Blue":
13 | return Color.blue
14 | case "Red":
15 | return Color.red
16 | case "Pink":
17 | return Color.pink
18 | case "Mint":
19 | return Color.mint
20 | case "Orange":
21 | return Color.orange
22 | case "Purple":
23 | return Color.purple
24 | case "Indigo":
25 | return Color.indigo
26 | case "Teal":
27 | return Color.teal
28 | default:
29 | return Color.accentColor
30 | }
31 | }
32 |
33 | func themeConvert(autoDarkMode: Bool, darkMode: Bool) -> ColorScheme? {
34 | if autoDarkMode {
35 | return nil
36 | }
37 |
38 | if darkMode {
39 | return .dark
40 | } else {
41 | return .light
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/financecontrol/ViewModel/SearchViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/03/05.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 |
11 | class SearchViewModel: ViewModel {
12 | @Published var search: String = ""
13 | @Published var input: String = ""
14 | var cancellables = Set()
15 |
16 | init() {
17 | updateSearch()
18 | }
19 |
20 | deinit {
21 | cancellables.cancelAll()
22 | }
23 |
24 | func getPublisher() -> Published.Publisher {
25 | return $search
26 | }
27 |
28 | func updateSearch() {
29 | self.$input
30 | .debounce(for: 0.3, scheduler: DispatchQueue.main)
31 | .sink { [weak self] value in
32 | withAnimation {
33 | self?.search = value.trimmingCharacters(in: .whitespacesAndNewlines)
34 | }
35 | }
36 | .store(in: &cancellables)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/financecontrolWidget/financecontrolWidgetBundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // financecontrolWidgetBundle.swift
3 | // financecontrolWidget
4 | //
5 | // Created by PinkXaciD on R 6/01/05.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | @main
12 | struct financecontrolWidgetBundle: WidgetBundle {
13 |
14 | @WidgetBundleBuilder
15 | var body: some Widget {
16 | getWidgets()
17 | }
18 |
19 | private func getWidgets() -> some Widget {
20 | if #available(iOS 16.0, *) {
21 | return WidgetBundleBuilder.buildBlock(
22 | // SmallSumWidget(),
23 | WeeklySpendingsAccessoryWidget(),
24 | AccessorySumWidget(),
25 | AccessoryCircularAddExpenseWidget(),
26 | WeeklySpendingsWidget()
27 | )
28 | } else {
29 | return WidgetBundleBuilder.buildBlock(
30 | // SmallSumWidget(),
31 | WeeklySpendingsWidget()
32 | )
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Custom Alert/CustomAlertViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomAlertViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/02.
6 | //
7 |
8 | import Combine
9 | import UIKit
10 |
11 | final class CustomAlertViewModel: ObservableObject {
12 | let id: UUID
13 |
14 | let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
15 | var cancellables = Set()
16 |
17 | init(id: UUID, haptic: UINotificationFeedbackGenerator.FeedbackType?) {
18 | self.id = id
19 |
20 | if let haptic {
21 | HapticManager.shared.notification(haptic)
22 | }
23 |
24 | catchTimer()
25 | }
26 |
27 | func catchTimer() {
28 | self.timer
29 | .sink { [weak self] _ in
30 | if let id = self?.id {
31 | CustomAlertManager.shared.removeAlert(id)
32 | }
33 | }
34 | .store(in: &cancellables)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/ViewModel/IconRowViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IconRowViewModel.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/08.
6 | //
7 |
8 | import SwiftUI
9 |
10 | final class IconRowViewModel: ViewModel {
11 | let icon: CustomIcon
12 | @Binding var selectedIcon: String?
13 |
14 | init(icon: CustomIcon, selection: Binding) {
15 | self.icon = icon
16 | self._selectedIcon = selection
17 | }
18 |
19 | func setIcon() {
20 | UIApplication.shared.setAlternateIconName(icon.fileName, completionHandler: completionHandler)
21 | }
22 |
23 | private func completionHandler(_ error: Error?) {
24 | if let error = error {
25 | ErrorType(error: error).publish()
26 | return
27 | }
28 |
29 | withAnimation {
30 | selectedIcon = icon.fileName
31 | }
32 | }
33 |
34 | func getIconImage() -> Image {
35 | return Image(icon.imageName, bundle: .main)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/financecontrolWidget/View/AccessorySumWidgetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessoryRectangularSumWidgetView.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/12.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | @available(iOS 16.0, *)
12 | struct AccessorySumWidgetView: View {
13 | @Environment(\.widgetFamily) private var widgetFamily
14 |
15 | let entry: SumEntry
16 |
17 | var body: some View {
18 | if widgetFamily == .accessoryCircular {
19 | AccessoryCircularSumWidgetView(entry: entry)
20 | } else {
21 | AccessoryRectangularSumWidgetView(entry: entry)
22 | }
23 | }
24 | }
25 |
26 |
27 | #if DEBUG
28 | struct AccessoryRectangularSumWidgetPreview: PreviewProvider {
29 | static var previews: some View {
30 | if #available(iOS 16.0, *) {
31 | AccessorySumWidgetView(entry: .init(date: .now, expenses: 1200, currency: "JPY"))
32 | .previewContext(WidgetPreviewContext(family: .accessoryCircular))
33 | }
34 | }
35 | }
36 | #endif
37 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/Color+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ColorExtensions.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/09/11.
6 | //
7 |
8 | import SwiftUI
9 |
10 | extension Color {
11 | static subscript(name: String) -> Color {
12 | switch name {
13 | case "red":
14 | return .red
15 | case "orange":
16 | return .orange
17 | case "yellow":
18 | return .yellow
19 | case "green":
20 | return .green
21 | case "teal":
22 | return .teal
23 | case "blue":
24 | return .blue
25 | case "purple":
26 | return .purple
27 | case "pink":
28 | return .pink
29 | case "nord1", "nord2", "nord3", "nord4", "nord5", "nord6", "nord7", "nord8", "nord9", "nord91", "nord92", "nord93", "nord94", "nord95":
30 | return CustomColor.nordAurora[name] ?? .black
31 | case "secondary":
32 | return .secondary
33 | default:
34 | return .clear
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/UDKey.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UDKeys.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2024/10/24.
6 | //
7 |
8 | import Foundation
9 |
10 | enum UDKey: String {
11 | case presentOnboarding
12 | case color
13 | case defaultCurrency
14 | case defaultSelectedCurrency
15 | case savedCurrencies
16 | case minimizeLegend
17 | case rates
18 | case updateTime
19 | case updateRates
20 | case autoDarkMode
21 | case darkMode
22 | case privacyScreen
23 | case separateCurrencies
24 | case ratesFetchQueue
25 | case formatWithoutTimeZones
26 | case githubURL
27 | case appWebsiteURL
28 | case urlUpdateVersion
29 | case socialNetworksUpdateVersion
30 | case socialNetworksJSON
31 | case iCloudSync
32 | case timeZoneFormat
33 |
34 | static var urlKeys: [Self] {
35 | [.appWebsiteURL, .githubURL]
36 | }
37 |
38 | var ckID: String? {
39 | switch self {
40 | case .appWebsiteURL:
41 | "website"
42 | case .githubURL:
43 | "github"
44 | default:
45 | nil
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/financecontrolTests/CoreDataTests/CoreDataModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CoreDataModelTests.swift
3 | // financecontrolTests
4 | //
5 | // Created by PinkXaciD on R 5/12/28.
6 | //
7 |
8 | import XCTest
9 | @testable import Squirrel
10 |
11 | final class CoreDataModelTests: XCTestCase {
12 |
13 | var cdm: CoreDataModel? = nil
14 |
15 | override func setUp() {
16 | super.setUp()
17 | cdm = CoreDataModel()
18 | }
19 |
20 | override func tearDown() {
21 | super.tearDown()
22 |
23 | cdm = nil
24 | }
25 |
26 | func testInitSettingUpProperties() {
27 | // MARK: Currencies now setting up from onboarding
28 | // if let currencies = cdm?.savedCurrencies, let currency = currencies.first {
29 | // XCTAssertEqual(currency.tag, Locale.current.currencyCode)
30 | // } else {
31 | // XCTFail("Currency not setted up")
32 | // }
33 |
34 | // XCTAssertTrue(cdm?.savedCurrencies == [])
35 | XCTAssertTrue(cdm?.savedSpendings == [])
36 | XCTAssertTrue(cdm?.savedCategories == [])
37 | XCTAssertTrue(cdm?.shadowedCategories == [])
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/financecontrol/Error/CoreDataErrors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CoreDataErrors.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/02/21.
6 | //
7 |
8 | import Foundation
9 |
10 | enum CoreDataError: LocalizedError {
11 | case failedToGetEntityDescription, failedToFindCategory
12 | }
13 |
14 | extension CoreDataError {
15 | var failureReason: String? {
16 | switch self {
17 | case .failedToGetEntityDescription:
18 | return NSLocalizedString("Failed to get CoreData entity description", comment: "")
19 | case .failedToFindCategory:
20 | return NSLocalizedString("Failed to find category for this spending", comment: "")
21 | }
22 | }
23 |
24 | var errorDescription: String? {
25 | switch self {
26 | case .failedToGetEntityDescription, .failedToFindCategory:
27 | return NSLocalizedString("It seems some system files are corrupted", comment: "")
28 | }
29 | }
30 |
31 | var recoverySuggestion: String? {
32 | switch self {
33 | case .failedToGetEntityDescription, .failedToFindCategory:
34 | return NSLocalizedString("Please submit a bug report and try to restart or reinstall the app", comment: "")
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/financecontrolWidget/Widget/WeeklySpendingsWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeeklySpendingsWidget.swift
3 | // SquirrelWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/07/26.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | struct WeeklySpendingsWidget: Widget {
12 | let kind = "WeeklySpendingsWidget"
13 |
14 | var body: some WidgetConfiguration {
15 | StaticConfiguration(kind: kind, provider: WeeklySpendingsWidgetProvider()) { entry in
16 | WeeklySpendingsWidgetView(entry: entry)
17 | }
18 | .configurationDisplayName("Weekly expenses")
19 | .description("Your expenses for this week.")
20 | .supportedFamilies([.systemMedium, .systemSmall])
21 | }
22 | }
23 |
24 | @available(iOS 16.0, *)
25 | struct WeeklySpendingsAccessoryWidget: Widget {
26 | let kind = "WeeklySpendingsAccessoryWidget"
27 |
28 | var body: some WidgetConfiguration {
29 | StaticConfiguration(kind: kind, provider: WeeklySpendingsWidgetProvider()) { entry in
30 | WeeklySpendingsAccessoryWidgetView(entry: entry)
31 | }
32 | .configurationDisplayName("Weekly expenses")
33 | .description("Your expenses for this week.")
34 | .supportedFamilies([.accessoryRectangular])
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/Date+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DateExtension.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/10.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Date {
11 | static let firstAvailableDate: Date = Date(timeIntervalSinceReferenceDate: 599_529_600) // 2020/01/01, 0:00 GMT
12 |
13 | var previousDay: Date {
14 | guard let date = Calendar.current.date(byAdding: .day, value: -1, to: self) else {
15 | return self
16 | }
17 | return date
18 | }
19 |
20 | func getFirstDayOfMonth(_ value: Int = 0) -> Date {
21 | guard let date = Calendar.current.date(byAdding: .month, value: value, to: self) else {
22 | return self
23 | }
24 |
25 | var components: DateComponents = Calendar.current.dateComponents([.month, .year, .era], from: date)
26 | components.calendar = Calendar.current
27 |
28 | guard let newDate = components.date else {
29 | return self
30 | }
31 |
32 | return newDate
33 | }
34 |
35 | var weekAgoUnwrapped: Self {
36 | let date = Calendar.current.startOfDay(for: self)
37 | return Calendar.current.date(byAdding: .day, value: -6, to: date) ?? Date()
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/financecontrol/Utils/InputUtils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AmountInputUtils.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/07/28.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct InputUtils {
11 | func checkAll(amount: String, place: String, comment: String) -> Bool {
12 | amountCheck(amount: amount)
13 | &&
14 | placeCheck(place: place)
15 | &&
16 | commentCheck(comment: comment)
17 | }
18 |
19 | func amountCheck(amount: String) -> Bool {
20 | let formatter = NumberFormatter.standard
21 |
22 | guard
23 | let number = formatter.number(from: amount),
24 | Double(truncating: number) < Double.greatestFiniteMagnitude,
25 | Double(truncating: number) > 0
26 | else {
27 | return false
28 | }
29 |
30 | return true
31 | }
32 |
33 | func placeCheck(place: String) -> Bool {
34 | guard
35 | place.count <= 100
36 | else {
37 | return false
38 | }
39 |
40 | return true
41 | }
42 |
43 | func commentCheck(comment: String) -> Bool {
44 | guard
45 | comment.count <= 300
46 | else {
47 | return false
48 | }
49 |
50 | return true
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/URL+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URL+Extensions.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2024/10/23.
6 | //
7 |
8 | import Foundation
9 |
10 | extension URL {
11 | static let addExpenseAction: URL! = URL(string: "squirrel://addExpense")
12 |
13 | static let github: URL = {
14 | UserDefaults.standard.url(forKey: UDKey.githubURL.rawValue) ?? URL(string: "https://github.com/PinkXaciD/Squirrel")!
15 | }()
16 |
17 | static let newGithubIssue: URL! = URL(string: "\(github.absoluteString)/issues/new")
18 |
19 | static let githubChangelog: URL! = URL(string: "\(github.absoluteString)/releases/tag/v\(Bundle.main.releaseVersionNumber ?? "")")
20 |
21 | static let appWebsite: URL = {
22 | UserDefaults.standard.url(forKey: UDKey.appWebsiteURL.rawValue) ?? URL(string: "https://squirrelapp.dev")!
23 | }()
24 |
25 | static let privacyPolicy: URL! = URL(string: "\(appWebsite.absoluteString)/privacy")
26 |
27 | static let appEmail: String = {
28 | guard let websiteURLString = URL.appWebsite.absoluteString.split(separator: "/").last else {
29 | return "contact@\(URL.appWebsite.absoluteString.replacingOccurrences(of: "https://", with: ""))"
30 | }
31 |
32 | return "contact@\(websiteURLString)"
33 | }()
34 | }
35 |
--------------------------------------------------------------------------------
/financecontrol/View/Home/Views/BarChartBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BarChartBar.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/27.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct BarChartBar: View {
11 | let index: Int
12 | var data: (key: Date, value: Double)
13 | var isActive: Bool
14 | let maxHeight: CGFloat
15 | let cornerRadius: CGFloat
16 |
17 | var body: some View {
18 | ZStack(alignment: .bottom) {
19 | Rectangle()
20 | .frame(height: maxHeight)
21 | .foregroundStyle(.secondary)
22 | .opacity(isActive ? 0.1 : 0.07)
23 |
24 | RoundedRectangle(cornerRadius: cornerRadius)
25 | .frame(height: data.value)
26 | .foregroundStyle(.primary)
27 | .opacity(isActive ? 1 : 0.7)
28 | }
29 | .hoverEffect()
30 | }
31 | }
32 |
33 | struct BarChartBar_Previews: PreviewProvider {
34 | static var cornerRadius: CGFloat {
35 | if #available(iOS 26.0, *) {
36 | return 10
37 | }
38 |
39 | return 5
40 | }
41 |
42 | static var previews: some View {
43 | BarChartBar(index: 1, data: (key: Date.now, value: 1.0), isActive: true, maxHeight: UIScreen.main.bounds.height / 5, cornerRadius: cornerRadius)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/NumbersViewModifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NumberaViewModifier.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/03.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 |
11 | struct NumbersViewModifier: ViewModifier {
12 | @Binding var text: String
13 | let currency: Currency
14 |
15 | func body(content: Content) -> some View {
16 | content
17 | .onReceive(Just(text.replacingOccurrences(of: "٫", with: Locale.current.decimalSeparator ?? "."))) { newValue in
18 | let decimalSeparator: String = Locale.current.decimalSeparator ?? "."
19 | var filteredText = newValue.filter { $0.isNumber || $0 == decimalSeparator.first ?? "." }
20 |
21 | while validate(filteredText.components(separatedBy: decimalSeparator)) {
22 | filteredText = String(filteredText.dropLast())
23 | }
24 |
25 | if newValue != filteredText {
26 | self.text = filteredText
27 | }
28 | }
29 | }
30 |
31 | private func validate(_ components: [String]) -> Bool {
32 | if currency.fractionDigits == 0 {
33 | return components.count > 1
34 | }
35 |
36 | return components.count > 2 || (components.count == 2 && components[1].count > currency.fractionDigits)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Custom Alert/CustomAlertData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomAlertData.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/02.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomAlertData: Identifiable {
11 | let id: UUID = .init()
12 | let type: CustomAlertType
13 | let title: LocalizedStringKey
14 | let description: LocalizedStringKey?
15 | let systemImage: String
16 |
17 | init(type: CustomAlertType, title: LocalizedStringKey, description: LocalizedStringKey? = nil, systemImage: String = "questionmark.circle") {
18 | self.type = type
19 | self.title = title
20 | self.description = description
21 | self.systemImage = systemImage
22 | }
23 |
24 | static func noConnection(_ description: LocalizedStringKey? = nil) -> Self {
25 | let image: String = {
26 | if #available(iOS 17, *) {
27 | return "network.slash"
28 | } else {
29 | return "network"
30 | }
31 | }()
32 |
33 | return CustomAlertData(
34 | type: .warning,
35 | title: "No connection",
36 | description: description,
37 | systemImage: image
38 | )
39 | }
40 |
41 | static func error(_ error: LocalizedError) -> Self {
42 | .init(type: .error, title: "Error", description: LocalizedStringKey(error.localizedDescription), systemImage: "xmark.circle")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | Info.plist
2 | Icon.png
3 | /.vscode/settings.json
4 |
5 | financecontrol/Assets.xcassets/AppIcon.appiconset/iOS_AppIcon_Default.png
6 | financecontrol/Assets.xcassets/AppIcon_Image.imageset/*.png
7 |
8 | financecontrol/Assets.xcassets/Icons/AppIcon_FirstFlight.appiconset/iOS_AppIcon_First_Flight.png
9 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_FirstFlight_Image.imageset/*.png
10 |
11 | financecontrol/Assets.xcassets/Icons/AppIcon_NeonNight.appiconset/iOS_AppIcon_Neon.png
12 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_NeonNight_Image.imageset/*.png
13 |
14 | financecontrol/Assets.xcassets/Icons/AppIcon_Winterized.appiconset/iOS_AppIcon_Winterized.png
15 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_Winterized_Image.imageset/*.png
16 |
17 | financecontrol/Assets.xcassets/Icons/AppIcon_DawnOfSquipan.appiconset/iOS_AppIcon_DawnOfSquipan.png
18 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_DawnOfSquipan_Image.imageset/*.png
19 |
20 | financecontrol/Assets.xcassets/Icons/AppIcon_NA.appiconset/iOS_AppIcon_NA.png
21 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_NA_Image.imageset/*.png
22 |
23 | financecontrol/Assets.xcassets/Icons/AppIcon_Stealth.appiconset/*.png
24 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_Stealth_Image.imageset/*.png
25 |
26 | financecontrol/Assets.xcassets/
27 |
28 | financecontrolWidget/Assets.xcassets/SquirrelLogoImage.imageset/*.png
29 |
30 | /Squirrel.xcodeproj/xcuserdata
31 | /Squirrel.xcodeproj/project.xcworkspace/xcuserdata
32 |
33 | CommitMessage.md
34 |
--------------------------------------------------------------------------------
/financecontrol/Data/Currency.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Currency.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 5/09/07.
6 | //
7 |
8 | import Foundation
9 |
10 | struct Currency: Hashable, Comparable, Identifiable {
11 | static func < (lhs: Currency, rhs: Currency) -> Bool {
12 | return (lhs.name ?? lhs.code) < (rhs.name ?? rhs.code)
13 | }
14 |
15 | let code: String
16 |
17 | var id: String {
18 | self.code
19 | }
20 |
21 | var name: String? {
22 | Locale.current.localizedString(forCurrencyCode: code)
23 | }
24 |
25 | var fractionDigits: Int {
26 | Locale.current.currencyFractionDigits(currencyCode: self.code)
27 | }
28 |
29 | static func getAll() -> [Currency] {
30 | return Locale.customCommonISOCurrencyCodes.map { .init(code: $0) }
31 | }
32 |
33 | static var localeCurrency: Currency? {
34 | if let code = Locale.current.currencyCode {
35 | return Currency(code: code)
36 | }
37 |
38 | return nil
39 | }
40 | }
41 |
42 | extension Locale {
43 | func getCurrency() -> Squirrel.Currency? {
44 | let currencyCode = {
45 | if #available(iOS 16, *) {
46 | return self.currency?.identifier
47 | } else {
48 | return self.currencyCode
49 | }
50 | }()
51 |
52 | if let currencyCode {
53 | return .init(code: currencyCode)
54 | }
55 |
56 | return nil
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/financecontrol/Utils/LaunchActions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LaunchActions.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/21.
6 | //
7 |
8 | import Foundation
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | func launch() {
14 | let dateFormatter = ISO8601DateFormatter()
15 | #if DEBUG
16 | let _ = CloudKitManager.shared
17 | #endif
18 |
19 | // MARK: Rates update scheduling
20 | let updateTime = UserDefaults.standard.string(forKey: UDKey.updateTime.rawValue) ?? dateFormatter.string(from: .distantPast)
21 |
22 | if !Calendar.current.isDate(dateFormatter.date(from: updateTime) ?? .distantPast, equalTo: .now, toGranularity: .hour) {
23 | #if DEBUG
24 | let logger = Logger(subsystem: Vars.appIdentifier, category: "\(#fileID)")
25 | logger.debug("Rates aren't up to date")
26 | logger.debug("Last updated at: \(updateTime)")
27 | logger.info("Updating rates...")
28 | #endif
29 |
30 | UserDefaults.standard.set(true, forKey: "updateRates")
31 | }
32 |
33 | // MARK: Currency checks
34 | if UserDefaults.standard.string(forKey: UDKey.defaultCurrency.rawValue) == nil {
35 | UserDefaults.standard.set(Locale.current.currencyCode ?? "USD", forKey: UDKey.defaultCurrency.rawValue)
36 | }
37 |
38 | if let sharedDefaults = UserDefaults(suiteName: Vars.groupName), sharedDefaults.string(forKey: UDKey.defaultCurrency.rawValue) == nil {
39 | sharedDefaults.set(Locale.current.currencyCode ?? "USD", forKey: UDKey.defaultCurrency.rawValue)
40 | WidgetsManager.shared.reloadSumWidgets()
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/financecontrolWidget/Timeline/AddExpenseWidgetProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddExpenseWidgetProvider.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/13.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 | #if DEBUG
11 | import OSLog
12 | #endif
13 |
14 | struct AddExpenseWidgetProvider: TimelineProvider {
15 | func placeholder(in context: Context) -> AddExpenseEntry {
16 | AddExpenseEntry(date: Date(), image: { Image(.squirrelLogo) }, url: .addExpenseAction)
17 | }
18 |
19 | func getSnapshot(in context: Context, completion: @escaping (AddExpenseEntry) -> Void) {
20 | let entry = AddExpenseEntry(date: Date(), image: { Image(.squirrelLogo) }, url: .addExpenseAction)
21 | completion(entry)
22 | }
23 |
24 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
25 | var entries: [AddExpenseEntry] = []
26 | #if DEBUG
27 | let logger: Logger = .init(subsystem: Vars.widgetIdentifier, category: "Add expense timeline")
28 | #endif
29 |
30 | for _ in 0..<2 {
31 | let entryDate = Calendar.current.startOfDay(for: .init())
32 | let entryImage = Image(.squirrelLogo)
33 | let entryURL = URL.addExpenseAction
34 | let entry = AddExpenseEntry(date: entryDate, image: { entryImage }, url: entryURL)
35 | entries.append(entry)
36 | #if DEBUG
37 | logger.debug("Generating entry... Date: \(entryDate), image name: \("squirrelLogo"), URL: \(entryURL?.absoluteString ?? "No URL")")
38 | #endif
39 | }
40 |
41 | let timeline = Timeline(entries: entries, policy: .never)
42 | completion(timeline)
43 | }
44 | }
45 |
46 | struct AddExpenseEntry: TimelineEntry {
47 | let date: Date
48 | let image: () -> Image
49 | let url: URL!
50 | }
51 |
--------------------------------------------------------------------------------
/financecontrol/Utils/CloudKitKVSManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitKVSManager.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/01/07.
6 | //
7 |
8 | import Foundation
9 | import Combine
10 |
11 | final class CloudKitKVSManager: ObservableObject {
12 | @Published
13 | var iCloudSync: Bool
14 |
15 | private let store: NSUbiquitousKeyValueStore
16 | private var valueSubscription: AnyCancellable?
17 |
18 | init(store: NSUbiquitousKeyValueStore = .default) {
19 | self.iCloudSync = store.bool(forKey: UDKey.iCloudSync.rawValue)
20 | self.store = store
21 |
22 | NotificationCenter.default.addObserver(
23 | self,
24 | selector: #selector(update(_:)),
25 | name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
26 | object: store
27 | )
28 |
29 | self.toggleKVS()
30 |
31 | store.synchronize()
32 | }
33 |
34 | deinit {
35 | NotificationCenter.default.removeObserver(
36 | self,
37 | name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
38 | object: self.store
39 | )
40 | }
41 |
42 | @objc
43 | func update(_ notification: Notification) {
44 | guard let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? Array else {
45 | return
46 | }
47 |
48 | if changedKeys.contains(UDKey.iCloudSync.rawValue) {
49 | DispatchQueue.main.async {
50 | self.iCloudSync = self.store.bool(forKey: UDKey.iCloudSync.rawValue)
51 | }
52 | }
53 | }
54 |
55 | private func toggleKVS() {
56 | self.valueSubscription = self.$iCloudSync
57 | .sink { newValue in
58 | self.store.set(newValue, forKey: UDKey.iCloudSync.rawValue)
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/financecontrol/Utils/HapticManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HapticManager.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/25.
6 | //
7 |
8 | import UIKit
9 | #if targetEnvironment(simulator)
10 | import OSLog
11 | #endif
12 |
13 | final class HapticManager {
14 | static let shared = HapticManager()
15 |
16 | func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) {
17 | let generator = UINotificationFeedbackGenerator()
18 | generator.notificationOccurred(type)
19 | #if targetEnvironment(simulator)
20 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
21 | logger.debug("\(type.debugDescription) haptic occured")
22 | #endif
23 | }
24 |
25 | func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
26 | let generator = UIImpactFeedbackGenerator(style: style)
27 | generator.impactOccurred()
28 | #if targetEnvironment(simulator)
29 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
30 | logger.debug("\(style.debugDescription) haptic occured")
31 | #endif
32 | }
33 | }
34 |
35 | #if targetEnvironment(simulator)
36 | extension UINotificationFeedbackGenerator.FeedbackType {
37 | var debugDescription: String {
38 | switch self {
39 | case .success:
40 | "Success"
41 | case .warning:
42 | "Warning"
43 | case .error:
44 | "Error"
45 | @unknown default:
46 | "Unknown"
47 | }
48 | }
49 | }
50 |
51 | extension UIImpactFeedbackGenerator.FeedbackStyle {
52 | var debugDescription: String {
53 | switch self {
54 | case .light:
55 | "Light"
56 | case .medium:
57 | "Medium"
58 | case .heavy:
59 | "Heavy"
60 | case .soft:
61 | "Soft"
62 | case .rigid:
63 | "Rigid"
64 | @unknown default:
65 | "Unknown"
66 | }
67 | }
68 | }
69 | #endif
70 |
--------------------------------------------------------------------------------
/financecontrolTests/FallbackRatesTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatesViewModelTests.swift
3 | // financecontrolTests
4 | //
5 | // Created by PinkXaciD on R 5/12/28.
6 | //
7 |
8 | import XCTest
9 | @testable import Squirrel
10 |
11 | final class FallbackRatesTests: XCTestCase {
12 |
13 | var isoDateFormatter: ISO8601DateFormatter {
14 | let formatter = ISO8601DateFormatter()
15 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
16 | return formatter
17 | }
18 |
19 | override func setUpWithError() throws {
20 | // Put setup code here. This method is called before the invocation of each test method in the class.
21 | }
22 |
23 | override func tearDownWithError() throws {
24 | // Put teardown code here. This method is called after the invocation of each test method in the class.
25 | }
26 |
27 | func testFallbackRatesTimestampParsing() {
28 | let fallbackDate = isoDateFormatter.date(from: Rates.fallback.timestamp)
29 |
30 | XCTAssertNotNil(fallbackDate)
31 | }
32 |
33 | func testFallbackRatesIsUpToDate() {
34 | let fallbackDate = isoDateFormatter.date(from: Rates.fallback.timestamp)!
35 | let minimalAcceptDate = Calendar.current.date(byAdding: .day, value: -7, to: .now)!
36 |
37 | XCTAssertLessThan(minimalAcceptDate, fallbackDate)
38 | }
39 |
40 | func testFallbackRatesHasAllValues() {
41 | let fallbackCurrencies = Rates.fallback.rates.keys
42 | var requiredCurrencies = Locale.customCommonISOCurrencyCodes.filter { $0 != "SLE" && $0 != "VEF" }
43 |
44 | for code in fallbackCurrencies {
45 | let index = requiredCurrencies.firstIndex(of: code)
46 |
47 | XCTAssertNotNil(index, code)
48 |
49 | if let index {
50 | requiredCurrencies.remove(at: index)
51 | }
52 | }
53 |
54 | XCTAssertTrue(requiredCurrencies.isEmpty, requiredCurrencies.description)
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/financecontrol/Utils/CustomIcon.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomIcon.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/08.
6 | //
7 |
8 | import SwiftUI
9 |
10 | enum CustomIcon: CaseIterable {
11 | case sqwoorl, firstFlight, neonNight, winterized, dawnOfSquipan, ghost, NA
12 | }
13 |
14 | extension CustomIcon {
15 | var displayName: LocalizedStringKey {
16 | switch self {
17 | case .sqwoorl:
18 | "Sqwoorl"
19 | case .firstFlight:
20 | "First Flight"
21 | case .neonNight:
22 | "Neon Night"
23 | case .winterized:
24 | "Winterized"
25 | case .dawnOfSquipan:
26 | "Dawn of Squipan"
27 | case .NA:
28 | "N:A"
29 | case .ghost:
30 | "Ghost"
31 | }
32 | }
33 |
34 | var description: LocalizedStringKey {
35 | switch self {
36 | case .sqwoorl:
37 | "Yes, that's his real name"
38 | case .firstFlight:
39 | "To the store!"
40 | case .neonNight:
41 | "It's probably all LED's nowadays"
42 | case .winterized:
43 | "Even a squirrel needs a hat in winter"
44 | case .dawnOfSquipan:
45 | "App of the rising sun"
46 | case .NA:
47 | "Everything that lives is designed to end. We are perpetually trapped in a never-ending spiral..."
48 | case .ghost:
49 | "Plays for the audience"
50 | }
51 | }
52 |
53 | var fileName: String? {
54 | switch self {
55 | case .sqwoorl:
56 | nil
57 | case .firstFlight:
58 | "AppIcon_FirstFlight"
59 | case .neonNight:
60 | "AppIcon_NeonNight"
61 | case .winterized:
62 | "AppIcon_Winterized"
63 | case .dawnOfSquipan:
64 | "AppIcon_DawnOfSquipan"
65 | case .NA:
66 | "AppIcon_NA"
67 | case .ghost:
68 | "AppIcon_Ghost"
69 | }
70 | }
71 |
72 | var imageName: String {
73 | "\(fileName ?? "AppIcon")_Image"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/PieChartCompleteView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PieChartCompleteView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/02/03.
6 | //
7 |
8 | import SwiftUI
9 | import ApplePie
10 |
11 | struct PieChartCompleteView: View {
12 | @Environment(\.layoutDirection) private var layoutDirection
13 | @EnvironmentObject private var vm: PieChartViewModel
14 | let data: ChartData
15 | let size: CGFloat
16 | @State private var update: Bool = false
17 |
18 | var body: some View {
19 | ZStack {
20 | if let selectedCategory = vm.selectedCategory {
21 | APChart(
22 | data.categoriesDict[selectedCategory.id]?.places ?? [],
23 | separators: 0.3,
24 | innerRadius: 0.73,
25 | animation: .default
26 | ) { element in
27 | APChartSector(element.sum, color: Color[element.color], id: element.id)
28 | }
29 | } else {
30 | APChart(
31 | categories(),
32 | separators: 0.3,
33 | innerRadius: 0.73,
34 | animation: .default
35 | ) { element in
36 | APChartSector(element.sum, color: Color[element.color], id: element.id)
37 | }
38 | }
39 |
40 | CenterChartView(
41 | selectedMonth: data.date,
42 | width: size,
43 | operationsInMonth: vm.selectedCategory == nil ? data.sum : data.categoriesDict[vm.selectedCategory?.id ?? .init()]?.sum ?? 0
44 | )
45 | }
46 | }
47 |
48 | private func categories() -> [ChartCategory] {
49 | if vm.showOther {
50 | return data.categories + data.otherCategories
51 | }
52 |
53 | if let otherCategory = data.otherCategory {
54 | var result = data.categories
55 | result.append(otherCategory)
56 | return result
57 | }
58 |
59 | return data.categories
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/financecontrolWidget/View/AccessoryCircularAddExpenseView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessoryCircularAddExpenseView.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/13.
6 | //
7 |
8 | import SwiftUI
9 | import WidgetKit
10 |
11 | @available(iOS 16.0, *)
12 | struct AccessoryCircularAddExpenseView: View {
13 | let entry: AddExpenseEntry
14 |
15 | var body: some View {
16 | if #available(iOS 17.0, *) {
17 | getNewWidget()
18 | } else {
19 | getOldWidget()
20 | }
21 | }
22 |
23 | @available(iOS 17.0, *)
24 | private func getNewWidget() -> some View {
25 | ZStack(alignment: .center) {
26 | Circle()
27 | .fill(Material.regular)
28 |
29 | entry.image()
30 | .resizable()
31 | .aspectRatio(contentMode: .fit)
32 | .padding(10)
33 | }
34 | .privacySensitive(false)
35 | .containerBackground(.clear, for: .widget)
36 | .widgetURL(entry.url)
37 | }
38 |
39 | @available(iOS, introduced: 16, deprecated: 17, message: "On newer platforms use getNewWidget()")
40 | private func getOldWidget() -> some View {
41 | ZStack {
42 | Circle()
43 | .fill(Material.regular)
44 |
45 | entry.image()
46 | .resizable()
47 | .aspectRatio(contentMode: .fit)
48 | .padding(10)
49 | }
50 | .privacySensitive(false)
51 | .widgetURL(entry.url)
52 | }
53 | }
54 |
55 | #if DEBUG
56 | struct AccessoryCircularAddExpenseViewPreviews: PreviewProvider {
57 | static var previews: some View {
58 | if #available(iOS 16.0, *){
59 | AccessoryCircularAddExpenseView(
60 | entry: .init(
61 | date: .init(),
62 | image: { Image(.squirrelLogo) },
63 | url: URL(string:"financecontrol://addExpense")
64 | )
65 | )
66 | .previewContext(WidgetPreviewContext(family: .accessoryCircular))
67 | }
68 | }
69 | }
70 | #endif
71 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Category/ShadowedCategoriesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShadowedCategoriesView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/30.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ShadowedCategoriesView: View {
11 | // @EnvironmentObject var cdm: CoreDataModel
12 |
13 | let categories: FetchedResults
14 |
15 | var body: some View {
16 | Group {
17 | List {
18 | if !categories.isEmpty {
19 | Section {
20 | ForEach(categories) { category in
21 | ShadowedCategoriesRow(category: category, safeCategory: category.safeObject())
22 | }
23 | } footer: {
24 | Text("Swipe from left to restore, swipe from right to delete")
25 | }
26 | }
27 | }
28 | .overlay {
29 | if categories.isEmpty {
30 | ZStack {
31 | Color(uiColor: .systemGroupedBackground)
32 |
33 | CustomContentUnavailableView(
34 | "No Archived Categories",
35 | imageName: "archivebox.fill",
36 | description: "You can archive categories in settings, they will be hidden from selection when you add new expenses, but all old expenses will be saved."
37 | )
38 | }
39 | .ignoresSafeArea()
40 | }
41 | }
42 | .navigationTitle("Archived Categories")
43 | }
44 | .animation(.default, value: categories.count)
45 | }
46 | }
47 |
48 | struct ShadowedCategoriesView_Previews: PreviewProvider {
49 | @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: NSPredicate(format: "isShadowed == true"), animation: .default)
50 | static private var categories: FetchedResults
51 |
52 | static var previews: some View {
53 | ShadowedCategoriesView(categories: categories)
54 | .environmentObject(CoreDataModel())
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/financecontrolWidget/View/AccessoryRectangularSumWidgetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessoryRectangularSumWidgetView.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/25.
6 | //
7 |
8 | import SwiftUI
9 | import WidgetKit
10 |
11 | @available(iOS 16.0, *)
12 | struct AccessoryRectangularSumWidgetView: View {
13 | let entry: SumEntry
14 |
15 | var body: some View {
16 | if #available(iOS 17.0, *) {
17 | getNewWidget()
18 | } else {
19 | getOldWidget()
20 | }
21 | }
22 |
23 | @available(iOS 17.0, *)
24 | private func getNewWidget() -> some View {
25 | HStack {
26 | VStack(alignment: .leading) {
27 | Text("Today's expenses")
28 | .privacySensitive(false)
29 |
30 | Text(entry.expenses.formatted(.currency(code: entry.currency)))
31 | .font(.system(size: 30, design: .rounded).bold())
32 | .minimumScaleFactor(0.5)
33 | .privacySensitive()
34 | }
35 |
36 | Spacer()
37 | }
38 | .containerBackground(.clear, for: .widget)
39 | }
40 |
41 | @available(iOS, introduced: 16, deprecated: 17, message: "On newer platforms use getNewWidget()")
42 | private func getOldWidget() -> some View {
43 | HStack {
44 | VStack(alignment: .leading) {
45 | Text("Today's expenses")
46 |
47 | Text(entry.expenses.formatted(.currency(code: entry.currency)))
48 | .font(.system(size: 30, design: .rounded).bold())
49 | .minimumScaleFactor(0.5)
50 | .privacySensitive()
51 | }
52 |
53 | Spacer()
54 | }
55 | }
56 | }
57 |
58 | #if DEBUG
59 | @available(iOS 16.0, *)
60 | struct AccessoryRectangularSumWidgetViewPreviews: PreviewProvider {
61 | static var previews: some View {
62 | AccessoryRectangularSumWidgetView(
63 | entry: .init(
64 | date: .now,
65 | expenses: 1200,
66 | currency: "JPY"
67 | )
68 | )
69 | .previewContext(WidgetPreviewContext(family: .accessoryRectangular))
70 | .previewDisplayName("Accessory Rectangular Sum Widget")
71 | }
72 | }
73 | #endif
74 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/PieChartCenterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CenterChartView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/11/21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CenterChartView: View {
11 | @EnvironmentObject
12 | private var cdm: CoreDataModel
13 | @EnvironmentObject
14 | private var rvm: RatesViewModel
15 | @EnvironmentObject
16 | private var fvm: FiltersViewModel
17 | @AppStorage(UDKey.defaultCurrency.rawValue)
18 | var defaultCurrency: String = Locale.current.currencyCode ?? "USD"
19 | var selectedMonth: Date
20 |
21 | let width: CGFloat
22 | let operationsInMonth: Double
23 |
24 | var body: some View {
25 | VStack(alignment: .center) {
26 |
27 | dateText()
28 | .padding(.top, 5)
29 | .scaledToFit()
30 |
31 | Text(operationsSum(operationsInMonth: operationsInMonth))
32 | .lineLimit(1)
33 | .font(.system(size: 30, weight: .semibold, design: .rounded))
34 | .padding(.horizontal, 6)
35 | .minimumScaleFactor(0.5)
36 | .scaledToFit()
37 |
38 | Text(defaultCurrency)
39 | .foregroundColor(Color.secondary)
40 | }
41 | .frame(maxWidth: width/1.4)
42 | .scaledToFit()
43 | .minimumScaleFactor(0.01)
44 | }
45 | }
46 |
47 | extension CenterChartView {
48 | internal init(selectedMonth: Date, width: CGFloat, operationsInMonth: Double) {
49 | self.selectedMonth = selectedMonth
50 | self.width = width
51 | self.operationsInMonth = operationsInMonth
52 | }
53 |
54 | @ViewBuilder
55 | private func dateText() -> some View {
56 | if fvm.applyFilters {
57 | Text("Filters applied")
58 | } else {
59 | if Calendar.current.isDate(selectedMonth, equalTo: Date(), toGranularity: .year) {
60 | Text(selectedMonth, format: .dateTime.month(.wide))
61 | } else {
62 | Text(selectedMonth, format: .dateTime.month().year())
63 | }
64 | }
65 | }
66 |
67 | private func operationsSum(operationsInMonth: Double) -> String {
68 | return Locale.current.currencyNarrowFormat(operationsInMonth, currency: UserDefaults.defaultCurrency()) ?? "Error"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Currency/NewCurrencyRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NewCurrencyRow.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/16.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct NewCurrencyRow: View {
11 | @EnvironmentObject private var cdm: CoreDataModel
12 | @EnvironmentObject private var rvm: RatesViewModel
13 | @Environment(\.dismiss) private var dismiss
14 | @AppStorage(UDKey.defaultCurrency.rawValue) private var defaultCurrency: String = Locale.current.currencyCode ?? "USD"
15 |
16 | let name: String
17 | let code: String
18 |
19 | var body: some View {
20 | Button(action: addCurrency) {
21 | buttonLabel
22 | }
23 | .normalizePadding()
24 | }
25 |
26 | private var buttonLabel: some View {
27 | VStack(alignment: .leading) {
28 | Text(name.capitalized)
29 | .foregroundStyle(.primary)
30 |
31 | if rvm.rates[code] != nil, rvm.rates[defaultCurrency] != nil {
32 | getRateRepresentation()
33 | .font(.footnote)
34 | .foregroundStyle(.secondary)
35 | } else {
36 | Text("No up to date exchange rate found for \(code)")
37 | .font(.footnote)
38 | .foregroundStyle(.secondary)
39 | }
40 | }
41 | .padding(.vertical, 1) /// Strange behavior without padding
42 | .foregroundStyle(Color.primary, Color.secondary)
43 | }
44 |
45 | private func addCurrency() {
46 | UserDefaults.standard.addCurrency(code)
47 | dismiss()
48 | }
49 |
50 | private func getRateRepresentation() -> Text {
51 | guard
52 | let rate = rvm.rates[code],
53 | let defaultRate = rvm.rates[defaultCurrency]
54 | else { return Text("Error") }
55 |
56 | func format1(_ code: String) -> String {
57 | return 1.formatted(.currency(code: code).precision(.fractionLength(0)).presentation(.isoCode))
58 | }
59 |
60 | if ((1 / rate) * defaultRate) > 1 {
61 | return Text("\(format1(code)) = \(((1 / rate) * defaultRate).formatted(.currency(code: defaultCurrency).presentation(.isoCode)))")
62 | } else {
63 | return Text("\(format1(defaultCurrency)) = \(((1 / defaultRate) * rate).formatted(.currency(code: code).presentation(.isoCode)))")
64 | }
65 | }
66 | }
67 |
68 | //#Preview {
69 | // NewCurrencyRow()
70 | //}
71 |
--------------------------------------------------------------------------------
/financecontrolWidget/Timeline/SumWidgetTimelineProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SumWidgetTimelineProvider.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/12.
6 | //
7 |
8 | import WidgetKit
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | struct SumWidgetTimelineProvider: TimelineProvider {
14 | func placeholder(in context: Context) -> SumEntry {
15 | SumEntry(date: Date(), expenses: 100, currency: "USD")
16 | }
17 |
18 | func getSnapshot(in context: Context, completion: @escaping (SumEntry) -> Void) {
19 | let sharedDefaults: UserDefaults? = UserDefaults(suiteName: Vars.groupName)
20 | let localeCurrency: String? = Locale.current.currencySymbol
21 |
22 | let entry = SumEntry(
23 | date: Date(),
24 | expenses: sharedDefaults?.double(forKey: "amount") ?? 0,
25 | currency: sharedDefaults?.string(forKey: "defaultCurrency") ?? localeCurrency ?? "USD"
26 | )
27 | completion(entry)
28 | }
29 |
30 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
31 | var entries: [SumEntry] = []
32 | let defaults: UserDefaults? = .init(suiteName: Vars.groupName)
33 | #if DEBUG
34 | let logger: Logger = .init(subsystem: Vars.widgetIdentifier, category: "Sum widget timelines")
35 | #endif
36 |
37 | let currentDate = Calendar.current.startOfDay(for: .now)
38 | for dayOffset in 0..<2 {
39 | let amountDate = defaults?.object(forKey: "date") as? Date
40 |
41 | let entryDate = Calendar.current.date(byAdding: .day, value: dayOffset, to: currentDate)!
42 | let entryExpenses = Calendar.current.isDate(amountDate ?? .distantPast, inSameDayAs: entryDate) ? (defaults?.double(forKey: "amount") ?? 0) : 0
43 | let entryCurrency: String = defaults?.string(forKey: "defaultCurrency") ?? Locale.current.currencyCode ?? "USD"
44 |
45 | #if DEBUG
46 | logger.debug("Generating entry... Date: \(entryDate), expenses: \(entryExpenses), currency: \(entryCurrency)")
47 | #endif
48 |
49 | let entry = SumEntry(date: entryDate, expenses: entryExpenses, currency: entryCurrency)
50 | entries.append(entry)
51 | }
52 |
53 | let timeline = Timeline(entries: entries, policy: .atEnd)
54 | completion(timeline)
55 | }
56 | }
57 |
58 | struct SumEntry: TimelineEntry {
59 | let date: Date
60 | let expenses: Double
61 | let currency: String
62 | }
63 |
--------------------------------------------------------------------------------
/financecontrol/Error/RatesErrors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatesErrors.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/09/26.
6 | //
7 |
8 | import Foundation
9 |
10 | enum InfoPlistError: LocalizedError {
11 | case noInfoFound
12 | case noURLFound
13 | case noAPIKeyFound
14 | case failedToReadURLComponents
15 | }
16 |
17 | extension InfoPlistError {
18 | public var failureReason: String? {
19 | switch self {
20 | case .noInfoFound:
21 | return NSLocalizedString("No Info file was found", comment: "")
22 | case .noURLFound:
23 | return NSLocalizedString("No URL was found", comment: "")
24 | case .noAPIKeyFound:
25 | return NSLocalizedString("No API key was found", comment: "")
26 | case .failedToReadURLComponents:
27 | return NSLocalizedString("Failed to create URL from given components", comment: "")
28 | }
29 |
30 | }
31 |
32 | public var errorDescription: String? {
33 | switch self {
34 | case .noInfoFound, .failedToReadURLComponents:
35 | return NSLocalizedString("It seems some system files are missing or corrupted", comment: "")
36 | case .noURLFound, .noAPIKeyFound:
37 | return NSLocalizedString("It seems some system files are corrupted", comment: "")
38 | }
39 | }
40 |
41 | public var recoverySuggestion: String? {
42 | switch self {
43 | case .noInfoFound, .failedToReadURLComponents:
44 | return NSLocalizedString("Please submit a bug report and try to reinstall the app", comment: "")
45 | case .noURLFound, .noAPIKeyFound:
46 | return NSLocalizedString("Please submit a bug report and try to restart the app", comment: "")
47 | }
48 | }
49 | }
50 |
51 | enum RatesFetchError: LocalizedError {
52 | case emptyDatabase
53 | }
54 |
55 | extension RatesFetchError {
56 | var errorDescription: String? {
57 | switch self {
58 | case .emptyDatabase:
59 | return NSLocalizedString("It seems some system files are missing or corrupted", comment: "")
60 | }
61 | }
62 |
63 | var recoverySuggestion: String? {
64 | switch self {
65 | case .emptyDatabase:
66 | return NSLocalizedString("Please submit a bug report and try to reinstall the app", comment: "")
67 | }
68 | }
69 |
70 | var failureReason: String? {
71 | switch self {
72 | case .emptyDatabase:
73 | return NSLocalizedString("Empty database", comment: "")
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Currency/CurrencyRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencySelectorRow.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/15.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CurrencyRow: View {
11 | @EnvironmentObject private var cdm: CoreDataModel
12 | @EnvironmentObject private var rvm: RatesViewModel
13 | @Binding var defaultCurrency: String
14 |
15 | let currency: Currency
16 | let selectedText: LocalizedStringKey
17 |
18 | var body: some View {
19 | ZStack {
20 | HStack {
21 | VStack(alignment: .leading) {
22 | Text(currency.name ?? "Error")
23 |
24 | getRateRepresentation()
25 | .font(.caption)
26 | .foregroundColor(.secondary)
27 | }
28 |
29 | Spacer()
30 |
31 | if defaultCurrency == currency.code {
32 | Image(systemName: "checkmark")
33 | .font(.body.bold())
34 | .foregroundColor(.accentColor)
35 | }
36 | }
37 | }
38 | .background {
39 | Rectangle()
40 | .foregroundColor(Color(uiColor: .secondarySystemGroupedBackground).opacity(0.001))
41 | .frame(maxWidth: .infinity, maxHeight: .infinity)
42 | }
43 | .padding(.vertical, 1) /// Strange behavior without padding
44 | .normalizePadding()
45 | }
46 |
47 | private func getRateRepresentation() -> Text {
48 | guard currency.code != defaultCurrency else {
49 | return Text(selectedText)
50 | }
51 |
52 | guard
53 | let rate = rvm.rates[currency.code],
54 | let defaultRate = rvm.rates[defaultCurrency]
55 | else {
56 | return Text("No up to date exchange rate found")
57 | }
58 |
59 | func format1(_ code: String) -> String {
60 | return 1.formatted(.currency(code: code).precision(.fractionLength(0)).presentation(.isoCode))
61 | }
62 |
63 | if ((1 / rate) * defaultRate) > 1 {
64 | return Text("\(format1(currency.code)) = \(((1 / rate) * defaultRate).formatted(.currency(code: defaultCurrency).presentation(.isoCode)))")
65 | } else {
66 | return Text("\(format1(defaultCurrency)) = \(((1 / defaultRate) * rate).formatted(.currency(code: currency.code).presentation(.isoCode)))")
67 | }
68 | }
69 | }
70 |
71 | //#Preview {
72 | // CurrencyRow()
73 | //}
74 |
--------------------------------------------------------------------------------
/financecontrol/View/Home/ViewModels/BarChartViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BarChartViewModel.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/01/21.
6 | //
7 |
8 | import CoreData
9 |
10 | final class BarChartViewModel: ViewModel {
11 | let context: NSManagedObjectContext
12 |
13 | @Published
14 | private(set) var data: NewBarChartData = NewBarChartData()
15 | @Published
16 | private(set) var lastFetchDate: Date? = nil
17 |
18 | init(context: NSManagedObjectContext) {
19 | self.context = context
20 |
21 | NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .UpdatePieChart, object: nil)
22 |
23 | updateData()
24 | }
25 |
26 | deinit {
27 | NotificationCenter.default.removeObserver(self, name: .UpdatePieChart, object: nil)
28 | }
29 |
30 | @objc
31 | private func updateData() {
32 | context.perform { [weak self] in
33 | guard let self else { return }
34 |
35 | let weekAgo = Calendar.autoupdatingCurrent.startOfDay(for: Date()).addingTimeInterval(.day * -6)
36 | let defaultCurrency = UserDefaults.standard.string(forKey: UDKey.defaultCurrency.rawValue) ?? "USD"
37 | let defaultRate = UserDefaults.standard.getRates()?[defaultCurrency] ?? 1
38 |
39 | let request = SpendingEntity.fetchRequest()
40 | request.sortDescriptors = [NSSortDescriptor(keyPath: \SpendingEntity.date, ascending: false)]
41 | request.predicate = NSPredicate(format: "date > %@", weekAgo as CVarArg)
42 |
43 | var data = NewBarChartData().bars
44 | var sum: Double = 0
45 |
46 | do {
47 | let spendings = try context.fetch(request)
48 |
49 | for spending in spendings {
50 | let startOfDay = Calendar.autoupdatingCurrent.startOfDay(for: spending.wrappedDate)
51 |
52 | let spendingSum = defaultCurrency == spending.wrappedCurrency ? spending.amountWithReturns : (spending.amountUSDWithReturns * defaultRate)
53 |
54 | data.updateValue((data[startOfDay] ?? 0) + spendingSum, forKey: startOfDay)
55 |
56 | sum += spendingSum
57 | }
58 |
59 | self.data = NewBarChartData(sum: sum, bars: data)
60 | self.lastFetchDate = Date()
61 | } catch {
62 | ErrorType(error: error).publish()
63 | }
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/financecontrolWidget/View/AccessoryCircularSumWidgetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessoryCircularSumWidgetView.swift
3 | // financecontrolWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/01/25.
6 | //
7 |
8 | import SwiftUI
9 | import WidgetKit
10 |
11 | @available(iOS 16.0, *)
12 | struct AccessoryCircularSumWidgetView: View {
13 | let entry: SumEntry
14 |
15 | var body: some View {
16 | if #available(iOS 17.0, *) {
17 | getNewWidget()
18 | } else {
19 | getOldWidget()
20 | }
21 | }
22 |
23 | @available(iOS 17.0, *)
24 | private func getNewWidget() -> some View {
25 | ZStack(alignment: .center) {
26 | Circle()
27 | .fill(Material.regular)
28 |
29 | VStack(spacing: 0) {
30 | Text(entry.currency)
31 | .font(.footnote)
32 |
33 | Text(entry.expenses.formatted(.number.notation(.compactName)))
34 | .font(.system(.title, design: .rounded).bold())
35 | .minimumScaleFactor(0.5)
36 | .scaledToFit()
37 | .padding(.horizontal, 10)
38 | }
39 | .privacySensitive()
40 | }
41 | .containerBackground(.clear, for: .widget)
42 | }
43 |
44 | @available(iOS, introduced: 16, deprecated: 17, message: "On newer platforms use getNewWidget()")
45 | private func getOldWidget() -> some View {
46 | ZStack(alignment: .center) {
47 | Circle()
48 | .fill(Material.regular)
49 |
50 | VStack(spacing: 0.5) {
51 | Text(entry.currency)
52 | .font(.footnote)
53 |
54 | Text(entry.expenses.formatted(.number.notation(.compactName)))
55 | .font(.system(.title, design: .rounded).bold())
56 | .minimumScaleFactor(0.5)
57 | .scaledToFit()
58 | .padding(.horizontal, 6)
59 | }
60 | .privacySensitive()
61 | }
62 | }
63 | }
64 |
65 | #if DEBUG
66 | @available(iOS 16.0, *)
67 | struct AccessoryCircularSumWidgetViewPreviews: PreviewProvider {
68 | static var previews: some View {
69 | AccessoryCircularSumWidgetView(
70 | entry: .init(
71 | date: .init(),
72 | expenses: 122,
73 | currency: "JPY"
74 | )
75 | )
76 | .previewContext(WidgetPreviewContext(family: .accessoryCircular))
77 | .previewDisplayName("Accessory Circular Sum Widget")
78 | }
79 | }
80 | #endif
81 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/EditReturnViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditReturnViewModel.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/12/05.
6 | //
7 |
8 | import SwiftUI
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | final class EditReturnViewModel: ViewModel {
14 | @Published var amount: String
15 | @Published var name: String
16 | @Published var date: Date
17 | @Published var currency: String
18 | var oldAmount: Double
19 | var spending: SpendingEntity?
20 | var returnEntity: ReturnEntity
21 | private var cdm: CoreDataModel
22 | private var rvm: RatesViewModel
23 |
24 | init(returnEntity: ReturnEntity, cdm: CoreDataModel, rvm: RatesViewModel) {
25 | let formatter = NumberFormatter.currency
26 |
27 | self.amount = formatter.string(from: returnEntity.amount as NSNumber) ?? "\(returnEntity.amount)".replacingOccurrences(of: ".", with: Locale.current.decimalSeparator ?? ".")
28 | self.name = returnEntity.name ?? ""
29 | self.date = returnEntity.date ?? .now
30 | self.currency = returnEntity.currency ?? "USD"
31 | self.oldAmount = returnEntity.amount
32 | self.spending = returnEntity.spending
33 | self.returnEntity = returnEntity
34 | self.cdm = cdm
35 | self.rvm = rvm
36 | }
37 |
38 | var doubleAmount: Double {
39 | if currency == spending?.wrappedCurrency {
40 | return Double(truncating: NumberFormatter.standard.number(from: amount) ?? 0)
41 | } else {
42 | let doubleAmount = Double(truncating: NumberFormatter.standard.number(from: amount) ?? 0)
43 |
44 | return round(doubleAmount / (rvm.rates[currency] ?? 1) * (rvm.rates[spending?.wrappedCurrency ?? "USD"] ?? 1) * 100) / 100
45 | }
46 | }
47 |
48 | func editFromSpending(spending: SpendingEntity) {
49 | let amountUSD: Double = doubleAmount / (spending.amount / spending.amountUSD)
50 |
51 | cdm.editRerturnFromSpending(
52 | spending: spending,
53 | oldReturn: returnEntity,
54 | amount: doubleAmount,
55 | amountUSD: amountUSD,
56 | currency: spending.wrappedCurrency,
57 | date: date,
58 | name: name
59 | )
60 | }
61 |
62 | func validate() -> Bool {
63 | guard
64 | !amount.isEmpty,
65 | doubleAmount != 0,
66 | let spending = self.spending,
67 | doubleAmount <= (spending.amountWithReturns + oldAmount)
68 | else {
69 | return true
70 | }
71 |
72 | return false
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/IconRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IconRow.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/08.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct IconRow: View {
11 | @StateObject private var vm: IconRowViewModel
12 | @Binding var selectedIcon: String?
13 | private let iconSize: CGFloat = 60
14 | private let cornerRadius: CGFloat = 13.5
15 |
16 | var body: some View {
17 | Button(action: vm.setIcon, label: label)
18 | .contextMenu {
19 | Button {
20 | vm.setIcon()
21 | } label: {
22 | Text("Set icon")
23 | }
24 |
25 | }
26 | }
27 |
28 | private func label() -> some View {
29 | HStack {
30 | icon
31 |
32 | text
33 |
34 | Spacer()
35 |
36 | checkmark
37 | }
38 | .normalizePadding()
39 | }
40 |
41 | private var icon: some View {
42 | vm.getIconImage()
43 | .resizable()
44 | .frame(width: iconSize, height: iconSize)
45 | .cornerRadius(cornerRadius)
46 | .setDarkModeForIcon()
47 | .overlay(iconOverlay)
48 | .hoverEffect(.lift)
49 | }
50 |
51 | private var iconOverlay: some View {
52 | RoundedRectangle(cornerRadius: cornerRadius)
53 | .stroke(lineWidth: 1)
54 | .foregroundColor(.primary)
55 | .opacity(0.3)
56 | }
57 |
58 | private var text: some View {
59 | VStack(alignment: .leading) {
60 | Text(vm.icon.displayName)
61 | .foregroundColor(.primary)
62 |
63 | if vm.icon.description != "" {
64 | Text(vm.icon.description)
65 | .font(.caption)
66 | .foregroundColor(.secondary)
67 | }
68 | }
69 | .padding(.leading)
70 | }
71 |
72 | private var checkmark: some View {
73 | Image(systemName: "checkmark")
74 | .font(.body.bold())
75 | .opacity(selectedIcon == vm.icon.fileName ? 1 : 0)
76 | }
77 | }
78 |
79 | extension IconRow {
80 | init(_ icon: CustomIcon, selection: Binding) {
81 | self._vm = StateObject(wrappedValue: .init(icon: icon, selection: selection))
82 | self._selectedIcon = selection
83 | }
84 | }
85 |
86 | fileprivate extension View {
87 | func setDarkModeForIcon() -> some View {
88 | if #unavailable(iOS 18.0) {
89 | return self.environment(\.colorScheme, .light)
90 | }
91 |
92 | return self
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/Filters/FiltersReturnsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FiltersReturnsView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/12.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct FiltersReturnsView: View {
11 | @Environment(\.dismiss)
12 | private var dismiss
13 |
14 | @Binding var withReturns: Bool?
15 |
16 | var body: some View {
17 | List {
18 | Section {
19 | Button {
20 | if withReturns != true {
21 | withReturns = true
22 | }
23 | } label: {
24 | HStack {
25 | Text("With returns")
26 | .foregroundColor(.primary)
27 |
28 | Spacer()
29 |
30 | Image(systemName: "checkmark")
31 | .font(.body.bold())
32 | .opacity(withReturns == true ? 1 : 0)
33 | .animation(.default.speed(3), value: withReturns)
34 | }
35 | }
36 |
37 | Button {
38 | if withReturns != false {
39 | withReturns = false
40 | }
41 | } label: {
42 | HStack {
43 | Text("Without returns")
44 | .foregroundColor(.primary)
45 |
46 | Spacer()
47 |
48 | Image(systemName: "checkmark")
49 | .font(.body.bold())
50 | .opacity(withReturns == false ? 1 : 0)
51 | .animation(.default.speed(3), value: withReturns)
52 | }
53 | }
54 | }
55 |
56 | Section {
57 | Button("Disable", role: .destructive) {
58 | if withReturns != nil {
59 | withReturns = nil
60 | }
61 | }
62 | .disabled(withReturns == nil)
63 | .animation(.default.speed(3), value: withReturns)
64 | }
65 | }
66 | .navigationTitle("Returns")
67 | .navigationBarTitleDisplayMode(.inline)
68 | .toolbar {
69 | ToolbarItem(placement: .topBarTrailing) {
70 | Button {
71 | dismiss()
72 | } label: {
73 | Text("Done")
74 | .font(.body.bold())
75 | }
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/financecontrol/Data/CoreData/DataManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DataManager.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/07/05.
6 | //
7 |
8 | import CoreData
9 |
10 | final class DataManager {
11 | static let shared = DataManager()
12 |
13 | let container: NSPersistentContainer
14 | let context: NSManagedObjectContext
15 | lazy private(set) var backgroundContext: NSManagedObjectContext = container.newBackgroundContext()
16 |
17 | init() {
18 | let container = NSPersistentCloudKitContainer(name: "DataContainer")
19 |
20 | if let storeDescription = container.persistentStoreDescriptions.first {
21 | if !NSUbiquitousKeyValueStore.default.bool(forKey: UDKey.iCloudSync.rawValue) {
22 | storeDescription.configuration = "Default"
23 | storeDescription.cloudKitContainerOptions = nil
24 | }
25 |
26 | storeDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
27 | storeDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
28 | }
29 |
30 | container.loadPersistentStores { _, error in
31 | if let error = error {
32 | ErrorType(error: error).publish()
33 | }
34 | }
35 |
36 | #if DEBUG
37 | // do {
38 | // try container.initializeCloudKitSchema()
39 | // } catch {
40 | // print(error)
41 | // }
42 | #endif
43 |
44 | let context = container.viewContext
45 | context.name = "Main context"
46 | context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
47 | context.automaticallyMergesChangesFromParent = true
48 |
49 | try? context.setQueryGenerationFrom(.current)
50 | self.container = container
51 | self.context = context
52 | }
53 |
54 | func save() {
55 | if context.hasChanges {
56 | do {
57 | try context.save()
58 | } catch {
59 | context.rollback()
60 | ErrorType(error: error).publish(file: #fileID, function: #function)
61 | }
62 | }
63 | }
64 |
65 | func deleteSpending(with objectID: NSManagedObjectID) {
66 | guard let object = try? backgroundContext.existingObject(with: objectID) else {
67 | print("Failed")
68 | return
69 | }
70 |
71 | backgroundContext.delete(object)
72 | do {
73 | try backgroundContext.save()
74 | } catch {
75 | ErrorType(error: error).publish()
76 | backgroundContext.rollback()
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/SettingsFormattingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsFormattingView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/09/03.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SettingsFormattingView: View {
11 | @EnvironmentObject
12 | private var cdm: CoreDataModel
13 |
14 | @AppStorage(UDKey.formatWithoutTimeZones.rawValue)
15 | private var formatWithoutTimeZones: Bool = false
16 | @AppStorage("timeZoneFormat")
17 | private var timeZoneFormat: Int = 0
18 |
19 | @State
20 | private var isToggled: Bool = false
21 |
22 | var body: some View {
23 | List {
24 | Section {
25 | Toggle("Always Format Without Timezones", isOn: $formatWithoutTimeZones)
26 | } footer: {
27 | Text("format-without-timezones-description-key")
28 | }
29 |
30 | if !formatWithoutTimeZones {
31 | timeZoneFormatSection
32 | }
33 | }
34 | .navigationTitle("Formatting")
35 | .onChange(of: formatWithoutTimeZones) { _ in
36 | if !isToggled {
37 | isToggled = true
38 | }
39 | }
40 | .onDisappear {
41 | if isToggled {
42 | cdm.fetchSpendings()
43 | }
44 | }
45 | .animation(.default, value: formatWithoutTimeZones)
46 | }
47 |
48 | private var timeZoneFormatSection: some View {
49 | Section {
50 | HStack {
51 | Text("Timezone Format")
52 |
53 | Menu {
54 | Picker("Timezone format", selection: $timeZoneFormat) {
55 | ForEach(TimeZone.Format.allCases, id: \.rawValue) { format in
56 | Button {} label: {
57 | if #available(iOS 16.0, *) {
58 | Text(format.localizedName)
59 |
60 | Text(TimeZone.autoupdatingCurrent.formatted(format))
61 | } else {
62 | Text("\(format.localizedName)\n\(TimeZone.autoupdatingCurrent.formatted(format))")
63 | }
64 | }
65 | .tag(format.rawValue)
66 | }
67 | .pickerStyle(.inline)
68 | }
69 | } label: {
70 | HStack {
71 | Spacer()
72 |
73 | Text("\(TimeZone.Format(rawValue: timeZoneFormat).localizedName)")
74 | }
75 | }
76 | }
77 | }
78 | .animation(.default.speed(2), value: timeZoneFormat)
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/Filters/FiltersCurrenciesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FiltersCurrenciesView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/12.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct FiltersCurrenciesView: View {
11 | @Environment(\.dismiss)
12 | private var dismiss
13 |
14 | @Binding var currencies: [String]
15 | let usedCurrencies: Set
16 | // @EnvironmentObject
17 | // private var cdm: CoreDataModel
18 |
19 | var body: some View {
20 | List {
21 | ForEach(usedCurrencies.sorted(by: <)) { currency in
22 | Button {
23 | rowAction(currency.code)
24 | } label: {
25 | rowLabel(currency)
26 | }
27 | }
28 |
29 | if !usedCurrencies.isEmpty {
30 | Section {
31 | Button("Select All") {
32 | currencies = usedCurrencies.map { $0.code }
33 | }
34 | .disabled(currencies.count == usedCurrencies.count)
35 |
36 | Button("Clear Selection", role: .destructive) {
37 | currencies = []
38 | }
39 | .disabled(currencies.isEmpty)
40 | .animation(.default.speed(3), value: currencies)
41 | }
42 | }
43 | }
44 | .navigationTitle("Currencies")
45 | .navigationBarTitleDisplayMode(.inline)
46 | .toolbar {
47 | trailingToolbar
48 | }
49 | .overlay {
50 | if usedCurrencies.isEmpty {
51 | CustomContentUnavailableView("No Expenses", imageName: "list.bullet", description: "You can add expenses from home screen.")
52 | }
53 | }
54 | }
55 |
56 | private var trailingToolbar: ToolbarItem {
57 | ToolbarItem(placement: .topBarTrailing) {
58 | Button {
59 | dismiss()
60 | } label: {
61 | Text("Done")
62 | .bold()
63 | }
64 |
65 | }
66 | }
67 |
68 | private func rowLabel(_ currency: Currency) -> some View {
69 | HStack {
70 | Text(currency.name ?? currency.code)
71 | .foregroundColor(.primary)
72 |
73 | Spacer()
74 |
75 | Image(systemName: "checkmark")
76 | .font(.body.bold())
77 | .opacity(currencies.contains(currency.code) ? 1 : 0)
78 | .animation(.default.speed(3), value: currencies)
79 | }
80 | }
81 |
82 | private func rowAction(_ code: String) {
83 | if let index = currencies.firstIndex(of: code) {
84 | currencies.remove(at: index)
85 | return
86 | }
87 |
88 | currencies.append(code)
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/financecontrol/Utils/NetworkMonitor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NetworkMonitor.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/02.
6 | //
7 |
8 | import Foundation
9 | import Network
10 | import Combine
11 | #if DEBUG
12 | import OSLog
13 | #endif
14 |
15 | // TODO: Doesn't setting up properly on init()
16 | final class NetworkMonitor: ObservableObject {
17 | static let shared = NetworkMonitor()
18 |
19 | let monitor: NWPathMonitor
20 | private let queue: DispatchQueue
21 |
22 | #if DEBUG
23 | let logger: Logger
24 | #endif
25 | var cancellables = Set()
26 |
27 | @Published
28 | var isConnected: Bool
29 | @Published
30 | var isExpensive: Bool
31 | @Published
32 | var status: NWPath.Status
33 |
34 | private init() {
35 | self.monitor = NWPathMonitor()
36 | self.isConnected = monitor.currentPath.status == .satisfied
37 | self.status = monitor.currentPath.status
38 | self.isExpensive = monitor.currentPath.isExpensive
39 | self.queue = DispatchQueue(label: "NetworkMonitor")
40 |
41 | #if DEBUG
42 | self.logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
43 | logToCL()
44 | #endif
45 | sendNotificationOnConnection()
46 |
47 |
48 | monitor.pathUpdateHandler = { path in
49 | DispatchQueue.main.async {
50 | self.isConnected = path.status == .satisfied
51 | self.isExpensive = path.isExpensive
52 | self.status = path.status
53 | }
54 | }
55 |
56 | monitor.start(queue: self.queue)
57 | }
58 |
59 | deinit {
60 | #if DEBUG
61 | logger.log("deinit")
62 | #endif
63 | }
64 |
65 | private func sendNotificationOnConnection() {
66 | self.$isConnected
67 | .receive(on: RunLoop.main)
68 | .sink { [weak self] value in
69 | if value {
70 | NotificationCenter.default.post(name: Notification.Name("ConnectionEstablished"), object: self)
71 | }
72 | }
73 | .store(in: &cancellables)
74 | }
75 |
76 | #if DEBUG
77 | private func logToCL() {
78 | self.$isConnected
79 | .sink { [weak self] value in
80 | self?.logger.debug("\(value.description)")
81 | }
82 | .store(in: &cancellables)
83 | }
84 | #endif
85 | }
86 |
87 | extension NWPath.Status {
88 | var description: String {
89 | switch self {
90 | case .satisfied:
91 | "satisfied"
92 | case .unsatisfied:
93 | "unsatisfied"
94 | case .requiresConnection:
95 | "requiresConnection"
96 | @unknown default:
97 | "unknown"
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/financecontrol/View/Spending/Views/ReturnRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReturnRow.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/20.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ReturnRow: View {
11 | @EnvironmentObject private var cdm: CoreDataModel
12 | @Binding var returnToEdit: ReturnEntity?
13 | let returnEntity: ReturnEntity
14 | let spendingCurrency: String
15 |
16 | var body: some View {
17 | HStack {
18 | VStack(alignment: .leading) {
19 | dateFormat(returnEntity.date ?? .distantPast)
20 | .foregroundColor(.secondary)
21 | .font(.caption)
22 |
23 | Text(returnEntity.amount.formatted(.currency(code: returnEntity.currency ?? spendingCurrency)))
24 | .font(.system(.title3, design: .rounded).bold())
25 | }
26 |
27 | if let name = returnEntity.name, !name.isEmpty {
28 | Spacer()
29 |
30 | Text(name)
31 | .lineLimit(3)
32 | .padding()
33 | .background {
34 | RoundedRectangle(cornerRadius: 10)
35 | .fill(Color(uiColor: .tertiarySystemGroupedBackground))
36 | }
37 | }
38 | }
39 | .padding(.vertical, 1)
40 | .normalizePadding()
41 | .foregroundColor(.primary)
42 | .swipeActions(edge: .leading) {
43 | getEditButton(returnEntity)
44 | .labelStyle(.iconOnly)
45 | }
46 | .swipeActions(edge: .trailing, allowsFullSwipe: false) {
47 | getDeleteButton(returnEntity)
48 | .labelStyle(.iconOnly)
49 | }
50 | .contextMenu {
51 | getEditButton(returnEntity)
52 |
53 | getDeleteButton(returnEntity)
54 | }
55 | .onTapGesture {
56 | returnToEdit = returnEntity
57 | }
58 | }
59 |
60 | private func getEditButton(_ entity: ReturnEntity) -> some View {
61 | Button {
62 | returnToEdit = entity
63 | } label: {
64 | Label("Edit", systemImage: "pencil")
65 | }
66 | .tint(.yellow)
67 | }
68 |
69 | private func getDeleteButton(_ entity: ReturnEntity) -> some View {
70 | Button(role: .destructive) {
71 | withAnimation {
72 | cdm.deleteReturn(spendingReturn: entity)
73 | }
74 | } label: {
75 | Label("Delete", systemImage: "trash.fill")
76 | }
77 | .tint(.red)
78 | }
79 |
80 | private func dateFormat(_ date: Date) -> Text {
81 | let dateFormatter = DateFormatter()
82 | dateFormatter.timeStyle = .short
83 | dateFormatter.dateStyle = .medium
84 | dateFormatter.locale = Locale.current
85 | dateFormatter.doesRelativeDateFormatting = true
86 |
87 | return Text(dateFormatter.string(from: date))
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/financecontrol/Utils/Keychain.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Keychain.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/07/22.
6 | //
7 |
8 | import Foundation
9 |
10 | struct Keychain {
11 | let url: String
12 |
13 | init(_ url: String) {
14 | self.url = url
15 | }
16 |
17 | func setPassword(_ key: String, overrideExisting: Bool = false) throws {
18 | guard let keyData = key.data(using: .utf8) else {
19 | throw KeychainError.failedToEncodeURL
20 | }
21 |
22 | if overrideExisting, try self.getPassword() != nil {
23 | try self.removePassword()
24 | }
25 |
26 | let query: [String : Any] = [
27 | kSecClass as String: kSecClassInternetPassword,
28 | kSecAttrServer as String: url,
29 | kSecValueData as String: keyData
30 | ]
31 |
32 | let status = SecItemAdd(query as CFDictionary, nil)
33 |
34 | guard status == errSecSuccess else {
35 | throw KeychainError.failedToSetValue
36 | }
37 | }
38 |
39 | func getPassword() throws -> String? {
40 | let query: [String:Any] = [
41 | kSecClass as String: kSecClassInternetPassword,
42 | kSecAttrServer as String: url,
43 | kSecReturnAttributes as String: true,
44 | kSecReturnData as String: true
45 | ]
46 |
47 | var item: CFTypeRef?
48 | let status = SecItemCopyMatching(query as CFDictionary, &item)
49 | guard status == errSecSuccess else {
50 | if status == errSecItemNotFound {
51 | return nil
52 | }
53 | return nil
54 | }
55 |
56 | guard let existingItem = item as? [String:Any],
57 | let apiKeyData = existingItem[kSecValueData as String] as? Data,
58 | let apiKey = String(data: apiKeyData, encoding: .utf8)
59 | else {
60 | throw KeychainError.failedToUnwrapValue
61 | }
62 |
63 | return apiKey
64 | }
65 |
66 | func removePassword() throws {
67 | let query: [String:Any] = [
68 | kSecClass as String: kSecClassInternetPassword,
69 | kSecAttrServer as String: url
70 | ]
71 |
72 | let status = SecItemDelete(query as CFDictionary)
73 |
74 | guard status == errSecSuccess else {
75 | HapticManager.shared.notification(.error)
76 | throw KeychainError.failedToRemoveValue
77 | }
78 |
79 | HapticManager.shared.notification(.success)
80 | }
81 | }
82 |
83 | enum KeychainError: String, LocalizedError {
84 | case failedToEncodeURL, failedToSetValue, failedToUnwrapValue, failedToRemoveValue
85 |
86 | var errorDescription: String? {
87 | "Keychain error \(self.rawValue)"
88 | }
89 |
90 | var recoverySuggestion: String? {
91 | "Try to restart the app"
92 | }
93 |
94 | var failureReason: String? {
95 | "KeychainError.\(self.rawValue)"
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/ColorAndIconView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ColorAndIconView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/07/19.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ColorAndIconView: View {
11 | @AppStorage(UDKey.color.rawValue)
12 | var defaultColor: String = "Orange"
13 | @AppStorage(UDKey.color.rawValue, store: UserDefaults(suiteName: Vars.groupName))
14 | var sharedDefaultColor: String = "Orange"
15 |
16 | @State
17 | private var selectedIcon: String? = UIApplication.shared.alternateIconName
18 |
19 | let colors: [(LocalizedStringKey, Color)] = [
20 | ("Orange", Color.orange),
21 | // ("Red", Color.red),
22 | ("Pink", Color.pink),
23 | ("Purple", Color.purple),
24 | ("Indigo", Color.indigo),
25 | ("Blue", Color.blue),
26 | ("Teal", Color.teal),
27 | ("Mint", Color.mint)
28 | ]
29 |
30 | var body: some View {
31 | List {
32 | colorSection
33 |
34 | iconSection
35 | }
36 | .navigationTitle("Color and Icon")
37 | .navigationBarTitleDisplayMode(.inline)
38 | }
39 |
40 | private var colorSection: some View {
41 | Section {
42 | ForEach(colors, id: \.1) { name, color in
43 | Button {
44 | setColor(color.description.capitalized)
45 | } label: {
46 | HStack {
47 | Text(name)
48 | .foregroundColor(color)
49 |
50 | Spacer()
51 |
52 | Image(systemName: "checkmark")
53 | .font(.body.bold())
54 | .opacity(defaultColor == color.description.capitalized ? 1 : 0)
55 | }
56 | }
57 | }
58 | // Picker("Color", selection: $defaultColor) {
59 | // ForEach(colors, id: \.1) { name, color in
60 | // Text(name)
61 | // .tag("\(color.description.capitalized)")
62 | // .foregroundColor(color)
63 | // }
64 | // }
65 | // .pickerStyle(.inline)
66 | // .labelsHidden()
67 | } header: {
68 | Text("Accent Color")
69 | }
70 | }
71 |
72 | private var iconSection: some View {
73 | Section {
74 | ForEach(CustomIcon.allCases, id: \.imageName) { icon in
75 | IconRow(icon, selection: $selectedIcon)
76 | }
77 | } header: {
78 | Text("Icon")
79 | }
80 | }
81 |
82 | private func setColor(_ color: String) {
83 | withAnimation {
84 | defaultColor = color
85 | sharedDefaultColor = color
86 | }
87 | WidgetsManager.shared.accentColorChanged = true
88 | }
89 | }
90 |
91 | struct ColorSelector_Previews: PreviewProvider {
92 | static var previews: some View {
93 | ColorAndIconView()
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_Release.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
42 |
44 |
50 |
51 |
52 |
53 |
59 |
61 |
67 |
68 |
69 |
70 |
72 |
73 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/financecontrolWidget/View/SumWidgetView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TotalSpendingsSmallView.swift
3 | // financecontrolWidget
4 | //
5 | // Created by PinkXaciD on R 6/01/11.
6 | //
7 |
8 | import SwiftUI
9 |
10 | #if DEBUG
11 | import WidgetKit
12 | #endif
13 |
14 | @available(*, deprecated, renamed: "WeeklySpendingsWidgetView", message: "This is old widget")
15 | struct SmallSumWidgetView: View {
16 | @Environment(\.colorScheme) private var colorScheme
17 |
18 | let entry: SumEntry
19 |
20 | var body: some View {
21 | if #available(iOS 17, *) {
22 | getNewWidget()
23 | } else {
24 | getOldWidget()
25 | }
26 | }
27 |
28 | private var gradient: LinearGradient {
29 | LinearGradient(
30 | colors: [.upperWidget, .bottomWidget],
31 | startPoint: .top,
32 | endPoint: .bottom
33 | )
34 | }
35 |
36 | private let upperTextOpacity: CGFloat = 0.7
37 |
38 | private let bottomTextFont: Font = .system(.largeTitle, design: .rounded).bold()
39 |
40 | @available(iOS, introduced: 17, message: "On older systems use getOldWidget()")
41 | private func getNewWidget() -> some View {
42 | HStack {
43 | VStack(alignment: .leading, spacing: 10) {
44 | Text("Today's expenses")
45 | .font(.body)
46 | .opacity(upperTextOpacity)
47 |
48 | Text(entry.expenses.formatted(.currency(code: entry.currency)))
49 | .font(bottomTextFont)
50 | .minimumScaleFactor(0.5)
51 | .privacySensitive()
52 |
53 | Spacer()
54 | }
55 |
56 | Spacer()
57 | }
58 | .foregroundColor(.primary)
59 | .containerBackground(gradient, for: .widget)
60 | }
61 |
62 | @available(iOS, introduced: 14.0, deprecated: 17.0, message: "On newer systems use getNewWidget()")
63 | private func getOldWidget() -> some View {
64 | ZStack {
65 | gradient
66 |
67 | HStack {
68 | VStack(alignment: .leading, spacing: 10) {
69 | Text("Today's expenses")
70 | .font(.subheadline)
71 | .opacity(upperTextOpacity)
72 |
73 | Text(entry.expenses.formatted(.currency(code: entry.currency)))
74 | .font(bottomTextFont)
75 | .minimumScaleFactor(0.5)
76 | .privacySensitive()
77 |
78 | Spacer()
79 | }
80 |
81 | Spacer()
82 | }
83 | .padding()
84 | }
85 | .foregroundColor(.primary)
86 | }
87 | }
88 |
89 | //#if DEBUG
90 | //struct WidgetPreview: PreviewProvider {
91 | // static var previews: some View {
92 | // SmallSumWidgetView(entry: .init(date: .now, expenses: 1200, currency: "JPY"))
93 | // .previewContext(WidgetPreviewContext(family: .systemSmall))
94 | // }
95 | //}
96 | //#endif
97 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/OtherExtensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OtherExtensions.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/24.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 |
11 | extension Bundle {
12 | var releaseVersionNumber: String? {
13 | return infoDictionary?["CFBundleShortVersionString"] as? String
14 | }
15 |
16 | var buildVersionNumber: String? {
17 | return infoDictionary?["CFBundleVersion"] as? String
18 | }
19 |
20 | static let mainIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.squirrelapp.squirrel"
21 |
22 | var displayName: String? {
23 | return object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
24 | }
25 | }
26 |
27 | extension CodingUserInfoKey {
28 | static let moc = CodingUserInfoKey(rawValue: "managedObjectContext")!
29 | }
30 |
31 | extension UIDevice {
32 | var isIPad: Bool {
33 | self.userInterfaceIdiom == .pad
34 | }
35 |
36 | var isIPhone: Bool {
37 | self.userInterfaceIdiom == .phone
38 | }
39 | }
40 |
41 | extension UIApplication {
42 | var keyWindow: UIWindow? {
43 | return self.connectedScenes
44 | .filter { $0.activationState == .foregroundActive }
45 | .first(where: { $0 is UIWindowScene })
46 | .flatMap({ $0 as? UIWindowScene })?.windows
47 | .first(where: \.isKeyWindow)
48 | }
49 | }
50 |
51 | extension Calendar {
52 | static let gmt: Calendar = {
53 | var calendar: Calendar = .init(identifier: .gregorian)
54 | calendar.locale = .current
55 | var timeZone: TimeZone {
56 | if #available(iOS 16.0, *) {
57 | return .gmt
58 | }
59 |
60 | return .init(secondsFromGMT: 0) ?? .current
61 | }
62 | calendar.timeZone = timeZone
63 | return calendar
64 | }()
65 | }
66 |
67 | extension TimeInterval {
68 | static let hour: Self = 3600
69 |
70 | static let day: Self = 86_400
71 | }
72 |
73 | extension DateFormatter {
74 | static let forRatesTimestamp: DateFormatter = {
75 | let formatter = DateFormatter()
76 | formatter.locale = Locale(identifier: "en_US_POSIX")
77 | formatter.timeZone = .init(secondsFromGMT: 0)
78 | formatter.dateFormat = "yyyy-MM-dd"
79 | return formatter
80 | }()
81 | }
82 |
83 | extension NumberFormatter {
84 | static let standard = NumberFormatter()
85 |
86 | static var currency: NumberFormatter {
87 | let formatter = NumberFormatter()
88 | formatter.maximumFractionDigits = 2
89 | formatter.minimumFractionDigits = 0
90 | formatter.decimalSeparator = Locale.current.decimalSeparator ?? "."
91 | return formatter
92 | }
93 | }
94 |
95 | extension NSNotification.Name {
96 | static let UpdatePieChart = NSNotification.Name("UpdatePieChart")
97 | }
98 |
99 | extension Set {
100 | func cancelAll() {
101 | for item in self {
102 | item.cancel()
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/financecontrolWidget/View/WeeklySpendingsTodaySumView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WeeklySpendingsTodaySumView.swift
3 | // SquirrelWidgetExtension
4 | //
5 | // Created by PinkXaciD on R 6/07/26.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct WeeklySpendingsMediumTodaySumView: View {
11 | let sum: Double
12 | let avg: Double
13 | let currency: String
14 |
15 | var body: some View {
16 | HStack {
17 | VStack(alignment: .leading, spacing: 0) {
18 | Text("Today:")
19 | .font(.footnote)
20 | .foregroundColor(.secondary)
21 | .padding(.bottom, 1)
22 | .privacySensitive()
23 |
24 | VStack(alignment: .leading, spacing: 0) {
25 | Text(currency)
26 | .font(.system(.title3, design: .rounded).bold())
27 |
28 | Text(Locale.current.currencyNarrowFormat(sum, currency: currency, removeFractionDigigtsFrom: 1000) ?? "Error")
29 | .font(.system(.title, design: .rounded).bold())
30 | .scaledToFit()
31 | .minimumScaleFactor(0.5)
32 | }
33 | .privacySensitive()
34 |
35 | Divider()
36 | .padding(.vertical, 5)
37 |
38 | Text("Average:")
39 | .font(.caption2)
40 | .foregroundColor(.secondary)
41 | .privacySensitive()
42 |
43 | VStack(alignment: .leading) {
44 | Text(avg.formatted(.currency(code: currency).precision(.fractionLength(0))))
45 | .font(.system(.body, design: .rounded).bold())
46 | .scaledToFit()
47 | .minimumScaleFactor(0.5)
48 | }
49 | .privacySensitive()
50 | }
51 |
52 | Spacer()
53 | }
54 | // TODO: Add spending button
55 | // .overlay(alignment: .topTrailing) {
56 | // Image(systemName: "plus.circle.fill")
57 | // .foregroundColor(.accentColor)
58 | // .font(.title2)
59 | // }
60 | }
61 | }
62 |
63 | struct WeeklySpendingsSmallTodaySumView: View {
64 | let currency: String
65 | let sum: Double
66 |
67 | var body: some View {
68 | VStack(alignment: .leading) {
69 | Text("Today:")
70 | .font(.footnote)
71 | .foregroundColor(.secondary)
72 | .privacySensitive()
73 |
74 | Text(
75 | sum.formatted(
76 | .currency(code: currency)
77 | .precision(
78 | .fractionLength(sum >= 1000 ? 0 : Locale.current.currencyFractionDigits(currencyCode: currency))
79 | )
80 | )
81 | )
82 | .font(.system(.title3, design: .rounded).bold())
83 | .scaledToFit()
84 | .minimumScaleFactor(0.5)
85 | .privacySensitive()
86 | }
87 | }
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Category/ShadowedCategoriesRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShadowedCatedoryRow.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/18.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ShadowedCategoriesRow: View {
11 |
12 | // @EnvironmentObject private var cdm: CoreDataModel
13 | @Environment(\.managedObjectContext) private var viewContext
14 |
15 | @State private var alertIsPresented: Bool = false
16 |
17 | let category: CategoryEntity
18 | let safeCategory: TSCategoryEntity
19 |
20 | var body: some View {
21 | categoryInfo
22 | .swipeActions(edge: .trailing, allowsFullSwipe: false) {
23 | getDeleteButton(isSwipeAction: true)
24 | .labelStyle(.iconOnly)
25 | }
26 | .swipeActions(edge: .leading) {
27 | getRestoreButton(isSwipeAction: true)
28 | .labelStyle(.iconOnly)
29 | }
30 | .contextMenu {
31 | getRestoreButton(isSwipeAction: false)
32 |
33 | getDeleteButton(isSwipeAction: false)
34 | }
35 | .alert("Delete this category?", isPresented: $alertIsPresented) {
36 | Button("Delete", role: .destructive) {
37 | withAnimation {
38 | viewContext.delete(category)
39 | try? viewContext.save()
40 | // cdm.deleteCategory(category)
41 | }
42 | }
43 |
44 | Button("Cancel", role: .cancel) {}
45 | } message: {
46 | Text("You can't undo this action.\nAll expenses from this category will be deleted")
47 | }
48 | .normalizePadding()
49 | }
50 |
51 | private var categoryInfo: some View {
52 | HStack {
53 | Image(systemName: "circle.fill")
54 | .font(.title)
55 | .foregroundStyle(.tertiary)
56 |
57 | VStack(alignment: .leading) {
58 | Text(safeCategory.name ?? "Error")
59 | .foregroundStyle(.primary)
60 | }
61 | }
62 | .padding(.vertical, 1) /// Strange behavior without padding
63 | .foregroundStyle(Color.primary, Color.secondary, Color[category.color ?? "nil"])
64 | }
65 |
66 | private func getDeleteButton(isSwipeAction: Bool) -> some View {
67 | Button(role: isSwipeAction ? nil : .destructive) {
68 | alertIsPresented.toggle()
69 | } label: {
70 | Label("Delete", systemImage: "trash.fill")
71 | }
72 | .tint(Color.red)
73 | }
74 |
75 | private func getRestoreButton(isSwipeAction: Bool) -> some View {
76 | Button(role: isSwipeAction ? .destructive : nil) {
77 | withAnimation {
78 | category.isShadowed.toggle()
79 | try? viewContext.save()
80 | // cdm.changeShadowStateOfCategory(category)
81 | }
82 | } label: {
83 | Label("Restore", systemImage: "arrow.uturn.backward")
84 | }
85 | .tint(Color.green)
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_RTL.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/financecontrol/Utils/WidgetsManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WidgetsManager.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/01/09.
6 | //
7 |
8 | import WidgetKit
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | final class WidgetsManager {
14 | static let shared: WidgetsManager = .init()
15 | let sumWidgets: [Widgets] = [.smallSum, .accessorySum, .weeklySpendings, .weeklySpendingsAccessory]
16 |
17 | #if DEBUG
18 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
19 | #endif
20 |
21 | var sumWidgetsNeedsToReload: Bool = false
22 | var accentColorChanged: Bool = false
23 |
24 | private let sharedDefaults = UserDefaults(suiteName: Vars.groupName)
25 |
26 | func reloadAll() {
27 | WidgetCenter.shared.reloadAllTimelines()
28 | }
29 | }
30 |
31 | // MARK: Sum Widgets
32 | extension WidgetsManager {
33 | func reloadSumWidgets() {
34 | if sumWidgetsNeedsToReload {
35 | for widget in sumWidgets {
36 | WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
37 | // print(widget.kind)
38 | }
39 | sumWidgetsNeedsToReload = false
40 |
41 | #if DEBUG
42 | logger.debug("Reload executed")
43 | #endif
44 | } else {
45 | #if DEBUG
46 | logger.debug("Reload called, but not executed")
47 | #endif
48 | }
49 | }
50 |
51 | func updateSpendingsWidgets(data: [String:Double], amount: Double) {
52 | guard let sharedDefaults else {
53 | #if DEBUG
54 | logger.error("Failed to initialize UserDefaults")
55 | #endif
56 | return
57 | }
58 |
59 | #if DEBUG
60 | // logger.debug("\(data)")
61 | #endif
62 |
63 | let currentDate = Calendar.current.startOfDay(for: .now)
64 | sharedDefaults.set(amount, forKey: "amount")
65 | sharedDefaults.set(currentDate, forKey: "date")
66 | sharedDefaults.set(data, forKey: "WeeklySpendingsWidgetData")
67 | sumWidgetsNeedsToReload = true
68 | }
69 |
70 | func updateAccentColor() {
71 | if accentColorChanged {
72 | let widgetsWithAccentColor: [Widgets] = [.weeklySpendings]
73 |
74 | for widget in widgetsWithAccentColor {
75 | WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
76 | }
77 | }
78 | }
79 | }
80 |
81 | enum Widgets {
82 | case smallSum, accessorySum, accessoryCircularAddExpense, weeklySpendings, weeklySpendingsAccessory
83 | }
84 |
85 | extension Widgets {
86 | var kind: String {
87 | switch self {
88 | case .smallSum:
89 | "SmallSumWidget"
90 | case .accessorySum:
91 | "AccessorySumWidget"
92 | case .accessoryCircularAddExpense:
93 | "AccessoryCircularAddExpense"
94 | case .weeklySpendings:
95 | "WeeklySpendingsWidget"
96 | case .weeklySpendingsAccessory:
97 | "WeeklySpendingsAccessoryWidget"
98 | }
99 | }
100 | }
101 | // WeeklySpendingsAccessoryWidget, WeeklySpendingsWidget
102 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/CustomContentUnavailableView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomContentUnavailableView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/03/05.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomContentUnavailableView: View {
11 | let imageName: String
12 | let title: LocalizedStringKey
13 | let description: LocalizedStringKey?
14 | let showEffect: Bool
15 | let effectType: CustomSymbolEffect
16 | @State private var animation: Bool = false
17 | @ScaledMetric private var imageSize: CGFloat = 50
18 |
19 | var body: some View {
20 | VStack(spacing: 6) {
21 | Image(systemName: imageName)
22 | .resizable()
23 | .aspectRatio(contentMode: .fit)
24 | .symbolRenderingMode(.monochrome)
25 | .availableSymbolEffect(value: animation, effect: effectType)
26 | .frame(height: imageSize)
27 | .foregroundColor(.secondary)
28 | .padding(15)
29 |
30 | Text(title)
31 | .font(.title2)
32 | .fontWeight(.bold)
33 |
34 | if let description {
35 | Text(description)
36 | .foregroundColor(.secondary)
37 | .font(.callout)
38 | }
39 | }
40 | .onAppear {
41 | animation.toggle()
42 | }
43 | .multilineTextAlignment(.center)
44 | .padding(.horizontal, 30)
45 | }
46 |
47 | init(_ title: LocalizedStringKey, imageName: String = "questionmark", description: LocalizedStringKey? = nil, effect: CustomSymbolEffect = .none) {
48 | self.title = title
49 | self.imageName = imageName
50 | self.description = description
51 | self.showEffect = effect != .none
52 | self.effectType = effect
53 | }
54 | }
55 |
56 | extension CustomContentUnavailableView {
57 | static let search: Self = CustomContentUnavailableView("No results", imageName: "magnifyingglass", description: "Check the spelling or try a new search.")
58 |
59 | static func search(_ text: String) -> CustomContentUnavailableView {
60 | return CustomContentUnavailableView("No results for \"\(text.trimmingCharacters(in: .whitespacesAndNewlines))\"", imageName: "magnifyingglass", description: "Check the spelling or try a new search.")
61 | }
62 | }
63 |
64 | fileprivate extension View {
65 | @ViewBuilder
66 | func availableSymbolEffect(value: T, effect: CustomSymbolEffect = .none) -> some View {
67 | if #available(iOS 17, *) {
68 | switch effect {
69 | case .variableColor:
70 | self
71 | .symbolEffect(.variableColor, options: .repeating, value: value)
72 | case .bounce:
73 | self
74 | .symbolEffect(.bounce.byLayer, value: value)
75 | case .none:
76 | self
77 | }
78 | } else {
79 | self
80 | }
81 | }
82 | }
83 |
84 | enum CustomSymbolEffect {
85 | case variableColor, bounce, none
86 | }
87 |
88 | #if DEBUG
89 | #Preview {
90 | CustomContentUnavailableView("Test", imageName: "archivebox.fill", description: "Description.", effect: .bounce)
91 | }
92 | #endif
93 |
--------------------------------------------------------------------------------
/financecontrol/View/Universal/Custom Alert/CustomAlertView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomAlertView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CustomAlertView: View {
11 | var data: CustomAlertData
12 | @StateObject private var viewModel: CustomAlertViewModel
13 | @State private var animate: Bool = false
14 |
15 | var body: some View {
16 | ZStack {
17 | RoundedRectangle(cornerRadius: 20)
18 | .fill(Material.regular)
19 | .shadow(radius: 5)
20 |
21 | HStack {
22 | Image(systemName: data.systemImage)
23 | .resizable()
24 | .aspectRatio(contentMode: .fit)
25 | .scaleEffect(animate ? 1 : 0.1)
26 | .foregroundColor(data.type.color)
27 | .padding()
28 |
29 | HStack {
30 | VStack(alignment: .leading) {
31 | Text(data.title)
32 | .fontWeight(.bold)
33 | .minimumScaleFactor(0.5)
34 |
35 | if let description = data.description {
36 | Text(description)
37 | .lineLimit(2)
38 | .minimumScaleFactor(0.5)
39 | }
40 | }
41 | .padding(.vertical, 5)
42 |
43 | Spacer()
44 | }
45 | }
46 | }
47 | .frame(maxWidth: .infinity, maxHeight: 80)
48 | .padding(.horizontal)
49 | .onAppear {
50 | withAnimation(.bouncy) {
51 | self.animate = true
52 | }
53 | }
54 | .onTapGesture {
55 | withAnimation(.bouncy) {
56 | CustomAlertManager.shared.removeAlert(data.id)
57 | }
58 | }
59 | .gesture(
60 | DragGesture()
61 | .onChanged { value in
62 | if value.predictedEndTranslation.height < -5 {
63 | withAnimation(.bouncy) {
64 | CustomAlertManager.shared.removeAlert(data.id)
65 | }
66 | }
67 | }
68 | )
69 | .transition(.opacity.combined(with: .scale).combined(with: .move(edge: .top)))
70 | }
71 |
72 | init(data: CustomAlertData) {
73 | self.data = data
74 | self._viewModel = StateObject(wrappedValue: .init(id: data.id, haptic: data.type.haptic))
75 | }
76 | }
77 |
78 | #if DEBUG
79 | #Preview {
80 | VStack {
81 | CustomAlertView(data: .init(type: .error, title: "Error", description: "Description.", systemImage: "xmark.circle"))
82 | CustomAlertView(data: .init(type: .warning, title: "Warning", description: "Description.", systemImage: "exclamationmark.circle"))
83 | CustomAlertView(data: .init(type: .success, title: "Success", description: "Description.", systemImage: "checkmark.circle"))
84 | CustomAlertView(data: .init(type: .info, title: "Info", description: "Description.", systemImage: "questionmark.circle"))
85 | }
86 | }
87 | #endif
88 |
--------------------------------------------------------------------------------
/financecontrol/View/Selectors/CustomColorSelector.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CustomColorSelector.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/09/09.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | struct CustomColorSelector: View {
12 |
13 | @Binding var colorSelectedDescription: String
14 | let colors = CustomColor.nordAurora
15 |
16 | var body: some View {
17 |
18 | let columns: [GridItem] = Array(repeating: .init(.flexible(minimum: 35, maximum: 50), spacing: 10, alignment: .center), count: 7)
19 |
20 | LazyVGrid(columns: columns) {
21 | ForEach(colors.compactMap{$0.key}.sorted{$0 < $1}, id: \.self) { colorDescription in
22 | Button {
23 | buttonAction(colorDescription)
24 | } label: {
25 | if #available(iOS 26.0, *) {
26 | newButtonLabel(colorDescription)
27 | } else {
28 | buttonLabel(colorDescription)
29 | }
30 | }
31 | .buttonStyle(.plain)
32 | .contentShape(.hoverEffect, Circle())
33 | .hoverEffect(.lift)
34 | }
35 | }
36 | }
37 |
38 | private func buttonLabel(_ colorDescription: String) -> some View {
39 | Circle()
40 | .fill(colors[colorDescription] ?? .black)
41 | .overlay {
42 | Circle()
43 | .stroke(lineWidth: colorDescription == colorSelectedDescription ? 3 : 0)
44 | .foregroundColor(Color(uiColor: .secondarySystemGroupedBackground))
45 | .opacity(colorDescription == colorSelectedDescription ? 1 : 0)
46 | .scaleEffect(colorDescription == colorSelectedDescription ? 0.8 : 1)
47 | }
48 | .frame(minWidth: 35, maxWidth: 50, minHeight: 35, maxHeight: 50)
49 | }
50 |
51 | @available(iOS 26.0, *)
52 | private func newButtonLabel(_ colorDescription: String) -> some View {
53 | Circle()
54 | .fill(colors[colorDescription] ?? .black)
55 | .overlay {
56 | Circle()
57 | .stroke(lineWidth: colorDescription == colorSelectedDescription ? 3 : 0)
58 | .foregroundColor(Color(uiColor: .secondarySystemGroupedBackground))
59 | .opacity(colorDescription == colorSelectedDescription ? 1 : 0)
60 | .scaleEffect(colorDescription == colorSelectedDescription ? 0.8 : 1)
61 | }
62 | .glassEffect(.regular, in: Circle())
63 | .frame(minWidth: 35, maxWidth: 50, minHeight: 35, maxHeight: 50)
64 | }
65 |
66 | private func buttonAction(_ colorDescription: String) {
67 | withAnimation(.bouncy) {
68 | colorSelectedDescription = colorDescription
69 | }
70 | }
71 | }
72 |
73 | struct CustomColorSelectorPreviews: PreviewProvider {
74 | static var previews: some View {
75 | CustomColorSelectorPreview()
76 | }
77 | }
78 |
79 | fileprivate struct CustomColorSelectorPreview: View {
80 | @State var colorSelectedDescription: String = "nordRed"
81 |
82 | var body: some View {
83 | List {
84 | CustomColorSelector(colorSelectedDescription: $colorSelectedDescription)
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Category/CategoryRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryRow.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/16.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CategoryRow: View {
11 |
12 | // @EnvironmentObject private var cdm: CoreDataModel
13 | @Environment(\.managedObjectContext) private var viewContext
14 |
15 | @ObservedObject
16 | var category: CategoryEntity
17 |
18 | var body: some View {
19 |
20 | NavigationLink {
21 | CategoryEditView(category: category)
22 | } label: {
23 | navLinkLabel
24 | }
25 | .swipeActions(edge: .leading) {
26 | favoriteButton
27 | .labelStyle(.iconOnly)
28 | }
29 | .swipeActions(edge: .trailing) {
30 | getDeleteButton(isSwipeAction: true)
31 | .labelStyle(.iconOnly)
32 | }
33 | .contextMenu {
34 | favoriteButton
35 |
36 | getDeleteButton(isSwipeAction: false)
37 | }
38 | .normalizePadding()
39 | .animation(.default, value: category.isFavorite)
40 | }
41 |
42 | private var navLinkLabel: some View {
43 | let spendingsCount: Int = category.spendings?.count ?? 0
44 |
45 | return HStack {
46 | Image(systemName: category.isFavorite ? "star.circle.fill" : "circle.fill")
47 | .font(.title)
48 | .foregroundStyle(Color[category.color ?? "nil"])
49 |
50 | VStack(alignment: .leading) {
51 | Text(category.name ?? "Error")
52 | .foregroundStyle(.primary)
53 |
54 | Text("\(spendingsCount) expenses")
55 | .font(.footnote)
56 | .foregroundStyle(.secondary)
57 | }
58 |
59 | Spacer()
60 |
61 | Text("Edit")
62 | .foregroundStyle(.secondary)
63 | }
64 | // .foregroundStyle(Color.primary, Color.secondary, Color[category.color ?? "nil"])
65 | .padding(.vertical, 1) /// Strange behavior without padding
66 | }
67 |
68 | private var favoriteButton: some View {
69 | Button {
70 | // withAnimation {
71 | category.isFavorite.toggle()
72 | try? viewContext.save()
73 | // cdm.changeFavoriteStateOfCategory(category)
74 | // }
75 | } label: {
76 | Label(
77 | category.isFavorite ? "Remove from favorites" : "Add to favorites",
78 | systemImage: category.isFavorite ? "star.slash.fill" : "star.fill"
79 | )
80 | }
81 | .tint(.yellow)
82 | }
83 |
84 | private func getDeleteButton(isSwipeAction: Bool) -> some View {
85 | Button(role: isSwipeAction ? .destructive : nil) {
86 | withAnimation {
87 | category.isShadowed.toggle()
88 | try? viewContext.save()
89 | // cdm.changeShadowStateOfCategory(category)
90 | }
91 | } label: {
92 | Label("Archive", systemImage: "archivebox.fill")
93 | }
94 | .tint(.gray)
95 | }
96 | }
97 |
98 | //#Preview {
99 | // CategoryRow()
100 | //}
101 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/Filters/FiltersCategoriesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FiltersCategoriesView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/06/12.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct FiltersCategoriesView: View {
11 | @Environment(\.dismiss)
12 | private var dismiss
13 |
14 | @Binding
15 | var categories: [UUID]
16 | @Binding
17 | var applyFilters: Bool
18 |
19 | // let listData: [CategoryEntity]
20 | @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)])
21 | private var fetchedCategories: FetchedResults
22 |
23 | var body: some View {
24 | List {
25 | Section {
26 | ForEach(fetchedCategories) { category in
27 | Button {
28 | categoryButtonAction(category)
29 | } label: {
30 | categoryRowLabel(category)
31 | }
32 | }
33 | }
34 |
35 | if !fetchedCategories.isEmpty {
36 | Section {
37 | Button("Select All") {
38 | categories = fetchedCategories.map { $0.id ?? .init() }
39 | }
40 | .disabled(categories.count == fetchedCategories.count)
41 |
42 | Button("Clear Selection", role: .destructive) {
43 | categories.removeAll()
44 | }
45 | .disabled(categories.isEmpty)
46 | .animation(.default.speed(2), value: categories)
47 | }
48 | }
49 | }
50 | .navigationTitle("Categories")
51 | .toolbar {
52 | trailingToolbar
53 | }
54 | .overlay {
55 | if fetchedCategories.isEmpty {
56 | CustomContentUnavailableView("No Categories", imageName: "list.bullet", description: "You can add categories in settings.")
57 | }
58 | }
59 | }
60 |
61 | private var trailingToolbar: ToolbarItem {
62 | ToolbarItem {
63 | Button("Done") {
64 | dismiss()
65 | }
66 | .font(.body.bold())
67 | }
68 | }
69 |
70 | private func categoryButtonAction(_ category: CategoryEntity) {
71 | guard let id = category.id else {
72 | return
73 | }
74 |
75 | if categories.contains(id) {
76 | let index: Int = categories.firstIndex(of: id) ?? 0
77 | categories.remove(at: index)
78 | } else {
79 | categories.append(id)
80 | }
81 | }
82 |
83 | private func categoryRowLabel(_ category: CategoryEntity) -> some View {
84 | return HStack {
85 | Image(systemName: "circle.fill")
86 | .foregroundColor(Color[category.color ?? ""])
87 | .font(.title)
88 |
89 | Text(category.name ?? "Error")
90 | .foregroundColor(.primary)
91 |
92 | Spacer()
93 |
94 | Image(systemName: "checkmark")
95 | .font(.body.bold())
96 | .opacity(categories.contains(category.id ?? .init()) ? 1 : 0)
97 | .animation(.default.speed(3), value: categories)
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/financecontrol/Data/CoreData/Entities/CategoryEntity/CategoryEntity+CoreDataClass.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryEntity+CoreDataClass.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/31.
6 | //
7 | //
8 |
9 | import Foundation
10 | import CoreData
11 | #if DEBUG
12 | import OSLog
13 | #endif
14 |
15 | @objc(CategoryEntity)
16 | public final class CategoryEntity: NSManagedObject, Codable {
17 | enum CodingKeys: CodingKey {
18 | case id, color, isShadowed, isFavorite, name, spendings
19 | }
20 |
21 | override public init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) {
22 | super.init(entity: entity, insertInto: context)
23 | }
24 |
25 | required convenience public init(from decoder: Decoder) throws {
26 | guard let context = decoder.userInfo[.moc] as? NSManagedObjectContext else {
27 | throw URLError(.badURL)
28 | }
29 |
30 | self.init(context: context)
31 | let container = try decoder.container(keyedBy: CodingKeys.self)
32 |
33 | // Required fields
34 | do {
35 | self.id = try container.decode(UUID.self, forKey: .id)
36 | self.color = try container.decode(String.self, forKey: .color)
37 | self.name = try container.decode(String.self, forKey: .name)
38 | } catch {
39 | #if DEBUG
40 | Logger(subsystem: Vars.appIdentifier, category: #fileID).error("\(error)")
41 | #endif
42 | throw error
43 | }
44 |
45 | // Optional fields
46 | do {
47 | self.isShadowed = try container.decode(Bool.self, forKey: .isShadowed)
48 | } catch let DecodingError.keyNotFound(_, context) {
49 | self.isShadowed = false
50 |
51 | #if DEBUG
52 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
53 | logger.debug("\(context.debugDescription)")
54 | #endif
55 | }
56 |
57 | do {
58 | self.isFavorite = try container.decode(Bool.self, forKey: .isFavorite)
59 | } catch let DecodingError.keyNotFound(_, context) {
60 | self.isFavorite = false
61 |
62 | #if DEBUG
63 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
64 | logger.debug("\(context.debugDescription)")
65 | #endif
66 | }
67 |
68 | do {
69 | self.spendings = try container.decode(Set.self, forKey: .spendings) as NSSet
70 | } catch let DecodingError.keyNotFound(_, context) {
71 | self.spendings = Set() as NSSet
72 |
73 | #if DEBUG
74 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
75 | logger.debug("\(context.debugDescription)")
76 | #endif
77 | }
78 | }
79 |
80 | public func encode(to encoder: Encoder) throws {
81 | var container = encoder.container(keyedBy: CodingKeys.self)
82 | try container.encode(id, forKey: .id)
83 | try container.encode(color, forKey: .color)
84 | try container.encode(isShadowed, forKey: .isShadowed)
85 | try container.encode(isFavorite, forKey: .isFavorite)
86 | try container.encode(name, forKey: .name)
87 | if let array = spendings?.allObjects as? [SpendingEntity] {
88 | try container.encode(array, forKey: .spendings)
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/ViewModel/PieChartViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PieChartLazyPageViewViewModel.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/02/03.
6 | //
7 |
8 | import SwiftUI
9 | import Combine
10 | #if DEBUG
11 | import OSLog
12 | #endif
13 |
14 | final class PieChartViewModel: ViewModel {
15 | private var cdm: CoreDataModel
16 | var fvm: FiltersViewModel
17 |
18 | @Published var selection: Int = 0
19 | @Published var data: [ChartData]
20 | @Published var selectedCategory: ChartCategory? = nil
21 | @Published var isScrollDisabled: Bool = false
22 | @Published var showOther: Bool = false
23 |
24 | private var filtersWereEnabled: Bool = false
25 |
26 | var cancellables = Set()
27 | let id = UUID()
28 |
29 | init(cdm: CoreDataModel, fvm: FiltersViewModel) {
30 | self.cdm = cdm
31 | self.fvm = fvm
32 |
33 | self.data = cdm.getNewChartData()
34 |
35 | NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .UpdatePieChart, object: nil)
36 |
37 | #if DEBUG
38 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
39 | logger.debug("ViewModel initialized")
40 | #endif
41 | }
42 |
43 | deinit {
44 | #if DEBUG
45 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID)
46 | logger.debug("ViewModel deinitialized")
47 | #endif
48 | NotificationCenter.default.removeObserver(self, name: .init("UpdatePieChart"), object: nil)
49 | }
50 | }
51 |
52 | // MARK: Methods
53 | extension PieChartViewModel {
54 | @objc func updateData() {
55 | DispatchQueue.main.async { [weak self] in
56 | guard let self else { return }
57 |
58 | let chartData: [ChartData] = {
59 | if self.fvm.applyFilters {
60 | if !self.filtersWereEnabled {
61 | self.selection = 0
62 | self.isScrollDisabled = true
63 | self.selectedCategory = nil
64 | self.filtersWereEnabled = true
65 | }
66 |
67 | return self.cdm.getNewFilteredChartData(
68 | firstDate: self.fvm.startFilterDate,
69 | secondDate: self.fvm.endFilterDate,
70 | categories: self.fvm.filterCategories,
71 | withReturns: self.fvm.withReturns,
72 | currencies: self.fvm.currencies
73 | )
74 | }
75 |
76 | if self.filtersWereEnabled {
77 | self.selectedCategory = nil
78 | self.isScrollDisabled = false
79 | self.filtersWereEnabled = false
80 | }
81 |
82 | return self.cdm.getNewChartData()
83 | }()
84 |
85 | self.data = chartData
86 | }
87 | }
88 |
89 | func applyFilters() {
90 | self.selection = 0
91 | self.isScrollDisabled = true
92 | self.selectedCategory = nil
93 | self.updateData()
94 | }
95 |
96 | func disableFilters() {
97 | self.selectedCategory = nil
98 | self.updateData()
99 | self.isScrollDisabled = false
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/financecontrol/Data/CoreData/DataContainer.xcdatamodeld/SpendingsModel.xcdatamodel/contents:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/StatsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StatsListView.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on R 6/02/16.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct StatsListView: View {
11 | @EnvironmentObject private var fvm: FiltersViewModel
12 | @EnvironmentObject private var vm: StatsListViewModel
13 | @EnvironmentObject private var searchModel: StatsSearchViewModel
14 | @EnvironmentObject private var statsViewModel: StatsViewModel
15 |
16 | @StateObject
17 | private var rowVM = StatsRowViewModel()
18 |
19 | @GestureState
20 | private var draggingRow: UUID? = nil
21 |
22 | @Binding
23 | var spendingToDelete: SpendingEntity?
24 | @Binding
25 | var presentDeleteDialog: Bool
26 |
27 | private var headerFont: Font {
28 | if #available(iOS 26.0, *) {
29 | return .body.bold()
30 | }
31 |
32 | return .subheadline.bold()
33 | }
34 |
35 | var body: some View {
36 | if !vm.data.isEmpty {
37 | list
38 | } else {
39 | noResults
40 | .padding(.vertical)
41 | }
42 | }
43 |
44 | private var list: some View {
45 | ForEach(vm.data, id: \.key) { section in
46 | VStack(alignment: .leading) {
47 | dateFormatForList(section.key)
48 | .textCase(nil)
49 | .font(headerFont)
50 | .foregroundStyle(.secondary)
51 | .padding(.horizontal)
52 | .padding(.top)
53 |
54 | VStack(spacing: 0) {
55 | ForEach(section.value) { spending in
56 | StatsRow(state: $draggingRow, data: spending, spendingToDelete: $spendingToDelete, presentDeleteDialog: $presentDeleteDialog)
57 | .environmentObject(rowVM)
58 |
59 | if spending.id != section.value.last?.id {
60 | Divider()
61 | }
62 | }
63 | }
64 | .clipShape(RoundedRectangle(cornerRadius: Self.listCornerRadius))
65 | }
66 | }
67 | }
68 |
69 | private var noResults: some View {
70 | if !searchModel.search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
71 | CustomContentUnavailableView.search(searchModel.search.trimmingCharacters(in: .whitespacesAndNewlines))
72 | } else if fvm.applyFilters {
73 | CustomContentUnavailableView("No Results for These Filters", imageName: "tray.fill")
74 | } else if vm.selection != 0 {
75 | CustomContentUnavailableView("No Expenses in This Month", imageName: "list.bullet", description: "You can add expenses from home screen.")
76 | } else {
77 | CustomContentUnavailableView("No Expenses", imageName: "list.bullet", description: "You can add expenses from home screen.")
78 | }
79 | }
80 |
81 | private func dateFormatForList(_ date: Date) -> Text {
82 | if Calendar.current.isDateInToday(date) {
83 | return Text("Today")
84 | } else if Calendar.current.isDateInYesterday(date) {
85 | return Text("Yesterday")
86 | } else if Calendar.current.isDateInTomorrow(date) {
87 | return Text("Tomorrow")
88 | } else if Calendar.current.isDate(date, equalTo: Date(), toGranularity: .year) {
89 | return Text(date, format: .dateTime.day().month(.wide))
90 | } else {
91 | return Text(date, format: .dateTime.day().month(.wide).year())
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/financecontrol/Data/CoreData/Entities/CategoryEntity/CategoriesModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoriesModel.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/09/07.
6 | //
7 |
8 | import CoreData
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | extension CoreDataModel {
14 |
15 | func fetchCategories() {
16 |
17 | // let request = CategoryEntity.fetchRequest()
18 | // request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
19 | // request.predicate = NSPredicate(format: "isShadowed == false")
20 | //
21 | // let requestForShadowed = CategoryEntity.fetchRequest()
22 | // requestForShadowed.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
23 | // requestForShadowed.predicate = NSPredicate(format: "isShadowed == true")
24 | //
25 | // do {
26 | // savedCategories = try context.fetch(request)
27 | // shadowedCategories = try context.fetch(requestForShadowed)
28 | // } catch {
29 | // ErrorType(error: error).publish()
30 | // }
31 | }
32 |
33 | func findCategory(_ id: UUID, in context: NSManagedObjectContext = DataManager.shared.context) -> CategoryEntity? {
34 | context.performAndWait {
35 | let request = CategoryEntity.fetchRequest()
36 | request.predicate = NSPredicate(format: "id == %@", id as CVarArg)
37 |
38 | do {
39 | return try context.fetch(request).first
40 | } catch {
41 | #if DEBUG
42 | let logger = Logger(subsystem: Vars.appIdentifier, category: "CoreDataModel")
43 | logger.error("Error finding category: \(error)")
44 | #endif
45 | return nil
46 | }
47 | }
48 | }
49 |
50 | func addCategory(name: String, color: String) -> CategoryEntity? {
51 |
52 | if let description = NSEntityDescription.entity(forEntityName: "CategoryEntity", in: context) {
53 |
54 | let newCategory = CategoryEntity(entity: description, insertInto: context)
55 |
56 | let id = UUID()
57 | newCategory.id = id
58 | newCategory.name = name
59 | newCategory.color = color
60 | newCategory.isShadowed = false
61 |
62 | manager.save()
63 | fetchCategories()
64 |
65 | return newCategory
66 | }
67 |
68 | return nil
69 | }
70 |
71 | func addToCategory(_ spending: SpendingEntity, _ category: CategoryEntity) {
72 |
73 | category.addToSpendings(spending)
74 | }
75 |
76 | func editCategory(_ category: CategoryEntity, name: String, color: String) {
77 |
78 | category.name = name
79 | category.color = color
80 | manager.save()
81 | fetchCategories()
82 | fetchSpendings()
83 | }
84 |
85 | func changeShadowStateOfCategory(_ category: CategoryEntity) {
86 |
87 | category.isShadowed.toggle()
88 | category.isFavorite = false
89 | manager.save()
90 | fetchCategories()
91 | }
92 |
93 | func changeFavoriteStateOfCategory(_ category: CategoryEntity) {
94 |
95 | category.isFavorite.toggle()
96 | manager.save()
97 | fetchCategories()
98 | }
99 |
100 | func deleteCategory(_ category: CategoryEntity) {
101 |
102 | context.delete(category)
103 | manager.save()
104 | fetchCategories()
105 | fetchSpendings()
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Currency/AddCurrencyView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddCurrencyView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/08/04.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AddCurrencyView: View {
11 | @EnvironmentObject private var cdm: CoreDataModel
12 |
13 | @State private var search: String = ""
14 | let currencyCodes = Dictionary(grouping: Locale.customCommonISOCurrencyCodes) { code in
15 | (Locale.current.localizedString(forCurrencyCode: code) ?? code).prefix(1).capitalized
16 | }
17 |
18 | var body: some View {
19 | Group {
20 | let searchResult = searchFunc()
21 |
22 | List {
23 | ForEach(Array(searchResult.keys).sorted(), id: \.self) { key in
24 | Section {
25 | if let currencies = searchResult[key] {
26 | let mappedCurrencies = currencies.map { (code: $0, name: Locale.current.localizedString(forCurrencyCode: $0) ?? $0) }
27 |
28 | ForEach(mappedCurrencies.sorted { $0.name < $1.name }, id: \.code) { currency in
29 | NewCurrencyRow(name: currency.name, code: currency.code)
30 | }
31 | }
32 | } header: {
33 | Text(key)
34 | }
35 | }
36 | }
37 | .overlay {
38 | if searchResult.isEmpty {
39 | if search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
40 | CustomContentUnavailableView("No currencies", imageName: "questionmark", description: "No currencies found. Maybe you have added all of them?")
41 | } else {
42 | CustomContentUnavailableView.search(search.trimmingCharacters(in: .whitespacesAndNewlines))
43 | }
44 | }
45 | }
46 | }
47 | .searchable(
48 | text: $search,
49 | placement: getSearchBarPlacement(),
50 | // placement: .automatic, iOS 26
51 | prompt: "Currency name or ISO Code"
52 | )
53 | .navigationTitle("Add Currency")
54 | }
55 |
56 | private func excludeAdded(_ data: [String]) -> [String] {
57 | let removeSet: Set = Set(UserDefaults.standard.getRawCurrencies())
58 | return data.filter { !removeSet.contains($0) }
59 | }
60 |
61 | private func searchFunc() -> [String : [String]] {
62 | let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines)
63 |
64 | if trimmedSearch.isEmpty {
65 | return currencyCodes
66 | .mapValues { excludeAdded($0) }
67 | .filter { !$0.value.isEmpty }
68 | } else {
69 | return currencyCodes.mapValues { values in
70 | excludeAdded(values).filter { value in
71 | value.localizedCaseInsensitiveContains(trimmedSearch) || (Locale.current.localizedString(forCurrencyCode: value) ?? "").localizedCaseInsensitiveContains(trimmedSearch)
72 | }
73 | }
74 | .filter { !$0.value.isEmpty }
75 | }
76 | }
77 |
78 | private func getSearchBarPlacement() -> SearchFieldPlacement {
79 | if #available(iOS 26.0, *) {
80 | return .automatic
81 | }
82 |
83 | return .navigationBarDrawer(displayMode: .always)
84 | }
85 | }
86 |
87 | struct AddCurrencyView_Previews {
88 | static var previews: some View {
89 | AddCurrencyView()
90 | .environmentObject(CoreDataModel())
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/financecontrol/Error/ErrorHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ErrorHandler.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/10/08.
6 | //
7 |
8 | import Foundation
9 | #if DEBUG
10 | import OSLog
11 | #endif
12 |
13 | final class ErrorHandler: ObservableObject {
14 | static let shared: ErrorHandler = .init()
15 |
16 | @Published var appError: ErrorType? = nil
17 | @Published var showAlert: Bool = false
18 |
19 | func dropError() {
20 | appError = nil
21 | showAlert = false
22 | }
23 | }
24 |
25 | struct ErrorType: Identifiable, Equatable {
26 | let id: UUID = .init()
27 | let errorDescription: String
28 | let failureReason: String
29 | let recoverySuggestion: String
30 | let helpAnchor: String
31 | var createIssue: Bool = true
32 |
33 | init(error: Error) {
34 | self.errorDescription = "Unknown error: \(error.localizedDescription)"
35 | self.failureReason = error.localizedDescription
36 | self.recoverySuggestion = "Please submit a bug report and try to restart the app"
37 | self.helpAnchor = ""
38 | }
39 |
40 | init(_ localizedError: LocalizedError) {
41 | self.errorDescription = localizedError.errorDescription ?? ""
42 | self.failureReason = localizedError.failureReason ?? ""
43 | self.recoverySuggestion = localizedError.recoverySuggestion ?? ""
44 | self.helpAnchor = localizedError.helpAnchor ?? ""
45 | }
46 |
47 | init(_ urlError: URLError) {
48 | switch urlError {
49 | case URLError(.badURL):
50 | self.errorDescription = "Can't reach requested URL"
51 | self.failureReason = urlError.localizedDescription
52 | self.recoverySuggestion = "Try to restart the app"
53 | self.helpAnchor = ""
54 |
55 | case URLError(.badServerResponse):
56 | self.errorDescription = "Squirrel servers did not response correctly"
57 | self.failureReason = urlError.localizedDescription
58 | self.recoverySuggestion = "Try to restart the app"
59 | self.helpAnchor = ""
60 |
61 | case URLError(.notConnectedToInternet):
62 | self.errorDescription = urlError.localizedDescription
63 | self.failureReason = urlError.localizedDescription
64 | self.recoverySuggestion = "Check your internet connection"
65 | self.helpAnchor = ""
66 | self.createIssue = false
67 |
68 | default:
69 | self.errorDescription = urlError.localizedDescription
70 | self.failureReason = urlError.localizedDescription
71 | self.recoverySuggestion = "Try to restart the app"
72 | self.helpAnchor = ""
73 | }
74 | }
75 |
76 | init(errorDescription: String, failureReason: String, recoverySuggestion: String, helpAnchor: String = "") {
77 | self.errorDescription = errorDescription
78 | self.failureReason = failureReason
79 | self.recoverySuggestion = recoverySuggestion
80 | self.helpAnchor = helpAnchor
81 | self.createIssue = false
82 | }
83 |
84 | func publish(file: String? = nil, function: String? = nil) {
85 | #if DEBUG
86 | let fileString = file != nil ? ", in file \(file ?? "")" : ""
87 | let funcString = function != nil ? ", in function \(function ?? "")" : ""
88 | let logger = Logger(subsystem: Vars.appIdentifier, category: "errors")
89 | logger.error("Failure reason: \(self.failureReason)\(fileString)\(funcString), occured at \(Date.now.formatted(date: .numeric, time: .standard))")
90 | #endif
91 |
92 | ErrorHandler.shared.appError = self
93 | ErrorHandler.shared.showAlert = true
94 | HapticManager.shared.notification(.error)
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_Debug.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
57 |
58 |
61 |
62 |
63 |
64 |
68 |
69 |
70 |
71 |
77 |
79 |
85 |
86 |
87 |
88 |
90 |
91 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_CoreDataDebug.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
57 |
58 |
61 |
62 |
63 |
64 |
68 |
69 |
70 |
71 |
77 |
79 |
85 |
86 |
87 |
88 |
90 |
91 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/financecontrol/View/Stats/Views/PieChartLegendView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PieChartLegendView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 6/02/03.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct PieChartLegendView: View {
11 | @EnvironmentObject
12 | private var pcvm: PieChartViewModel
13 |
14 | @Binding
15 | var minimize: Bool
16 | @Binding
17 | var selection: Int
18 |
19 | private var verticalPadding: CGFloat {
20 | if #available(iOS 26, *) {
21 | return 14
22 | }
23 |
24 | return 10
25 | }
26 |
27 | var body: some View {
28 | let data = pcvm.data[(selection >= pcvm.data.count || selection < 0) ? 0 : selection]
29 |
30 | if !data.categories.isEmpty, pcvm.selectedCategory == nil {
31 | Divider()
32 | }
33 |
34 | if let selectedCategory = pcvm.selectedCategory, !(data.categoriesDict[selectedCategory.id]?.places ?? []).isEmpty {
35 | Divider()
36 | }
37 |
38 | Group {
39 | if minimize {
40 | ScrollView(.horizontal, showsIndicators: false) {
41 | WrappedGlassEffectContainer {
42 | HStack(spacing: 10) {
43 | content
44 | }
45 | .padding(.horizontal, 20)
46 | .padding(.vertical, verticalPadding)
47 | }
48 | }
49 | .font(.system(size: 14))
50 | .transaction { transaction in
51 | transaction.animation = nil
52 | }
53 | .transition(.opacity)
54 | } else {
55 | HStack(spacing: 0) {
56 | WrappedGlassEffectContainer {
57 | VStack(alignment: .leading, spacing: 10) {
58 | content
59 | }
60 | }
61 |
62 | Spacer()
63 | }
64 | .font(.system(size: 14))
65 | .padding(.horizontal, 20)
66 | .padding(.vertical, verticalPadding)
67 | .transaction { transaction in
68 | transaction.animation = nil
69 | }
70 | .transition(.maskFromTheBottomWithOpacity)
71 | }
72 | }
73 | }
74 |
75 | @ViewBuilder
76 | private var content: some View {
77 | let data = pcvm.data[(selection >= pcvm.data.count || selection < 0) ? 0 : selection]
78 |
79 | if let selectedCategory = pcvm.selectedCategory {
80 | ForEach(data.categoriesDict[selectedCategory.id]?.places ?? []) { place in
81 | PieChartLegendRowView(category: place)
82 | }
83 | } else {
84 | ForEach(data.categories) { category in
85 | PieChartLegendRowView(category: category)
86 | }
87 |
88 | if let otherCategory = data.otherCategory, !pcvm.showOther {
89 | PieChartLegendRowView(category: otherCategory)
90 | }
91 |
92 | if pcvm.showOther {
93 | ForEach(data.otherCategories) { category in
94 | PieChartLegendRowView(category: category)
95 | }
96 | }
97 | }
98 | }
99 | }
100 |
101 | fileprivate struct WrappedGlassEffectContainer: View where Content: View {
102 | let content: () -> Content
103 |
104 | var body: some View {
105 | if #available(iOS 26.0, *) {
106 | GlassEffectContainer {
107 | content()
108 | }
109 | } else {
110 | content()
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/financecontrol/Data/CoreData/Entities/ReturnEntity/ReturnsModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReturnsModel.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/11/30.
6 | //
7 |
8 | import CoreData
9 |
10 | extension CoreDataModel {
11 | func addReturn(to spending: SpendingEntity, amount: Double, amountUSD: Double, currency: String, date: Date, name: String) {
12 | guard
13 | let description = NSEntityDescription.entity(forEntityName: "ReturnEntity", in: context)
14 | else {
15 | return
16 | }
17 |
18 | let newReturn: ReturnEntity = .init(entity: description, insertInto: context)
19 | newReturn.id = .init()
20 | newReturn.amount = amount
21 | newReturn.amountUSD = amountUSD
22 | newReturn.currency = currency
23 | newReturn.date = date
24 | newReturn.name = name
25 |
26 | spending.addToReturns(newReturn)
27 | manager.save()
28 |
29 | fetchSpendings()
30 |
31 | HapticManager.shared.notification(.success)
32 | }
33 |
34 | func importReturn(to spending: SpendingEntity, returnEntity: ReturnEntity) {
35 | guard
36 | let description = NSEntityDescription.entity(forEntityName: "ReturnEntity", in: context)
37 | else {
38 | ErrorType(CoreDataError.failedToGetEntityDescription).publish()
39 | return
40 | }
41 |
42 | let newReturn: ReturnEntity = .init(entity: description, insertInto: context)
43 | newReturn.id = returnEntity.id ?? .init()
44 | newReturn.amount = returnEntity.amount
45 | newReturn.amountUSD = returnEntity.amountUSD
46 | newReturn.currency = returnEntity.currency ?? spending.wrappedCurrency
47 | newReturn.date = returnEntity.date ?? Date()
48 | newReturn.name = returnEntity.name
49 |
50 | spending.addToReturns(newReturn)
51 |
52 | let privateContext = returnEntity.managedObjectContext
53 |
54 | privateContext?.delete(returnEntity)
55 | }
56 |
57 | func deleteReturn(spendingReturn: ReturnEntity) {
58 | context.delete(spendingReturn)
59 | manager.save()
60 | fetchSpendings()
61 | }
62 |
63 | func editReturn(
64 | entity returnEntity: ReturnEntity,
65 | amount: Double,
66 | amountUSD: Double,
67 | currency: String,
68 | date: Date,
69 | name: String
70 | ) {
71 | returnEntity.amount = amount
72 | returnEntity.amountUSD = amountUSD
73 | returnEntity.currency = currency
74 | returnEntity.date = date
75 | returnEntity.name = name
76 | manager.save()
77 | fetchSpendings()
78 | }
79 |
80 | func editRerturnFromSpending(
81 | spending: SpendingEntity,
82 | oldReturn: ReturnEntity,
83 | amount: Double,
84 | amountUSD: Double,
85 | currency: String,
86 | date: Date,
87 | name: String,
88 | performSave: Bool = true
89 | ) {
90 | guard
91 | let description = NSEntityDescription.entity(forEntityName: "ReturnEntity", in: context)
92 | else {
93 | return
94 | }
95 |
96 | let newReturn = ReturnEntity(entity: description, insertInto: context)
97 |
98 | newReturn.id = UUID()
99 | newReturn.amount = amount
100 | newReturn.amountUSD = amountUSD
101 | newReturn.currency = currency
102 | newReturn.date = date
103 | newReturn.name = name
104 |
105 | spending.removeFromReturns(oldReturn)
106 | spending.addToReturns(newReturn)
107 |
108 | if performSave {
109 | manager.save()
110 | fetchSpendings()
111 | }
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/financecontrol/View/Settings/Views/Category/AddCategoryView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddCategoryView.swift
3 | // financecontrol
4 | //
5 | // Created by PinkXaciD on R 5/07/19.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AddCategoryView: View {
11 | @EnvironmentObject private var cdm: CoreDataModel
12 |
13 | @Binding var selectedCategory: CategoryEntity?
14 | let insert: Bool
15 |
16 | @State private var name: String = ""
17 | @State private var colorSelectedDescription: String = ""
18 | @State private var triedToSave: Bool = false
19 |
20 | @FocusState private var isFocused: Bool
21 |
22 | @Environment(\.dismiss) private var dismiss
23 |
24 | var body: some View {
25 | List {
26 | nameSection
27 |
28 | colorSection
29 | }
30 | .navigationTitle("New Category")
31 | .toolbar {
32 | // keyboardToolbar
33 |
34 | trailingToolbar
35 | }
36 | .addKeyboardToolbar(showToolbar: isFocused) {
37 | clearFocus()
38 | }
39 | }
40 |
41 | private var nameSection: some View {
42 | Section {
43 | TextField("Enter name", text: $name)
44 | .focused($isFocused)
45 | .onAppear(perform: fieldFocus)
46 | } footer: {
47 | if triedToSave && name.isEmpty {
48 | Text("Required")
49 | .foregroundColor(.red)
50 | }
51 |
52 | if name.count >= 50 {
53 | Text("\(100 - name.count) characters left")
54 | .foregroundColor(name.count > 100 ? .red : .secondary)
55 | }
56 | }
57 | }
58 |
59 | private var colorSection: some View {
60 | Section {
61 | CustomColorSelector(colorSelectedDescription: $colorSelectedDescription)
62 | .padding(.vertical, 10)
63 | } footer: {
64 | if triedToSave && colorSelectedDescription.isEmpty {
65 | Text("Required")
66 | .foregroundColor(.red)
67 | }
68 | }
69 | }
70 |
71 | private var keyboardToolbar: ToolbarItemGroup {
72 | hideKeyboardToolbar {
73 | clearFocus()
74 | }
75 | }
76 |
77 | private var trailingToolbar: ToolbarItem {
78 | ToolbarItem(placement: .topBarTrailing) {
79 | Button("Save") {
80 | if name.isEmpty || colorSelectedDescription.isEmpty || name.count > 100 {
81 | triedToSave = true
82 | HapticManager.shared.notification(.warning)
83 | } else {
84 | if insert {
85 | selectedCategory = cdm.addCategory(name: name, color: colorSelectedDescription)
86 | } else {
87 | _ = cdm.addCategory(name: name, color: colorSelectedDescription)
88 | }
89 |
90 | HapticManager.shared.notification(.success)
91 | dismiss()
92 | }
93 | }
94 | .font(.body.bold())
95 | .foregroundColor(name.isEmpty || colorSelectedDescription.isEmpty || name.count > 100 ? .secondary.opacity(0.7) : .accentColor)
96 | }
97 | }
98 |
99 | func clearFocus() {
100 | isFocused = false
101 | }
102 |
103 | func fieldFocus() {
104 | isFocused = true
105 | }
106 | }
107 |
108 | //struct NewCategoryView_Previews: PreviewProvider {
109 | // static var previews: some View {
110 | // @State var id = UUID()
111 | //
112 | // AddCategoryView(selectedCategory: $id, insert: false)
113 | // .environmentObject(CoreDataModel())
114 | // }
115 | //}
116 |
--------------------------------------------------------------------------------
/financecontrol/Extensions/TimeZone+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TimeZone+Extensions.swift
3 | // Squirrel
4 | //
5 | // Created by PinkXaciD on 2025/01/17.
6 | //
7 |
8 | import Foundation
9 |
10 | extension TimeZone {
11 | enum Format: RawRepresentable, CaseIterable {
12 | case gmt, name, location
13 |
14 | var rawValue: Int {
15 | switch self {
16 | case .gmt:
17 | 0
18 | case .name:
19 | 1
20 | case .location:
21 | 2
22 | }
23 | }
24 |
25 | init(rawValue: Int) {
26 | switch rawValue {
27 | case 0:
28 | self = .gmt
29 | case 1:
30 | self = .name
31 | default:
32 | self = .location
33 | }
34 | }
35 |
36 | var localizedName: String {
37 | switch self {
38 | case .gmt:
39 | String(localized: "timezone-offset-from-gmt", comment: "Timezone ofset from GMT")
40 | case .name:
41 | String(localized: "timezone-name", comment: "Timezone name")
42 | case .location:
43 | String(localized: "timezone-location", comment: "Timezone location")
44 | }
45 | }
46 |
47 | var formatStyle: Date.FormatStyle.Symbol.TimeZone {
48 | switch self {
49 | case .gmt:
50 | .localizedGMT(.short)
51 | case .name:
52 | .specificName(.long)
53 | case .location:
54 | .genericLocation
55 | }
56 | }
57 | }
58 |
59 | func formatted(_ style: Self.Format, for date: Date = Date()) -> String {
60 | var formatStyle = Date.FormatStyle()
61 | formatStyle.timeZone = self
62 |
63 | return date.formatted(formatStyle.timeZone(style.formatStyle))
64 | }
65 |
66 | func getImage() -> String {
67 | let offset = self.hoursFromGMT()
68 |
69 | switch offset {
70 | case ...(-3):
71 | return "globe.americas.fill"
72 | case ...3:
73 | return "globe.europe.africa.fill"
74 | case ...7:
75 | return "globe.central.south.asia.fill"
76 | default:
77 | return "globe.asia.australia.fill"
78 | }
79 | }
80 | }
81 |
82 | extension TimeZone {
83 | func hoursFromGMT() -> Double {
84 | return Double(self.secondsFromGMT() / 3600)
85 | }
86 | }
87 |
88 | /*
89 | var name: String {
90 | switch self {
91 | case .identifier:
92 | String(localized: "timezone-identifier", comment: "Timezone identifier")
93 | case .gmt:
94 | String(localized: "timezone-offset-from-gmt", comment: "Timezone ofset from GMT")
95 | case .name:
96 | String(localized: "timezone-name", comment: "Timezone name")
97 | }
98 | }
99 |
100 | var example: String {
101 | switch self {
102 | case .identifier:
103 | TimeZone.autoupdatingCurrent.identifier
104 | case .gmt:
105 | Date().formatted(.dateTime.timeZone(.localizedGMT(.short)))
106 | case .name:
107 | TimeZone.autoupdatingCurrent.localizedName(for: .standard, locale: .autoupdatingCurrent) ?? "Error"
108 | }
109 | }
110 |
111 | func formatTimeZone(_ timeZone: TimeZone) -> String {
112 | switch self {
113 | case .identifier:
114 | return timeZone.identifier
115 | case .gmt:
116 | if timeZone.secondsFromGMT() != 0 {
117 | return "GMT\(timeZone.hoursFromGMT().formatted(.number.sign(strategy: .always())))"
118 | }
119 |
120 | return "GMT"
121 | case .name:
122 | return timeZone.localizedName(for: .standard, locale: .autoupdatingCurrent) ?? timeZone.identifier
123 | }
124 | }
125 | */
126 |
--------------------------------------------------------------------------------