├── Aspire Budgeting
├── Assets.xcassets
│ ├── Contents.json
│ ├── logo.imageset
│ │ ├── 1024.png
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── 20.png
│ │ ├── 29.png
│ │ ├── 40.png
│ │ ├── 50.png
│ │ ├── 57.png
│ │ ├── 58.png
│ │ ├── 60.png
│ │ ├── 72.png
│ │ ├── 76.png
│ │ ├── 80.png
│ │ ├── 87.png
│ │ ├── 100.png
│ │ ├── 1024.png
│ │ ├── 114.png
│ │ ├── 120.png
│ │ ├── 144.png
│ │ ├── 152.png
│ │ ├── 167.png
│ │ ├── 180.png
│ │ └── Contents.json
│ ├── circularLogo.imageset
│ │ ├── 1.png
│ │ ├── 1_3x.png
│ │ ├── 1_dark.png
│ │ ├── 1_dark_3x.png
│ │ └── Contents.json
│ ├── google.imageset
│ │ ├── google.png
│ │ └── Contents.json
│ ├── memo_icon.imageset
│ │ ├── ls_memo.png
│ │ └── Contents.json
│ ├── moneyBag.imageset
│ │ ├── moneyBag.png
│ │ └── Contents.json
│ ├── pen_icon.imageset
│ │ ├── shape_2.png
│ │ └── Contents.json
│ ├── bankIcon.imageset
│ │ ├── bank_icon.png
│ │ ├── bank_icon@2x.png
│ │ ├── bank_icon@3x.png
│ │ └── Contents.json
│ ├── sheetsIcon.imageset
│ │ ├── sheets_48dp 1.png
│ │ ├── sheets_48dp@3x.png
│ │ └── Contents.json
│ ├── calendar_icon.imageset
│ │ ├── uil_calender.png
│ │ └── Contents.json
│ ├── minigraph_down.imageset
│ │ ├── minigraph@3x.png
│ │ ├── minigraph_down.png
│ │ └── Contents.json
│ ├── minigraph_up.imageset
│ │ ├── minigraph@3x.png
│ │ ├── minigraph_up.png
│ │ └── Contents.json
│ ├── categories_icon.imageset
│ │ ├── dashicons_category.png
│ │ └── Contents.json
│ ├── diamondSeparator.imageset
│ │ ├── slide-colored@2x.png
│ │ ├── slide-colored@3x.png
│ │ └── Contents.json
│ ├── dollar_icon.imageset
│ │ ├── ic_baseline-attach-money.png
│ │ └── Contents.json
│ ├── accounts_icon.imageset
│ │ ├── ic_baseline-account-balance-wallet.png
│ │ └── Contents.json
│ ├── expenseGreen.colorset
│ │ └── Contents.json
│ ├── expenseRed.colorset
│ │ └── Contents.json
│ ├── tabBarItemSelectedTintColor.colorset
│ │ └── Contents.json
│ ├── accountBalanceCardColor.colorset
│ │ └── Contents.json
│ ├── primaryTextColor.colorset
│ │ └── Contents.json
│ ├── tabBarColor.colorset
│ │ └── Contents.json
│ ├── primaryBackgroundColor.colorset
│ │ └── Contents.json
│ ├── secondaryTextColor.colorset
│ │ └── Contents.json
│ └── tabBarItemDefaultTintColor.colorset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Resources
│ ├── Karla-Bold.ttf
│ ├── Nunito-Bold.ttf
│ ├── Rubik-Light.ttf
│ ├── Rubik-Medium.ttf
│ ├── Karla-Regular.ttf
│ ├── Nunito-Regular.ttf
│ ├── Rubik-Regular.ttf
│ └── Nunito-SemiBold.ttf
├── Models
│ ├── AspireSheet.swift
│ ├── CategoryTransfer.swift
│ ├── TrxCategory.swift
│ ├── User.swift
│ ├── DashboardCategoryRow.swift
│ ├── AddTransactionMetadata.swift
│ ├── DashboardCategory.swift
│ ├── File.swift
│ ├── AccountBalances.swift
│ ├── AspireNumber.swift
│ ├── Transaction.swift
│ └── Dashboard.swift
├── ViewModels
│ ├── SettingsViewModel.swift
│ ├── AddTransactionViewModel.swift
│ ├── AccountBalancesViewModel.swift
│ ├── ViewModel.swift
│ ├── TransactionsViewModel.swift
│ ├── CategoryTransferViewModel.swift
│ ├── FileSelectorViewModel.swift
│ └── DashboardViewModel.swift
├── Facilities
│ ├── Publisher+Extensions.swift
│ ├── AspireVersionInfo.swift
│ ├── AuthenticationManager.swift
│ ├── AspireFonts.swift
│ ├── GoogleSDKCredentials.swift
│ ├── Images.swift
│ ├── LocalAuthorizationManager.swift
│ ├── ObjectFactory.swift
│ ├── ValueRangeCreator.swift
│ ├── AppDefaults.swift
│ ├── GoogleUserManager.swift
│ └── GoogleDriveManager.swift
├── Views
│ ├── ErrorBannerView.swift
│ ├── AspireNumberView.swift
│ ├── YoutubePlayerView.swift
│ ├── GradientTextView.swift
│ ├── BackgroundSplitColorView.swift
│ ├── AmountTextField.swift
│ ├── AspireSegmentedView.swift
│ ├── FaceIDView.swift
│ ├── ShareSheet.swift
│ ├── GoogleSignInButton.swift
│ ├── AspireSegmentedItem.swift
│ ├── BannerView.swift
│ ├── AspirePickerButton.swift
│ ├── SettingsView.swift
│ ├── AspireNavigationBar.swift
│ ├── SignInView.swift
│ ├── TabBarView
│ │ ├── ProminentTabBarItemView.swift
│ │ ├── TabBarItemView.swift
│ │ └── TabBarView.swift
│ ├── CardView
│ │ ├── BaseCardView.swift
│ │ └── CardTotalsView.swift
│ ├── LoadingView.swift
│ ├── AspireRadioControl.swift
│ ├── CategoryListView.swift
│ ├── Dashboard
│ │ ├── DashboardCardsListView.swift
│ │ ├── DashboardView.swift
│ │ ├── CollapsedCardView.swift
│ │ ├── DashboardRow.swift
│ │ └── ExpandedCardView.swift
│ ├── SearchBar.swift
│ ├── AspireTextField.swift
│ ├── AspireButton.swift
│ ├── AspireProgressBar.swift
│ ├── AspireMasterView.swift
│ ├── AccountBalancesView.swift
│ ├── FileSelectorView.swift
│ ├── CategoryDetailsView.swift
│ ├── TransactionsView.swift
│ └── CategoryTransferView.swift
├── PreviewProvider
│ ├── MockAuthorizer.swift
│ └── MockUser.swift
├── ContentView.swift
├── AppDelegate.swift
├── Info.plist
└── Base.lproj
│ └── LaunchScreen.storyboard
├── Documentation
├── out
│ ├── LaunchSequence
│ │ └── LaunchSequence.png
│ ├── DashboardView_InitSeq
│ │ └── DashboardView_InitSeq.png
│ ├── ContentView_InitSequence
│ │ └── ContentView_InitSequence.png
│ └── AspireMasterView_InitSequence
│ │ └── AspireMasterView_InitSequence.png
├── ContentView_InitSequence.puml
├── DashboardView_InitSeq.puml
├── AspireMasterView_InitSequence.puml
└── LaunchSequence.puml
├── Aspire Budgeting.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── xcshareddata
│ └── IDETemplateMacros.plist
└── xcuserdata
│ └── Labs.xcuserdatad
│ └── xcschemes
│ └── xcschememanagement.plist
├── fastlane
├── Appfile
├── Scanfile
├── Fastfile
└── README.md
├── Aspire BudgetingTests
├── MockFile.swift
├── Resources
│ ├── credentials.plist
│ └── bad_credentials.plist
├── MockNotificationCenter.swift
├── MockGIDSignIn.swift
├── MockSpreadsheet.swift
├── Info.plist
├── DashboardViewModelTests.swift
├── ViewModelTests.swift
├── DashboardMetadataTests.swift
├── DashboardTests.swift
├── StateManagerTests.swift
├── UserManagerTests.swift
├── GoogleSDKCredentialsTests.swift
└── GoogleDriveManagerTests.swift
├── Gemfile
├── Podfile
├── .github
└── workflows
│ └── build_and_test.yml
├── BuildPhases
└── set_google_credentials.sh
├── Podfile.lock
├── .swiftlint.yml
├── README.md
├── .gitignore
└── CODE_OF_CONDUCT.md
/Aspire Budgeting/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Karla-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Karla-Bold.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Nunito-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Nunito-Bold.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Rubik-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Rubik-Light.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Rubik-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Rubik-Medium.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Karla-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Karla-Regular.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Nunito-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Nunito-Regular.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Rubik-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Rubik-Regular.ttf
--------------------------------------------------------------------------------
/Aspire Budgeting/Resources/Nunito-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Resources/Nunito-SemiBold.ttf
--------------------------------------------------------------------------------
/Documentation/out/LaunchSequence/LaunchSequence.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Documentation/out/LaunchSequence/LaunchSequence.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/logo.imageset/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/logo.imageset/1024.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/20.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/20.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/29.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/29.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/40.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/40.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/50.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/50.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/57.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/57.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/58.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/58.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/60.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/72.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/76.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/80.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/80.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/87.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/87.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/100.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/100.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/1024.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/114.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/114.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/120.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/144.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/152.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/167.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/167.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/AppIcon.appiconset/180.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/google.imageset/google.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/google.imageset/google.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1_3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1_3x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/memo_icon.imageset/ls_memo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/memo_icon.imageset/ls_memo.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/moneyBag.imageset/moneyBag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/moneyBag.imageset/moneyBag.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/pen_icon.imageset/shape_2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/pen_icon.imageset/shape_2.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/bank_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/bank_icon.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1_dark.png
--------------------------------------------------------------------------------
/Documentation/out/DashboardView_InitSeq/DashboardView_InitSeq.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Documentation/out/DashboardView_InitSeq/DashboardView_InitSeq.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/bank_icon@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/bank_icon@2x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/bank_icon@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/bank_icon@3x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1_dark_3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/1_dark_3x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/sheetsIcon.imageset/sheets_48dp 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/sheetsIcon.imageset/sheets_48dp 1.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/AspireSheet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireSheet.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | struct AspireSheet: Codable {
7 | let file: File
8 | let dataMap: [String: String]
9 | }
10 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/calendar_icon.imageset/uil_calender.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/calendar_icon.imageset/uil_calender.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/minigraph_down.imageset/minigraph@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/minigraph_down.imageset/minigraph@3x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/minigraph_up.imageset/minigraph@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/minigraph_up.imageset/minigraph@3x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/minigraph_up.imageset/minigraph_up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/minigraph_up.imageset/minigraph_up.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/sheetsIcon.imageset/sheets_48dp@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/sheetsIcon.imageset/sheets_48dp@3x.png
--------------------------------------------------------------------------------
/Documentation/out/ContentView_InitSequence/ContentView_InitSequence.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Documentation/out/ContentView_InitSequence/ContentView_InitSequence.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/minigraph_down.imageset/minigraph_down.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/minigraph_down.imageset/minigraph_down.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/categories_icon.imageset/dashicons_category.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/categories_icon.imageset/dashicons_category.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/diamondSeparator.imageset/slide-colored@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/diamondSeparator.imageset/slide-colored@2x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/diamondSeparator.imageset/slide-colored@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/diamondSeparator.imageset/slide-colored@3x.png
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/dollar_icon.imageset/ic_baseline-attach-money.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/dollar_icon.imageset/ic_baseline-attach-money.png
--------------------------------------------------------------------------------
/Documentation/out/AspireMasterView_InitSequence/AspireMasterView_InitSequence.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Documentation/out/AspireMasterView_InitSequence/AspireMasterView_InitSequence.png
--------------------------------------------------------------------------------
/Aspire Budgeting.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/accounts_icon.imageset/ic_baseline-account-balance-wallet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aspirebudgetingmobile/aspirebudgeting_ios/HEAD/Aspire Budgeting/Assets.xcassets/accounts_icon.imageset/ic_baseline-account-balance-wallet.png
--------------------------------------------------------------------------------
/fastlane/Appfile:
--------------------------------------------------------------------------------
1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app
2 | # apple_id("[[APPLE_ID]]") # Your Apple email address
3 |
4 |
5 | # For more information about the Appfile, see:
6 | # https://docs.fastlane.tools/advanced/#appfile
7 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/SettingsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsViewModel.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | struct SettingsViewModel {
7 | let fileName: String
8 | var changeSheet: () -> Void
9 | var fileSelectorVM: FileSelectorViewModel
10 | }
11 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/MockFile.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockFile.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import Foundation
8 |
9 | struct MockFile: AspireFile {
10 | var name: String?
11 | var identifier: String?
12 | }
13 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/CategoryTransfer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryTransfer.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | struct CategoryTransfer {
7 | let amount: String
8 | let fromCategory: TrxCategory
9 | let toCategory: TrxCategory
10 | let memo: String?
11 | }
12 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
6 |
7 | # gem "rails"
8 | gem "danger"
9 | gem "danger-swiftlint"
10 | gem "fastlane"
11 | gem "xcode-install"
12 | gem "cocoapods"
13 |
--------------------------------------------------------------------------------
/Aspire Budgeting.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/Publisher+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Publisher+Extensions.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 |
8 | extension Publisher {
9 | func toResult() -> AnyPublisher, Never> {
10 | map(Result.success)
11 | .catch { Just(Result.failure($0)) }
12 | .eraseToAnyPublisher()
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/TrxCategory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TrxCategory.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | struct TrxCategory {
7 | let title: String
8 | }
9 |
10 | struct TrxCategories: ConstructableFromRows {
11 | let categories: [TrxCategory]
12 |
13 | init(rows: [[String]]) {
14 | categories = rows.flatMap { $0.map(TrxCategory.init) }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Aspire Budgeting.xcodeproj/xcshareddata/IDETemplateMacros.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | FILEHEADER
6 |
7 | // ___FILENAME___
8 | // ___PRODUCTNAME___
9 | //
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/User.swift:
--------------------------------------------------------------------------------
1 | //
2 | // User.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 | import GTMSessionFetcher
8 |
9 | struct User {
10 | let name: String
11 | let authorizer: GTMFetcherAuthorizationProtocol
12 |
13 | init(name: String, authorizer: GTMFetcherAuthorizationProtocol) {
14 | self.name = name
15 | self.authorizer = authorizer
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/logo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "scale" : "2x"
10 | },
11 | {
12 | "idiom" : "universal",
13 | "filename" : "1024.png",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/google.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "google.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/pen_icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "filename" : "shape_2.png",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "version" : 1,
19 | "author" : "xcode"
20 | }
21 | }
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/memo_icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "ls_memo.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/moneyBag.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "idiom" : "universal",
9 | "scale" : "2x"
10 | },
11 | {
12 | "filename" : "moneyBag.png",
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/calendar_icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "uil_calender.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/categories_icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "dashicons_category.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/DashboardCategoryRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryRow.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | struct CategoryRow: Hashable {
7 | let categoryName: String
8 | let available: String
9 | let spent: String
10 | let budgeted: String
11 | let monthly: String
12 |
13 | init(row: [String]) {
14 | categoryName = row[2]
15 | available = row[3]
16 | spent = row[6]
17 | monthly = row[7]
18 | budgeted = row[9]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/dollar_icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "ic_baseline-attach-money.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/expenseGreen.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.471",
9 | "green" : "0.733",
10 | "red" : "0.282"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/expenseRed.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.314",
9 | "green" : "0.314",
10 | "red" : "1.000"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/accounts_icon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "ic_baseline-account-balance-wallet.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/tabBarItemSelectedTintColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.620",
9 | "green" : "0.820",
10 | "red" : "0.192"
11 | }
12 | },
13 | "idiom" : "universal"
14 | }
15 | ],
16 | "info" : {
17 | "author" : "xcode",
18 | "version" : 1
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/minigraph_up.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "minigraph_up.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "filename" : "minigraph@3x.png",
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "author" : "xcode",
20 | "version" : 1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Podfile:
--------------------------------------------------------------------------------
1 | # Uncomment the next line to define a global platform for your project
2 | platform :ios, '13.0'
3 |
4 | target 'Aspire Budgeting' do
5 | # Comment the next line if you don't want to use dynamic frameworks
6 | use_frameworks!
7 |
8 | # Pods for Aspire Budgeting
9 | pod 'GoogleAPIClientForREST/Sheets'
10 | pod 'GoogleAPIClientForREST/Drive'
11 | pod 'GoogleSignIn'
12 | target 'Aspire BudgetingTests' do
13 | inherit! :search_paths
14 | # Pods for testing
15 | end
16 |
17 | end
18 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/minigraph_down.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "minigraph_down.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "filename" : "minigraph@3x.png",
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "author" : "xcode",
20 | "version" : 1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/sheetsIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "sheets_48dp 1.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "filename" : "sheets_48dp@3x.png",
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "author" : "xcode",
20 | "version" : 1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/Resources/credentials.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | test_id
7 | REVERSED_CLIENT_ID
8 | com.google.test_reverse_id
9 | PLIST_VERSION
10 | 1
11 | BUNDLE_ID
12 | test_bundle_id
13 |
14 |
15 |
--------------------------------------------------------------------------------
/Documentation/ContentView_InitSequence.puml:
--------------------------------------------------------------------------------
1 | @startuml
2 | actor User
3 |
4 | User -> ContentView : Launch App
5 | activate ContentView
6 |
7 | ContentView -> StateManager : getCurrentState()
8 |
9 | alt isLoggedOut
10 | ContentView -> SignInView : init()
11 |
12 | else needsLocalAuth
13 | ContentView -> FaceIDView : init()
14 |
15 | else hasDefaultSheet
16 | ContentView -> AspireMasterView : init()
17 |
18 | else
19 | ContentView -> FileSelectorView : init()
20 | end
21 |
22 | deactivate ContentView
23 | @enduml
--------------------------------------------------------------------------------
/Aspire BudgetingTests/MockNotificationCenter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockNotificationCenter.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import Foundation
8 |
9 | final class MockNotificationCenter: AspireNotificationCenter {
10 | var notificationName = Notification.Name("")
11 |
12 | func post(
13 | name aName: NSNotification.Name,
14 | object anObject: Any?,
15 | userInfo aUserInfo: [AnyHashable: Any]?
16 | ) {
17 | notificationName = aName
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/diamondSeparator.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "filename" : "slide-colored@2x.png",
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "filename" : "slide-colored@3x.png",
14 | "idiom" : "universal",
15 | "scale" : "3x"
16 | }
17 | ],
18 | "info" : {
19 | "author" : "xcode",
20 | "version" : 1
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/AddTransactionMetadata.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddTransactionMetadata.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | protocol ConstructableFromBatchRequest {
9 | init(rowsList: [[String]])
10 | }
11 |
12 | struct AddTransactionMetadata: ConstructableFromBatchRequest {
13 | let transactionCategories: [String]
14 | let transactionAccounts: [String]
15 |
16 | init(rowsList: [[String]]) {
17 | transactionCategories = rowsList[0]
18 | transactionAccounts = rowsList[1]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/fastlane/Scanfile:
--------------------------------------------------------------------------------
1 | # For more information about this configuration visit
2 | # https://docs.fastlane.tools/actions/scan/#scanfile
3 |
4 | # In general, you can use the options available
5 | # fastlane scan --help
6 |
7 | # Remove the # in front of the line to enable the option
8 |
9 | # scheme("Example")
10 |
11 | # open_report(true)
12 |
13 | # clean(true)
14 |
15 | # Enable skip_build to skip debug builds for faster test performance
16 | #skip_build(true)
17 |
18 | devices(["iPhone 8"])
19 | reset_simulator(true)
20 | clean(true)
21 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/bankIcon.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "bank_icon.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "filename" : "bank_icon@2x.png",
10 | "idiom" : "universal",
11 | "scale" : "2x"
12 | },
13 | {
14 | "filename" : "bank_icon@3x.png",
15 | "idiom" : "universal",
16 | "scale" : "3x"
17 | }
18 | ],
19 | "info" : {
20 | "author" : "xcode",
21 | "version" : 1
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/ErrorBannerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ErrorBannerView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct ErrorBannerView: View {
9 | var error: Error
10 |
11 | var body: some View {
12 | Text(error.localizedDescription)
13 | .font(.custom("Rubik-Light", size: 18))
14 | .foregroundColor(.white)
15 | .opacity(0.6)
16 | }
17 | }
18 |
19 | // struct ErrorBannerView_Previews: PreviewProvider {
20 | // static var previews: some View {
21 | // ErrorBannerView()
22 | // }
23 | // }
24 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/DashboardCategory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Category.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | struct DashboardCategory: Hashable, Equatable {
7 | let categoryName: String
8 | let available: AspireNumber
9 | let spent: AspireNumber
10 | let budgeted: AspireNumber
11 | let monthly: AspireNumber
12 |
13 | init(row: [String]) {
14 | categoryName = row[2]
15 | available = AspireNumber(stringValue: row[3])
16 | spent = AspireNumber(stringValue: row[6])
17 | monthly = AspireNumber(stringValue: row[7])
18 | budgeted = AspireNumber(stringValue: row[9])
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/Resources/bad_credentials.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BAD_CLIENT_ID
6 | 901460211277-u4q1hhhh3697g3e590f2q1s5e089pid5.apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.901460211277-u4q1hhhh3697g3e590f2q1s5e089pid5
9 | PLIST_VERSION
10 | 1
11 | BUNDLE_ID
12 | in.teramolabs.Aspire-Budgeting
13 |
14 |
15 |
--------------------------------------------------------------------------------
/Documentation/DashboardView_InitSeq.puml:
--------------------------------------------------------------------------------
1 | @startuml
2 | actor AspireMasterView
3 | activate AspireMasterView
4 |
5 | AspireMasterView -> DashboardView : init(file)
6 | activate DashboardView
7 | DashboardView -> DashboardView : onAppear()
8 | activate DashboardView
9 | DashboardView -> GoogleSheetsManager : verifySheet(file)
10 |
11 | alt sheetsManager.error == nil
12 | loop For each category group
13 | DashboardView -> DashboardCardView : init()
14 | end
15 |
16 | else sheetsManager.error != nil
17 | DashboardView -> ErrorBannerView : init()
18 | end
19 | deactivate DashboardView
20 | deactivate DashboardView
21 | deactivate AspireMasterView
22 | @enduml
--------------------------------------------------------------------------------
/Documentation/AspireMasterView_InitSequence.puml:
--------------------------------------------------------------------------------
1 | @startuml
2 | actor User
3 |
4 | User -> ContentView : Launch App
5 | activate ContentView
6 |
7 | ContentView -> AspireMasterView : init()
8 |
9 | activate AspireMasterView
10 | AspireMasterView -> AspireNavigationBar : init()
11 | AspireMasterView -> AspireSegmentedView : init()
12 |
13 | alt selectedSegment == 0
14 | AspireMasterView -> GoogleSheetsManager : getDefaultFile()
15 | AspireMasterView -> DashboardView: init(file)
16 |
17 | else selectedSegment == 1
18 | AspireMasterView -> AccountBalancesView : init()
19 | end
20 |
21 |
22 | deactivate AspireMasterView
23 | deactivate ContentView
24 | @enduml
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/accountBalanceCardColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x48",
9 | "green" : "0x37",
10 | "red" : "0x2D"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "idiom" : "universal"
23 | }
24 | ],
25 | "info" : {
26 | "author" : "xcode",
27 | "version" : 1
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/AspireVersionInfo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireVersionInfo.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | enum AspireVersionInfo {
9 | private static let infoDictionary = Bundle.main.infoDictionary
10 |
11 | static var build: String {
12 | guard let build = (infoDictionary?["CFBundleVersion"] as? String) else {
13 | fatalError("Could not read Info.plist")
14 | }
15 | return build
16 | }
17 |
18 | static var version: String {
19 | guard let version = (infoDictionary?["CFBundleShortVersionString"] as? String) else {
20 | fatalError("Could not read Info.plist")
21 | }
22 | return version
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Aspire Budgeting/PreviewProvider/MockAuthorizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockAuthorizer.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | import Foundation
7 | import GTMSessionFetcher
8 |
9 | final class MockAuthorizer: NSObject, GTMFetcherAuthorizationProtocol {
10 | func authorizeRequest(_ request: NSMutableURLRequest?, delegate: Any, didFinish sel: Selector) {}
11 |
12 | func stopAuthorization() {}
13 |
14 | func stopAuthorization(for request: URLRequest) {}
15 |
16 | func isAuthorizingRequest(_ request: URLRequest) -> Bool {
17 | false
18 | }
19 |
20 | func isAuthorizedRequest(_ request: URLRequest) -> Bool {
21 | false
22 | }
23 |
24 | var userEmail: String?
25 | }
26 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/MockGIDSignIn.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Mocks.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import Foundation
8 | import GoogleSignIn
9 |
10 | final class MockGIDSignIn: IGIDSignIn {
11 | var presentingViewController: UIViewController!
12 |
13 | var signInCalled = false
14 | func signIn() {
15 | signInCalled = true
16 | }
17 |
18 | var clientID: String!
19 | weak var delegate: GIDSignInDelegate!
20 | var scopes: [Any]!
21 |
22 | var restoreCalled = false
23 | func restorePreviousSignIn() {
24 | restoreCalled = true
25 | }
26 |
27 | var signOutCalled = false
28 | func signOut() {
29 | signOutCalled = true
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireNumberView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireNumberView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireNumberView: View {
9 | let number: AspireNumber
10 | var body: some View {
11 | Text(number.stringValue)
12 | .font(.karlaBold(size: 16))
13 | .foregroundColor(number.isNegative ? .materialRed800 : .materialGreen800)
14 |
15 | }
16 | }
17 |
18 | struct AspireNumberView_Previews: PreviewProvider {
19 | static var previews: some View {
20 | Group {
21 | AspireNumberView(number: AspireNumber(stringValue: "$50"))
22 | AspireNumberView(number: AspireNumber(stringValue: "-$50"))
23 | }
24 |
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Aspire Budgeting/PreviewProvider/MockUser.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockUser.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | import Foundation
7 | import GoogleSignIn
8 | import GTMSessionFetcher
9 |
10 | final class MockProfile: GIDProfileData {
11 | override var name: String! {
12 | "First Last"
13 | }
14 | }
15 |
16 | final class MockAuthentication: GIDAuthentication {
17 | override func fetcherAuthorizer() -> GTMFetcherAuthorizationProtocol! {
18 | MockAuthorizer()
19 | }
20 | }
21 |
22 | final class MockUser: GIDGoogleUser {
23 | override var profile: GIDProfileData! {
24 | MockProfile()
25 | }
26 |
27 | override var authentication: GIDAuthentication! {
28 | MockAuthentication()
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/AuthenticationManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AuthenticationManager.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 |
8 | final class AuthenticationManager: ObservableObject {
9 |
10 | private let userManager: UserManager
11 | private var cancellables = Set()
12 |
13 | @Published private(set) var user: User?
14 |
15 | var isLoggedOut: Bool {
16 | user == nil
17 | }
18 |
19 | init(userManager: UserManager) {
20 | self.userManager = userManager
21 | }
22 |
23 | func authenticateRemotely() {
24 | userManager
25 | .userPublisher
26 | .sink { user in
27 | self.user = user
28 | }
29 | .store(in: &cancellables)
30 |
31 | userManager.authenticate()
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Aspire Budgeting.xcodeproj/xcuserdata/Labs.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Aspire Budgeting.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 7
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | 06ED8A5E235A908E009BA6A7
16 |
17 | primary
18 |
19 |
20 | 06ED8A74235A9091009BA6A7
21 |
22 | primary
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/AddTransactionViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddTransactionViewModel.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | typealias AddTransactionViewModel = ViewModel
9 | typealias SubmitResultHandler = (Result) -> Void
10 | struct AddTrxDataProvider {
11 | let transactionCategories: [String]
12 | let transactionAccounts: [String]
13 | let submit: (Transaction, @escaping SubmitResultHandler) -> Void
14 |
15 | init(metadata: AddTransactionMetadata,
16 | submitAction: @escaping (Transaction, @escaping SubmitResultHandler) -> Void) {
17 | self.transactionCategories = metadata.transactionCategories
18 | self.transactionAccounts = metadata.transactionAccounts
19 | self.submit = submitAction
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/fastlane/Fastfile:
--------------------------------------------------------------------------------
1 | # This file contains the fastlane.tools configuration
2 | # You can find the documentation at https://docs.fastlane.tools
3 | #
4 | # For a list of all available actions, check out
5 | #
6 | # https://docs.fastlane.tools/actions
7 | #
8 | # For a list of all available plugins, check out
9 | #
10 | # https://docs.fastlane.tools/plugins/available-plugins
11 | #
12 |
13 | # Uncomment the line if you want fastlane to automatically update itself
14 | # update_fastlane
15 | setup_travis
16 |
17 | default_platform(:ios)
18 |
19 | before_all do
20 | xcversion(version: "12.2")
21 | sh 'pod install'
22 | end
23 |
24 | platform :ios do
25 | desc 'Runs the unit tests in Aspire BudgetingTests'
26 | lane :unit_test do
27 | scan(scheme: "Aspire Budgeting")
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/File.swift:
--------------------------------------------------------------------------------
1 | //
2 | // File.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 | import GoogleAPIClientForREST
8 |
9 | protocol AspireFile {
10 | var name: String? { get }
11 | var identifier: String? { get }
12 | }
13 |
14 | extension GTLRDrive_File: AspireFile {}
15 |
16 | struct File: Identifiable, Codable {
17 | let id: String
18 | let name: String
19 |
20 | init(driveFile: AspireFile) {
21 | name = driveFile.name ?? "no file name"
22 | id = driveFile.identifier ?? ""
23 | }
24 |
25 | init(id: String, name: String) {
26 | self.name = name
27 | self.id = id
28 | }
29 | }
30 |
31 | extension File: Equatable {
32 | static func == (lhs: File, rhs: File) -> Bool {
33 | (lhs.name == rhs.name) && (lhs.id == rhs.id)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/YoutubePlayerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // YoutubePlayerView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 | import WebKit
8 |
9 | struct YoutubePlayerView: UIViewRepresentable {
10 | func updateUIView(_ uiView: WKWebView, context: UIViewRepresentableContext) {}
11 |
12 | func makeUIView(context: UIViewRepresentableContext) -> WKWebView {
13 | let webConfiguration = WKWebViewConfiguration()
14 | webConfiguration.allowsInlineMediaPlayback = false
15 |
16 | let webView = WKWebView(frame: .zero, configuration: webConfiguration)
17 |
18 | let videoURL = URL(string: "https://www.youtube.com/embed/RBf9YBBDgbs")
19 | let request = URLRequest(url: videoURL!)
20 | webView.load(request)
21 |
22 | return webView
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/MockSpreadsheet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockSpreadsheet.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | import Foundation
7 | import GoogleAPIClientForREST
8 |
9 | final class MockSpreadsheet: GTLRSheets_Spreadsheet {
10 | init(sheets: [GTLRSheets_Sheet]?) {
11 | super.init()
12 | super.sheets = sheets
13 | }
14 |
15 | @available(*, unavailable)
16 | required init?(coder: NSCoder) {
17 | fatalError("init(coder:) has not been implemented")
18 | }
19 | }
20 |
21 | final class MockSheet: GTLRSheets_Sheet {
22 | init(properties: GTLRSheets_SheetProperties) {
23 | super.init()
24 | super.properties = properties
25 | }
26 |
27 | @available(*, unavailable)
28 | required init?(coder: NSCoder) {
29 | fatalError("init(coder:) has not been implemented")
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/fastlane/README.md:
--------------------------------------------------------------------------------
1 | fastlane documentation
2 | ================
3 | # Installation
4 |
5 | Make sure you have the latest version of the Xcode command line tools installed:
6 |
7 | ```
8 | xcode-select --install
9 | ```
10 |
11 | Install _fastlane_ using
12 | ```
13 | [sudo] gem install fastlane -NV
14 | ```
15 | or alternatively using `brew cask install fastlane`
16 |
17 | # Available Actions
18 | ## iOS
19 | ### ios unit_test
20 | ```
21 | fastlane ios unit_test
22 | ```
23 | Runs the unit tests in Aspire BudgetingTests
24 |
25 | ----
26 |
27 | This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run.
28 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools).
29 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
30 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/GradientTextView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GradientTextView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct GradientTextView: View {
9 | let string: String
10 | let tracking: CGFloat
11 | let font: Font
12 | let paddingEdges: Edge.Set
13 | let paddingLength: CGFloat?
14 | let gradient: LinearGradient
15 |
16 | var body: some View {
17 | Text(string)
18 | .tracking(tracking)
19 | .font(font)
20 | .padding(paddingEdges, paddingLength)
21 | .foregroundColor(.clear)
22 | .overlay(gradient
23 | .mask(Text(string)
24 | .tracking(1)
25 | .font(font)
26 | .scaledToFill())
27 | )
28 | }
29 | }
30 |
31 | // struct GradientTextView_Previews: PreviewProvider {
32 | // static var previews: some View {
33 | // GradientTextView()
34 | // }
35 | // }
36 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/primaryTextColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x1C",
9 | "green" : "0x1C",
10 | "red" : "0x1C"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xFF",
27 | "green" : "0xFF",
28 | "red" : "0xFF"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/tabBarColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "1.000",
9 | "green" : "1.000",
10 | "red" : "1.000"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.267",
27 | "green" : "0.180",
28 | "red" : "0.188"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/primaryBackgroundColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xFF",
9 | "green" : "0xFC",
10 | "red" : "0xFC"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0x31",
27 | "green" : "0x21",
28 | "red" : "0x23"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/secondaryTextColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0x60",
9 | "green" : "0x60",
10 | "red" : "0x60"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xCE",
27 | "green" : "0xBA",
28 | "red" : "0xBC"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/tabBarItemDefaultTintColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "srgb",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0.847",
9 | "green" : "0.847",
10 | "red" : "0.847"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0.592",
27 | "green" : "0.494",
28 | "red" : "0.502"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/BackgroundSplitColorView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BacgroundColorView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct BackgroundSplitColorView: View {
9 | var body: some View {
10 | ZStack {
11 | Color(red: 53 / 255, green: 55 / 255, blue: 72 / 255)
12 | GeometryReader { geometry in
13 | Path { path in
14 | let screenWidth = geometry.size.width
15 | let screenHeight = geometry.size.height
16 |
17 | path.move(to: CGPoint(x: 0, y: 0.43 * screenHeight))
18 | path.addLine(to: CGPoint(x: 0, y: screenHeight))
19 | path.addLine(to: CGPoint(x: screenWidth, y: screenHeight))
20 | path.addLine(to: CGPoint(x: screenWidth, y: 0.55 * screenHeight))
21 | }.fill(Color.white)
22 | }
23 | }.edgesIgnoringSafeArea(.all)
24 | }
25 | }
26 |
27 | struct BackgroundColorView_Previews: PreviewProvider {
28 | static var previews: some View {
29 | BackgroundSplitColorView()
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AmountTextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AmountTextField.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireTextField: View {
9 | @Binding var amount: String
10 |
11 | var body: some View {
12 | ZStack {
13 | Rectangle()
14 | .foregroundColor(Color(red: 0.769, green: 0.769, blue: 0.769))
15 | .frame(height: 50)
16 | .cornerRadius(5)
17 | .padding()
18 | .opacity(0.95)
19 |
20 | HStack {
21 | Image("dollar_icon").padding(.horizontal)
22 | Spacer()
23 | TextField("Enter Amount", text: $amount)
24 | .keyboardType(.decimalPad)
25 | .padding(.horizontal)
26 | .foregroundColor(Color(red: 0.208, green: 0.216, blue: 0.282))
27 | .font(.rubikMedium(size: 18))
28 | }.padding()
29 | }
30 | }
31 | }
32 |
33 | // struct AmountTextField_Previews: PreviewProvider {
34 | // static var previews: some View {
35 | // AmountTextField()
36 | // }
37 | // }
38 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireSegmentedView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireSegmentedView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | // TODO: Delete
9 | struct AspireSegmentedView: View {
10 | @Binding var selectedSegment: Int
11 |
12 | var body: some View {
13 | ZStack {
14 | Rectangle().frame(height: 55).background(Color.red).opacity(0.06)
15 | VStack {
16 | HStack(spacing: 0) {
17 | AspireSegmentedItem(
18 | title: "Dashboard",
19 | itemIndex: 0,
20 | selectedSegment: $selectedSegment
21 | )
22 | AspireSegmentedItem(
23 | title: "Account Balances",
24 | itemIndex: 1,
25 | selectedSegment: $selectedSegment
26 | )
27 | }
28 | .frame(minWidth: 0, maxWidth: .infinity)
29 | }
30 | }
31 | }
32 | }
33 |
34 | // struct AspireSegmentedView_Previews: PreviewProvider {
35 | // static var previews: some View {
36 | // AspireSegmentedView()
37 | // }
38 | // }
39 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/FaceIDView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FaceIDView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct FaceIDView: View {
9 |
10 | var body: some View {
11 | ZStack {
12 | Rectangle().edgesIgnoringSafeArea(.all).foregroundColor(Colors.aspireGray)
13 | VStack {
14 | Image("logo")
15 | .resizable()
16 | .aspectRatio(contentMode: .fit)
17 | .frame(maxHeight: 150)
18 | Text("Aspire")
19 | .font(.custom("Nunito-Regular", size: 30))
20 | .foregroundColor(.white)
21 | .padding(-20)
22 | Spacer()
23 | Text("Continue by using FaceID, TouchID or your Passcode")
24 | .font(.custom("Rubik-Light", size: 18))
25 | .padding()
26 | .multilineTextAlignment(.center)
27 | .foregroundColor(Color.white)
28 | .opacity(0.6)
29 | }
30 | }
31 | }
32 | }
33 |
34 | struct FaceIDView_Previews: PreviewProvider {
35 | static var previews: some View {
36 | FaceIDView()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/DashboardViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardViewModelTests.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import Combine
8 | import XCTest
9 |
10 | class DashboardViewModelTests: XCTestCase {
11 |
12 | let dashboardPublisher = PassthroughSubject()
13 | var cancellables = Set()
14 |
15 | func test_dashboardPublishedSuccessfully() {
16 | let vm = DashboardViewModel(publisher: dashboardPublisher.eraseToAnyPublisher())
17 | let exp = XCTestExpectation()
18 |
19 | vm
20 | .$dashboard
21 | .dropFirst()
22 | .sink { dashboard in
23 | let dashboard = try! XCTUnwrap(dashboard)
24 | XCTAssertEqual(dashboard, MockProvider.dashboard)
25 | exp.fulfill()
26 | }
27 | .store(in: &cancellables)
28 |
29 | vm.refresh()
30 | dashboardPublisher.send(MockProvider.dashboard)
31 |
32 | wait(for: [exp], timeout: 1)
33 |
34 | XCTAssertEqual(vm.cardViewItems, MockProvider.cardViewItems2)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Documentation/LaunchSequence.puml:
--------------------------------------------------------------------------------
1 | @startuml
2 | actor User
3 | User -> SceneDelegate : Launch app
4 | activate SceneDelegate
5 |
6 | SceneDelegate -> ObjectFactory : getBugTracker()
7 | activate ObjectFactory
8 |
9 | SceneDelegate -> AspireBugTracker : start()
10 | SceneDelegate -> ObjectFactory : getDriveManager()
11 | SceneDelegate -> ObjectFactory : getSheetsManager()
12 | SceneDelegate -> ObjectFactory : getLocalAuthorizationManager()
13 | SceneDelegate -> ObjectFactory : getStateManager()
14 |
15 | SceneDelegate -> StateManager : $currentStatePublisher()
16 |
17 | alt currentState == loggedOut
18 | SceneDelegate -> UserManager : authenticateWithGoogle()
19 |
20 | else currentState == verifiedGoogleUser
21 | SceneDelegate -> UserManager : authenticateLocally()
22 |
23 | else currentState == authenticatedLocally
24 | SceneDelegate -> SheetsManager : checkDefaultsForSpreadsheet()
25 | end
26 |
27 | SceneDelegate -> ContentView : init(userManager, driveManager, sheetsManager, localAuthorizationManager, stateManager)
28 |
29 | deactivate ObjectFactory
30 | deactivate SceneDelegate
31 | @enduml
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/AccountBalancesViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountBalancesViewModel.swift
3 | // Aspire Budgeting
4 | //
5 | import Combine
6 |
7 | final class AccountBalancesViewModel: ObservableObject {
8 |
9 | let publisher: AnyPublisher
10 | var cancellables = Set()
11 |
12 | @Published private(set) var accountBalances = AccountBalances()
13 | @Published private(set) var error: Error?
14 |
15 | var isLoading: Bool {
16 | accountBalances.isEmpty && error == nil
17 | }
18 |
19 | init(publisher: AnyPublisher) {
20 | self.publisher = publisher
21 | }
22 |
23 | func refresh() {
24 | cancellables.removeAll()
25 |
26 | publisher
27 | .sink { completion in
28 | switch completion {
29 | case let .failure(error):
30 | self.error = error
31 |
32 | case .finished:
33 | Logger.info("Account Balances retrieved")
34 | }
35 | } receiveValue: {
36 | self.accountBalances = $0
37 | }
38 | .store(in: &cancellables)
39 |
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/AspireFonts.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireFonts.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | extension Font {
9 | static func rubikRegular(size: CGFloat) -> Font {
10 | Font.custom("Rubik-Regular", size: size)
11 | }
12 |
13 | static func rubikLight(size: CGFloat) -> Font {
14 | Font.custom("Rubik-Light", size: size)
15 | }
16 |
17 | static func rubikMedium(size: CGFloat) -> Font {
18 | Font.custom("Rubik-Medium", size: size)
19 | }
20 |
21 | static func nunitoSemiBold(size: CGFloat) -> Font {
22 | Font.custom("Nunito-SemiBold", size: size)
23 | }
24 |
25 | static func nunitoRegular(size: CGFloat) -> Font {
26 | Font.custom("Nunito-Regular", size: size)
27 | }
28 |
29 | static func nunitoBold(size: CGFloat) -> Font {
30 | Font.custom("Nunito-Bold", size: size)
31 | }
32 |
33 | static func karlaRegular(size: Double) -> Font {
34 | Font.custom("Karla-Regular", size: CGFloat(size))
35 | }
36 |
37 | static func karlaBold(size: Double) -> Font {
38 | Font.custom("Karla-Bold", size: CGFloat(size))
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/ShareSheet.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShareSheet.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 | import UIKit
8 |
9 | struct ShareSheet: UIViewControllerRepresentable {
10 | typealias Callback = (
11 | _ activityType: UIActivity.ActivityType?,
12 | _ completed: Bool,
13 | _ returnedItems: [Any]?, _ error: Error?
14 | ) -> Void
15 |
16 | let activityItems: [Any]
17 | let applicationActivities: [UIActivity]? = nil
18 | let excludedActivityTypes: [UIActivity.ActivityType]? = nil
19 | let callback: Callback? = nil
20 |
21 | func makeUIViewController(context: Context) -> UIActivityViewController {
22 | let controller = UIActivityViewController(
23 | activityItems: activityItems,
24 | applicationActivities: applicationActivities)
25 | controller.excludedActivityTypes = excludedActivityTypes
26 | controller.completionWithItemsHandler = callback
27 | return controller
28 | }
29 |
30 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
31 | // nothing to do here
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/build_and_test.yml:
--------------------------------------------------------------------------------
1 | name: Aspire Budgeting Build and Test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | test:
6 | name: Build and Test
7 | runs-on: macOS-latest
8 | strategy:
9 | matrix:
10 | destination: ['platform=iOS Simulator,OS=13.1,name=iPhone 8', 'platform=iOS Simulator,OS=14.1,name=iPhone 9']
11 | xcode: ['/Applications/Xcode_12.3.app/Contents/Developer']
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v2
15 |
16 | - name: Inject PLIST files
17 | run: |
18 | cd "Aspire Budgeting"; cp ../"Aspire BudgetingTests"/Resources/credentials.plist Resources/; cat Resources/credentials.plist; cd ..
19 |
20 | - name: Install Dependencies
21 | run: |
22 | bundle install
23 | bundle exec pod install
24 |
25 | - name: Code Validation
26 | env:
27 | DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
28 | run: bundle exec danger
29 |
30 | - name: Run Tests
31 | run: bundle exec fastlane unit_test
32 |
33 | - name: Code Coverage
34 | run: bash <(curl -s https://codecov.io/bash)
35 |
--------------------------------------------------------------------------------
/BuildPhases/set_google_credentials.sh:
--------------------------------------------------------------------------------
1 | # This script will insert the reverse client id as URL Scheme in the Info.plist
2 |
3 | REVERSED_CLIENT_ID=$(/usr/libexec/PlistBuddy -c "Print REVERSED_CLIENT_ID" "$SRCROOT"/Aspire\ Budgeting/Resources/credentials.plist)
4 |
5 | INFO_PLIST=$SRCROOT/Aspire\ Budgeting/Info.plist
6 | echo $REVERSED_CLIENT_ID
7 | echo $INFO_PLIST
8 |
9 | if [[ $REVERSED_CLIENT_ID == *"com.google"* ]]; then
10 |
11 | if grep -q $REVERSED_CLIENT_ID "$INFO_PLIST"; then
12 | echo "REVERSE_CLIENT_ID exists in Info.plist"
13 | exit 0;
14 | fi
15 |
16 | echo "Inserting Credentials into Info.plist"
17 | /usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$INFO_PLIST"
18 | /usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$INFO_PLIST"
19 | /usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$INFO_PLIST"
20 | /usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes: string ${REVERSED_CLIENT_ID}" "$INFO_PLIST"
21 | /usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleTypeRole string Editor" "$INFO_PLIST"
22 | echo "Credentials inserted."
23 | exit 0
24 | fi
25 |
26 | echo "error: Failed to add credentials to Info.plist"
27 | exit 1
28 |
29 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Assets.xcassets/circularLogo.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "scale" : "1x"
6 | },
7 | {
8 | "appearances" : [
9 | {
10 | "appearance" : "luminosity",
11 | "value" : "dark"
12 | }
13 | ],
14 | "idiom" : "universal",
15 | "scale" : "1x"
16 | },
17 | {
18 | "filename" : "1.png",
19 | "idiom" : "universal",
20 | "scale" : "2x"
21 | },
22 | {
23 | "appearances" : [
24 | {
25 | "appearance" : "luminosity",
26 | "value" : "dark"
27 | }
28 | ],
29 | "filename" : "1_dark.png",
30 | "idiom" : "universal",
31 | "scale" : "2x"
32 | },
33 | {
34 | "filename" : "1_3x.png",
35 | "idiom" : "universal",
36 | "scale" : "3x"
37 | },
38 | {
39 | "appearances" : [
40 | {
41 | "appearance" : "luminosity",
42 | "value" : "dark"
43 | }
44 | ],
45 | "filename" : "1_dark_3x.png",
46 | "idiom" : "universal",
47 | "scale" : "3x"
48 | }
49 | ],
50 | "info" : {
51 | "author" : "xcode",
52 | "version" : 1
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/ViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewModel.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | protocol AspireViewModel {
9 | associatedtype DataProvider
10 |
11 | var dataProvider: DataProvider? { get }
12 | var error: Error? { get }
13 | var refresh: () -> Void { get }
14 |
15 | init(result: Result?,
16 | refreshAction: @escaping () -> Void)
17 |
18 | init(refreshAction: @escaping () -> Void)
19 | }
20 |
21 | extension AspireViewModel {
22 | init(refreshAction: @escaping () -> Void) {
23 | self.init(result: nil, refreshAction: refreshAction)
24 | }
25 | }
26 |
27 | struct ViewModel: AspireViewModel {
28 | typealias DataProvider = T
29 |
30 | let dataProvider: T?
31 | let error: Error?
32 | let refresh: () -> Void
33 |
34 | init(result: Result?, refreshAction: @escaping () -> Void) {
35 | self.refresh = refreshAction
36 |
37 | if let result = result {
38 | switch result {
39 | case .failure(let error):
40 | self.error = error
41 | self.dataProvider = nil
42 |
43 | case .success(let dashboard):
44 | self.dataProvider = dashboard
45 | self.error = nil
46 | }
47 | } else {
48 | self.dataProvider = nil
49 | self.error = nil
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/GoogleSignInButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GoogleSignInButton.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import GoogleSignIn
7 | import SwiftUI
8 |
9 | struct GoogleSignInButton: UIViewRepresentable {
10 | private var presentingViewController: UIViewController? {
11 | UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
12 | }
13 |
14 | let colorScheme: ColorScheme
15 |
16 | func updateUIView(
17 | _ signInButton: GIDSignInButton,
18 | context: UIViewRepresentableContext
19 | ) {
20 | guard let presentingVC = presentingViewController else {
21 | signInButton.isEnabled = false
22 | return
23 | }
24 |
25 | GIDSignIn.sharedInstance()?.presentingViewController = presentingVC
26 | signInButton.colorScheme = colorScheme == .light ? .light : .dark
27 | }
28 |
29 | func makeUIView(context: UIViewRepresentableContext) -> GIDSignInButton {
30 | let button =
31 | GIDSignInButton()
32 | button.style = .wide
33 |
34 | return button
35 | }
36 | }
37 |
38 | struct GoogleSignInButton_Previews: PreviewProvider {
39 | static var previews: some View {
40 | Group {
41 | GoogleSignInButton(colorScheme: .light)
42 | GoogleSignInButton(colorScheme: .dark)
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireSegmentedItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireSegmentedItem.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireSegmentedItem: View {
9 | let title: String
10 | let itemIndex: Int
11 | @Binding var selectedSegment: Int
12 |
13 | private var opacity: Double {
14 | selectedSegment == itemIndex ? 1 : 0.1
15 | }
16 |
17 | var body: some View {
18 | VStack {
19 | Spacer()
20 | Button(
21 | action: {
22 | self.selectedSegment = self.itemIndex
23 | }, label: {
24 | Text(title)
25 | .tracking(1)
26 | .font(.rubikRegular(size: 18))
27 | .foregroundColor(.white)
28 | .opacity(self.opacity)
29 | }
30 | ).disabled(selectedSegment == self.itemIndex)
31 | Spacer()
32 | if selectedSegment == itemIndex {
33 | Rectangle()
34 | .frame(height: 3)
35 | .foregroundColor(Colors.segmentRed)
36 | }
37 | }
38 | .frame(minWidth: 0, maxWidth: .infinity, maxHeight: 55)
39 | }
40 | }
41 |
42 | struct AspireSegmentedItem_Previews: PreviewProvider {
43 | static var previews: some View {
44 | AspireSegmentedItem(
45 | title: "Dashboard",
46 | itemIndex: 0,
47 | selectedSegment: .constant(0)
48 | )
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/BannerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BannerView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 | struct BannerViewTitle: ViewModifier {
8 | enum BannerTitleSize: CGFloat {
9 | case medium = 22
10 | case large = 38
11 | }
12 |
13 | let size: BannerTitleSize
14 |
15 | func body(content: Content) -> some View {
16 | content
17 | .font(.nunitoBold(size: size.rawValue))
18 | .foregroundColor(.white)
19 | }
20 | }
21 |
22 | extension View {
23 | func bannerTitle(size: BannerViewTitle.BannerTitleSize) -> some View {
24 | self.modifier(BannerViewTitle(size: size))
25 | }
26 | }
27 | struct BannerView: View {
28 | let content: Content
29 | let baseColor: Color
30 |
31 | private let bannerHeight: CGFloat = 100
32 |
33 | init(baseColor: Color,
34 | @ViewBuilder content: () -> Content) {
35 | self.baseColor = baseColor
36 | self.content = content()
37 | }
38 |
39 | var body: some View {
40 | ZStack {
41 | Rectangle()
42 | .fill(baseColor)
43 | .frame(height: bannerHeight)
44 | content
45 | }
46 | }
47 | }
48 |
49 | struct BannerView_Previews: PreviewProvider {
50 | static var previews: some View {
51 | BannerView(baseColor: .materialBlue800) {
52 | Text("Investments")
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspirePickerButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspirePickerButton.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspirePickerButton: View {
9 | let title: String
10 | let imageName: String
11 | let action: () -> Void
12 |
13 | init(
14 | title: String,
15 | imageName: String,
16 | action: @escaping () -> Void
17 | ) {
18 | self.title = title
19 | self.imageName = imageName
20 | self.action = action
21 | }
22 |
23 | var body: some View {
24 | Button(action: action) {
25 | ZStack {
26 | Rectangle()
27 | .foregroundColor(Color(red: 0.769, green: 0.769, blue: 0.769))
28 | .frame(height: 50)
29 | .cornerRadius(5)
30 | HStack {
31 | Image(imageName)
32 | .padding(.horizontal)
33 | Text(self.title)
34 | .padding(.horizontal)
35 | .foregroundColor(Color(red: 0.208, green: 0.216, blue: 0.282))
36 | .font(.nunitoSemiBold(size: 25))
37 | .frame(maxWidth: .infinity, alignment: .leading)
38 | }
39 | }
40 | }
41 | .buttonStyle(PlainButtonStyle())
42 | .padding()
43 | }
44 | }
45 |
46 | // struct AspirePickerButton_Previews: PreviewProvider {
47 | // static var previews: some View {
48 | // AspirePickerButton()
49 | // }
50 | // }
51 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/SettingsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct SettingsView: View {
9 | let viewModel: SettingsViewModel
10 |
11 | @State private var showShareSheet = false
12 | @State private var showFileSelector = false
13 |
14 | var body: some View {
15 | Form {
16 | Section(footer:
17 | VStack(alignment: .leading) {
18 | Text("Linked Sheet: \(viewModel.fileName)")
19 | Text("App Version: \(AspireVersionInfo.version).\(AspireVersionInfo.build)")
20 | }) {
21 | Button("Change Sheet") {
22 | showFileSelector = true
23 | }
24 | Button("Export Log File") {
25 | showShareSheet = true
26 | }
27 | }
28 | }.sheet(isPresented: $showShareSheet) {
29 | ShareSheet(activityItems: [logURL])
30 | }
31 | .sheet(isPresented: $showFileSelector) {
32 | FileSelectorView(viewModel: viewModel.fileSelectorVM)
33 | }
34 | .onReceive(
35 | viewModel
36 | .fileSelectorVM
37 | .$aspireSheet
38 | .compactMap { $0 }) { _ in
39 | showFileSelector = false
40 | }
41 | }
42 | }
43 |
44 | // struct SettingsView_Previews: PreviewProvider {
45 | // static var previews: some View {
46 | // SettingsView()
47 | // }
48 | // }
49 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireNavigationBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireNavigationBar.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireNavigationBar: View {
9 | @EnvironmentObject var appCoordinator: AppCoordinator
10 |
11 | @Binding var title: String
12 | @State var showCategoryTransfer = false
13 |
14 | var body: some View {
15 | ZStack(alignment: .bottom) {
16 | Color.primaryBackgroundColor
17 | Text(title)
18 | .font(.nunitoBold(size: 20))
19 | .foregroundColor(.primaryTextColor)
20 | .alignmentGuide(.bottom, computeValue: { d in
21 | d[.bottom] + (d.height / 4)
22 | })
23 |
24 | HStack(alignment: .center) {
25 | Spacer()
26 | Button(action: {
27 | showCategoryTransfer = true
28 | }, label: {
29 | Image(systemName: "repeat")
30 | })
31 | .alignmentGuide(VerticalAlignment.center, computeValue: { d in
32 | d[.bottom] + (d.height / 1.5)
33 | })
34 | }.padding(.trailing)
35 | }
36 | .sheet(isPresented: $showCategoryTransfer, content: {
37 | NavigationView {
38 | CategoryTransferView(viewModel: appCoordinator.categoryTransferViewModel)
39 | }
40 | })
41 | }
42 | }
43 |
44 | struct AspireNavigationBar_Previews: PreviewProvider {
45 | static var previews: some View {
46 | AspireNavigationBar(title: .constant("Dashboard"))
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/AccountBalances.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountBalancesMetadata.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | struct AccountBalance: Hashable {
9 | let accountName: String
10 | let balance: AspireNumber
11 | let additionalText: String
12 | }
13 |
14 | protocol ConstructableFromRows {
15 | init(rows: [[String]])
16 | }
17 |
18 | struct AccountBalances: ConstructableFromRows {
19 | let accountBalances: [AccountBalance]
20 | init(rows: [[String]]) {
21 | accountBalances = AccountBalances.parse(metadata: rows)
22 | }
23 |
24 | init() {
25 | self.accountBalances = .init()
26 | }
27 |
28 | var isEmpty: Bool {
29 | accountBalances.isEmpty
30 | }
31 |
32 | private static func parse(metadata: [[String]]) -> [AccountBalance] {
33 | var accountBalances = [AccountBalance]()
34 | var accountName: String?
35 | var balance: AspireNumber?
36 | var additionalText: String?
37 |
38 | for row in metadata {
39 | if row.count == 2 {
40 | accountName = row[0]
41 | balance = AspireNumber(stringValue: row[1])
42 | } else if row.count == 1 {
43 | additionalText = row[0]
44 | accountBalances.append(
45 | AccountBalance(accountName: accountName!,
46 | balance: balance!,
47 | additionalText: additionalText!)
48 | )
49 | }
50 |
51 | }
52 | return accountBalances
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/SignInView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SignInView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct SignInView: View {
9 | @Environment (\.colorScheme) var colorScheme: ColorScheme
10 |
11 | private let rootVC = UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController
12 |
13 | var body: some View {
14 | VStack {
15 | Text("Aspire Budgeting")
16 | .font(.nunitoSemiBold(size: 20))
17 | .foregroundColor(.primaryTextColor)
18 |
19 | Image.circularLogo
20 | .padding(.top)
21 |
22 | Text("Welcome to Aspire Budgeting")
23 | .font(.nunitoRegular(size: 16))
24 | .foregroundColor(.primaryTextColor)
25 | .padding()
26 |
27 | Text("Take control of your money everywhere you go")
28 | .lineLimit(2)
29 | .font(.nunitoRegular(size: 14))
30 | .multilineTextAlignment(.center)
31 | .foregroundColor(.secondary)
32 | .frame(width: 173)
33 |
34 | Image.diamondSeparator
35 | .padding()
36 |
37 | GoogleSignInButton(colorScheme: colorScheme)
38 | .frame(height: 50)
39 | .padding()
40 |
41 | }
42 | .background(Color.primaryBackgroundColor)
43 | }
44 | }
45 |
46 | struct SignInView_Previews: PreviewProvider {
47 | static var previews: some View {
48 | Group {
49 | SignInView()
50 | SignInView().environment(\.colorScheme, .dark)
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/GoogleSDKCredentials.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GoogleSDKCredentials.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | enum CredentialsError: Error {
9 | case missingCredentialsPLIST
10 | case couldNotCreate
11 | }
12 |
13 | struct GoogleSDKCredentials: Codable {
14 | // swiftlint:disable identifier_name
15 | let CLIENT_ID: String
16 | let REVERSED_CLIENT_ID: String
17 | // swiftlint:enable identifier_name
18 |
19 | static func getCredentials(
20 | from fileName: String = "credentials",
21 | type: String = "plist",
22 | bundle: Bundle = Bundle.main,
23 | decoder: PropertyListDecoder = PropertyListDecoder()
24 | ) throws -> GoogleSDKCredentials {
25 | var credentialsData: Data
26 | var credentials: GoogleSDKCredentials
27 |
28 | guard let credentialsURL = bundle.url(forResource: fileName,
29 | withExtension: type)
30 | else {
31 | Logger.error("credentials.plist file not found")
32 | throw CredentialsError.missingCredentialsPLIST
33 | }
34 |
35 | do {
36 | credentialsData = try Data(contentsOf: credentialsURL)
37 | credentials = try decoder.decode(GoogleSDKCredentials.self, from: credentialsData)
38 |
39 | } catch {
40 | Logger.error(
41 | "Exception thrown while trying to create GoogleSDKCredentials",
42 | context: error.localizedDescription
43 | )
44 | throw CredentialsError.couldNotCreate
45 | }
46 |
47 | return credentials
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import GoogleSignIn
8 | import SwiftUI
9 |
10 | struct ContentView: View {
11 | @ObservedObject var authenticationManager: AuthenticationManager
12 | @EnvironmentObject var appCoordinator: AppCoordinator
13 |
14 | var needsLocalAuth: Bool {
15 | appCoordinator.needsLocalAuth
16 | }
17 |
18 | var isLoggedOut: Bool {
19 | authenticationManager.isLoggedOut
20 | }
21 |
22 | var hasDefaultSheet: Bool {
23 | appCoordinator.hasDefaultSheet
24 | }
25 |
26 | var body: some View {
27 | VStack {
28 | if isLoggedOut {
29 | SignInView()
30 | .frame(maxHeight: .infinity)
31 | } else if needsLocalAuth {
32 | FaceIDView()
33 | } else if hasDefaultSheet {
34 | AspireMasterView()
35 | } else {
36 | FileSelectorView(viewModel: appCoordinator.fileSelectorVM)
37 | }
38 | }.background(Color.primaryBackgroundColor.edgesIgnoringSafeArea(.all))
39 | }
40 | }
41 |
42 | // struct ContentView_Previews: PreviewProvider {
43 | // static let objectFactory = ObjectFactory()
44 | // static var previews: some View {
45 | // ContentView(userManager: objectFactory.userManager,
46 | // driveManager: objectFactory.driveManager,
47 | // sheetsManager: objectFactory.sheetsManager,
48 | // localAuthorizationManager: objectFactory.localAuthorizationManager,
49 | // stateManager: objectFactory.stateManager)
50 | // }
51 | // }
52 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/TabBarView/ProminentTabBarItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProminentTabBarItemView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct ProminentTabBarItemView: View {
9 |
10 | var width: CGFloat = 70
11 |
12 | private var innerCircleWidth: CGFloat {
13 | width - 10
14 | }
15 |
16 | private var imageWidth: CGFloat {
17 | innerCircleWidth / 2
18 | }
19 |
20 | private var gradient: LinearGradient {
21 | let endColor = Color(red: 0.495, green: 0.945, blue: 0.571)
22 |
23 | let startColor = Color(red: 0.176, green: 0.784, blue: 0.59)
24 |
25 | let gradient = Gradient(colors: [startColor, endColor])
26 |
27 | return LinearGradient(gradient: gradient, startPoint: .bottomLeading, endPoint: .topTrailing)
28 | }
29 |
30 | let systemImageName: String
31 | let action: () -> Void
32 |
33 | var body: some View {
34 | Button(action: action) {
35 | ZStack {
36 | Circle()
37 | .size(CGSize(width: width, height: width))
38 | .foregroundColor(.white)
39 |
40 | Circle()
41 | .size(CGSize(width: innerCircleWidth, height: innerCircleWidth))
42 | .fill(gradient)
43 | .offset(x: 5, y: 5)
44 |
45 | Image(systemName: systemImageName)
46 | .resizable()
47 | .frame(width: imageWidth, height: imageWidth)
48 | .foregroundColor(.white)
49 | }.frame(width: width, height: width)
50 | }
51 | }
52 | }
53 |
54 | struct ProminentTabBarItemView_Previews: PreviewProvider {
55 | static var previews: some View {
56 | ProminentTabBarItemView(systemImageName: "plus") {}
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/ViewModelTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewModelTests.swift
3 | // Aspire BudgetingTests
4 | // swiftlint:disable empty_xctest_method overridden_super_call
5 |
6 | @testable import Aspire_Budgeting
7 | import XCTest
8 |
9 | final class ViewModelTests: XCTestCase {
10 |
11 | var refreshCalled = false
12 |
13 | override func setUpWithError() throws {
14 | refreshCalled = false
15 | }
16 |
17 | func refresh() {
18 | refreshCalled = true
19 | }
20 |
21 | func testViewModelBasic() {
22 | let viewModel = ViewModel(refreshAction: refresh)
23 |
24 | XCTAssertNil(viewModel.error)
25 | XCTAssertNil(viewModel.dataProvider)
26 | viewModel.refresh()
27 | XCTAssertTrue(refreshCalled)
28 | }
29 |
30 | func testViewModelAdvanced() {
31 | var result: Result = .success(5)
32 | var viewModel = ViewModel(result: result, refreshAction: refresh)
33 |
34 | XCTAssertNil(viewModel.error)
35 | XCTAssertNotNil(viewModel.dataProvider)
36 | XCTAssertEqual(viewModel.dataProvider!, 5)
37 | viewModel.refresh()
38 | XCTAssertTrue(refreshCalled)
39 |
40 | result = .failure(GoogleDriveManagerError.inconsistentSheet)
41 | viewModel = ViewModel(result: result, refreshAction: refresh)
42 |
43 | XCTAssertNil(viewModel.dataProvider)
44 | XCTAssertNotNil(viewModel.error)
45 | XCTAssertEqual(GoogleDriveManagerError.inconsistentSheet,
46 | viewModel.error! as! GoogleDriveManagerError)
47 |
48 | viewModel = ViewModel(result: nil, refreshAction: refresh)
49 | XCTAssertNil(viewModel.error)
50 | XCTAssertNil(viewModel.dataProvider)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - AppAuth (1.2.0):
3 | - AppAuth/Core (= 1.2.0)
4 | - AppAuth/ExternalUserAgent (= 1.2.0)
5 | - AppAuth/Core (1.2.0)
6 | - AppAuth/ExternalUserAgent (1.2.0)
7 | - GoogleAPIClientForREST/Core (1.3.10):
8 | - GTMSessionFetcher (>= 1.1.7)
9 | - GoogleAPIClientForREST/Drive (1.3.10):
10 | - GoogleAPIClientForREST/Core
11 | - GTMSessionFetcher (>= 1.1.7)
12 | - GoogleAPIClientForREST/Sheets (1.3.10):
13 | - GoogleAPIClientForREST/Core
14 | - GTMSessionFetcher (>= 1.1.7)
15 | - GoogleSignIn (5.0.1):
16 | - AppAuth (~> 1.2)
17 | - GTMAppAuth (~> 1.0)
18 | - GTMSessionFetcher/Core (~> 1.1)
19 | - GTMAppAuth (1.0.0):
20 | - AppAuth/Core (~> 1.0)
21 | - GTMSessionFetcher (~> 1.1)
22 | - GTMSessionFetcher (1.2.2):
23 | - GTMSessionFetcher/Full (= 1.2.2)
24 | - GTMSessionFetcher/Core (1.2.2)
25 | - GTMSessionFetcher/Full (1.2.2):
26 | - GTMSessionFetcher/Core (= 1.2.2)
27 |
28 | DEPENDENCIES:
29 | - GoogleAPIClientForREST/Drive
30 | - GoogleAPIClientForREST/Sheets
31 | - GoogleSignIn
32 |
33 | SPEC REPOS:
34 | trunk:
35 | - AppAuth
36 | - GoogleAPIClientForREST
37 | - GoogleSignIn
38 | - GTMAppAuth
39 | - GTMSessionFetcher
40 |
41 | SPEC CHECKSUMS:
42 | AppAuth: bce82c76043657c99d91e7882e8a9e1a93650cd4
43 | GoogleAPIClientForREST: 4acfffd77f1c3c8741b6be9eaed0e603278efbde
44 | GoogleSignIn: 3a51b9bb8e48b635fd7f4272cee06ca260345b86
45 | GTMAppAuth: 4deac854479704f348309e7b66189e604cf5e01e
46 | GTMSessionFetcher: 61bb0f61a4cb560030f1222021178008a5727a23
47 |
48 | PODFILE CHECKSUM: 23f86c04c4b072fbb1c6c9b9272fe4325610ac9a
49 |
50 | COCOAPODS: 1.10.1
51 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/CardView/BaseCardView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BaseCardView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct BaseCardView: View {
9 | var minY: CGFloat
10 | var curY: CGFloat
11 |
12 | private let cornerRadius: CGFloat = 24
13 | private let height: CGFloat = 163
14 |
15 | private var baseColor: Color
16 | private let content: Content
17 |
18 | private var offsetY: CGFloat {
19 | curY < minY ? minY - curY : 0
20 | }
21 |
22 | init(minY: CGFloat,
23 | curY: CGFloat,
24 | baseColor: Color,
25 | @ViewBuilder content: () -> Content) {
26 | self.minY = minY
27 | self.curY = curY
28 | self.baseColor = baseColor
29 | self.content = content()
30 | }
31 |
32 | var body: some View {
33 | ZStack(alignment: .top) {
34 | containerBox
35 | content
36 | }
37 | .offset(y: offsetY)
38 | }
39 | }
40 |
41 | extension BaseCardView {
42 | private var containerBox: some View {
43 | Rectangle()
44 | .fill(baseColor)
45 | .cornerRadius(cornerRadius)
46 | .frame(height: height)
47 | }
48 | }
49 |
50 | // MARK: - Previews
51 | struct CardView_Previews: PreviewProvider {
52 | static var previews: some View {
53 | Group {
54 | Group {
55 | BaseCardView(minY: 0,
56 | curY: 0,
57 | baseColor: .materialBlue800) { Color.materialBlue800 }
58 |
59 | BaseCardView(minY: 0,
60 | curY: 0,
61 | baseColor: .materialTeal800) { Color.materialTeal800 }
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/AspireNumber.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireNumber.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | infix operator /|
9 | struct AspireNumber: Equatable, Hashable {
10 | let stringValue: String
11 | let decimalValue: Decimal
12 | let isNegative: Bool
13 |
14 | init(stringValue: String) {
15 | let numFormatter = NumberFormatter()
16 | numFormatter.numberStyle = .currency
17 | numFormatter.minimumFractionDigits = 2
18 |
19 | self.init(
20 | stringValue: stringValue,
21 | decimalValue: numFormatter.number(from: stringValue)?.decimalValue ?? 0
22 | )
23 | }
24 |
25 | init(
26 | stringValue: String = "",
27 | decimalValue: Decimal = 0
28 | ) {
29 | self.stringValue = stringValue
30 | self.decimalValue = decimalValue
31 | isNegative = decimalValue < 0
32 | }
33 |
34 | /// Get the ratio of two AspireNumbers as a Double
35 | /// - parameter num : The numerator
36 | /// - parameter den : The denominator
37 | /// - returns: 0 if: num is negative or num is 0;
38 | /// 1 if: den is 0 or num is greater than den or ratio is greater than 1
39 | static func /| (num: AspireNumber, den: AspireNumber) -> Double {
40 | if num.isNegative || num.decimalValue == 0 {
41 | return 0
42 | }
43 |
44 | if den.decimalValue == 0 || num.decimalValue > den.decimalValue {
45 | return 1
46 | }
47 |
48 | let ratio = Double(truncating: (num.decimalValue / den.decimalValue) as NSNumber)
49 | return ratio > 1 ? 1 : ratio
50 | }
51 |
52 | static func >= (lhs: AspireNumber, rhs: AspireNumber) -> Bool {
53 | lhs.decimalValue >= rhs.decimalValue
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/Images.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Images.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | extension Image {
9 | static let circularLogo = Image("circularLogo")
10 | static let diamondSeparator = Image("diamondSeparator")
11 | static let magnifyingGlass = Image(systemName: "magnifyingglass")
12 | static let multiplyCircleFill = Image(systemName: "multiply.circle.fill")
13 | static let sheetsIcon = Image("sheetsIcon")
14 | static let bankIcon = Image("bankIcon")
15 |
16 | static let dollarSignCircle = Image(systemName: "dollarsign.circle")
17 | static let yenSignCircle = Image(systemName: "yensign.circle")
18 | static let sterlingSignCircle = Image(systemName: "sterlingsign.circle")
19 | static let rubleSignCircle = Image(systemName: "rublesign.circle")
20 | static let euroSignCircle = Image(systemName: "eurosign.circle")
21 | static let indianRupeeSignCircle = Image(systemName: "indianrupeesign.circle")
22 |
23 | static let currencySymbols: [Image] = [.dollarSignCircle,
24 | .yenSignCircle,
25 | sterlingSignCircle,
26 | rubleSignCircle,
27 | euroSignCircle,
28 | indianRupeeSignCircle,
29 | ]
30 |
31 | static let minigraphUp = Image("minigraph_up")
32 | static let minigraphDown = Image("minigraph_down")
33 | static let bankNote = Image(systemName: "banknote")
34 | static let scribble = Image(systemName: "scribble")
35 | static let envelope = Image(systemName: "envelope")
36 | static let creditCard = Image(systemName: "creditcard")
37 | }
38 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/TransactionsViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransactionsViewModel.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | final class TransactionsViewModel: ObservableObject {
10 | let publisher: AnyPublisher
11 | let dateFormatter = DateFormatter()
12 | var cancellables = Set()
13 |
14 | @Published private(set) var transactions: Transactions?
15 | @Published private(set) var error: Error?
16 |
17 | var isLoading: Bool {
18 | transactions == nil && error == nil
19 | }
20 |
21 | init(publisher: AnyPublisher) {
22 | self.publisher = publisher
23 | }
24 |
25 | func filtered(by filter: String) -> [Transaction] {
26 | guard let transactions = transactions else {
27 | return .init()
28 | }
29 |
30 | if filter.isEmpty {
31 | return transactions.transactions
32 | }
33 |
34 | return transactions
35 | .transactions
36 | .filter {
37 | $0.contains(filter)
38 | }
39 | }
40 |
41 | func formattedDate(for date: Date) -> String {
42 | dateFormatter.dateStyle = .medium
43 | dateFormatter.timeStyle = .none
44 |
45 | return dateFormatter.string(from: date)
46 | }
47 |
48 | func refresh() {
49 | cancellables.removeAll()
50 |
51 | publisher
52 | .sink { completion in
53 | switch completion {
54 | case let .failure(error):
55 | self.error = error
56 |
57 | case .finished:
58 | self.error = nil
59 | Logger.info("Trsansactions fetched.")
60 | }
61 | } receiveValue: {
62 | self.transactions = $0
63 | }
64 | .store(in: &cancellables)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/LoadingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoadingView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct LoadingView: View {
9 |
10 | var height: CGFloat?
11 |
12 | @State var isAnimating = false
13 |
14 | let itemsPerRow = 6
15 |
16 | var numberOfRows: Int {
17 | let heightPerRow = UIScreen.main.bounds.width/CGFloat(itemsPerRow)
18 | let displayHeight = height == nil ? UIScreen.main.bounds.height : height!
19 | return Int(displayHeight/heightPerRow) + 1
20 | }
21 |
22 | var randomCurrencySymbol: Image {
23 | let count = Image.currencySymbols.count
24 | return Image.currencySymbols[Int.random(in: 0.. String {
13 | "\(spent.stringValue) spent • \(budgeted.stringValue) budgeted"
14 | }
15 |
16 | var body: some View {
17 | ScrollView {
18 | ForEach(categories, id: \.self) { category in
19 | VStack(alignment: .leading) {
20 | HStack {
21 | Text(category.categoryName)
22 | .font(.karlaBold(size: 16))
23 | .foregroundColor(.primaryTextColor)
24 | Spacer()
25 | AspireNumberView(number: category.available)
26 | }
27 | HStack {
28 | Text(getAuxillaryText(spent: category.spent,
29 | budgeted: category.budgeted))
30 | .font(.karlaRegular(size: 14))
31 | .foregroundColor(.secondaryTextColor)
32 | Spacer()
33 |
34 | Text("available")
35 | .font(.karlaRegular(size: 14))
36 | .foregroundColor(.secondaryTextColor)
37 | }
38 |
39 | AspireProgressBar(barType: .minimal,
40 | shadowColor: .gray,
41 | tintColor: tintColor,
42 | progressFactor: category.available /| category.monthly)
43 | }
44 | .padding([.bottom, .horizontal])
45 | }
46 | }
47 | }
48 | }
49 |
50 | struct CategoryListView_Previews: PreviewProvider {
51 | static var previews: some View {
52 | CategoryListView(categories: MockProvider.cardViewItems3[0].categories,
53 | tintColor: .materialBrown800)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/Dashboard/DashboardCardsListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardCardsListView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct DashboardCardsListView: View {
9 |
10 | let cardViewItems: [DashboardCardView.DashboardCardItem]
11 |
12 | var body: some View {
13 | GeometryReader {g in
14 | ScrollView {
15 | VStack {
16 | ForEach(0..(
19 | minY: g.frame(in: .global).minY,
20 | curY: geo.frame(in: .global).minY,
21 | baseColor: colorFor(idx: idx)) {
22 | DashboardCardView(cardViewItem: self.cardViewItems[idx],
23 | baseColor: colorFor(idx: idx))
24 | }
25 | .padding(.horizontal)
26 | }.frame(maxWidth: .infinity)
27 | .frame(height: 125)
28 | }
29 | }
30 | }.background(Color.primaryBackgroundColor)
31 | }
32 | }
33 | }
34 |
35 | // MARK: - Internal Types
36 | extension DashboardCardsListView {
37 | static let baseColors: [Color] =
38 | [.materialRed800,
39 | .materialPink800,
40 | .materialPurple800,
41 | .materialDeepPurple800,
42 | .materialIndigo800,
43 | .materialBlue800,
44 | .materialLightBlue800,
45 | .materialTeal800,
46 | .materialGreen800,
47 | .materialBrown800,
48 | .materialGrey800,
49 | ].shuffled()
50 |
51 | private func colorFor(idx: Int) -> Color {
52 | DashboardCardsListView
53 | .baseColors[idx % DashboardCardsListView.baseColors.count]
54 | }
55 | }
56 |
57 | struct CardListView_Previews: PreviewProvider {
58 | static var previews: some View {
59 | DashboardCardsListView(cardViewItems: MockProvider.cardViewItems3)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/SearchBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SearchBar.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct SearchBar: View {
9 | @Binding var text: String
10 |
11 | @State private var isEditing = false
12 |
13 | var body: some View {
14 | HStack {
15 | TextField("Search ...", text: $text)
16 | .padding(7)
17 | .padding(.horizontal, 25)
18 | .background(Color(.systemGray6))
19 | .cornerRadius(8)
20 | .overlay(
21 | HStack {
22 | Image.magnifyingGlass
23 | .foregroundColor(.gray)
24 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
25 | .padding(.leading, 8)
26 |
27 | if isEditing {
28 | Button(
29 | action: {
30 | self.text = ""
31 | }, label: {
32 | Image.multiplyCircleFill
33 | .foregroundColor(.gray)
34 | .padding(.trailing, 8)
35 | })
36 | }
37 | }
38 | )
39 | .padding(.horizontal, 10)
40 | .onTapGesture {
41 | self.isEditing = true
42 | }
43 |
44 | if isEditing {
45 | Button(action: {
46 | self.isEditing = false
47 | self.text = ""
48 | UIApplication
49 | .shared
50 | .sendAction(#selector(UIResponder.resignFirstResponder),
51 | to: nil,
52 | from: nil,
53 | for: nil)
54 |
55 | }, label: {
56 | Text("Cancel")
57 | })
58 | .padding(.trailing, 10)
59 | .transition(.move(edge: .trailing))
60 | .animation(.default)
61 | }
62 | }
63 | }
64 | }
65 |
66 | struct SearchBar_Previews: PreviewProvider {
67 | static var previews: some View {
68 | SearchBar(text: .constant(""))
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/LocalAuthorizationManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalAuthorizationManager.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import Foundation
8 | import LocalAuthentication
9 |
10 | protocol AppLocalAuthorizer {
11 | func authenticateUserLocally() -> AnyPublisher
12 | }
13 |
14 | final class LocalAuthorizationManager: AppLocalAuthorizer {
15 |
16 | /// Creates a new context. Required as the previous context could be invalidated.
17 | private var context: LAContext {
18 | LAContext()
19 | }
20 |
21 | func authenticateUserLocally() -> AnyPublisher {
22 | Deferred {
23 | Future { [weak self] promise in
24 | guard let self = self else { return }
25 | Logger.info(
26 | "Authenticating user locally"
27 | )
28 |
29 | let context = self.context
30 | context.localizedCancelTitle = "Cancel"
31 |
32 | var error: NSError?
33 | if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
34 | let reason = "Log in to your account"
35 | context.evaluatePolicy(
36 | .deviceOwnerAuthentication,
37 | localizedReason: reason
38 | ) { success, error in
39 | if let error = error {
40 | Logger.error(
41 | "Encountered local authorization error.",
42 | context: error.localizedDescription
43 | )
44 | promise(.failure(error))
45 | }
46 | Logger.info(
47 | "Local Authorization success = ",
48 | context: success
49 | )
50 | promise(.success(()))
51 | context.invalidate()
52 | }
53 | } else {
54 | Logger.error(
55 | "Cannot evaluate deviceOwnerAuthentication policy"
56 | )
57 | }
58 |
59 | }
60 | }.eraseToAnyPublisher()
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireTextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireTextField.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireTextField: View {
9 | @Environment(\.colorScheme) var colorScheme
10 | @Binding var text: String
11 |
12 | let placeholder: String
13 | let keyboardType: UIKeyboardType
14 | let disabled: Bool
15 | let leftImage: Image?
16 |
17 | init(
18 | text: Binding,
19 | placeHolder: String,
20 | keyboardType: UIKeyboardType,
21 | disabled: Bool = false,
22 | leftImage: Image? = nil
23 | ) {
24 | self._text = text
25 | self.placeholder = placeHolder
26 | self.keyboardType = keyboardType
27 | self.disabled = disabled
28 | self.leftImage = leftImage
29 | }
30 |
31 | var body: some View {
32 | HStack {
33 | if self.leftImage != nil {
34 | leftImage!
35 | .resizable()
36 | .scaledToFit()
37 | .frame(width: 30, height: 30, alignment: .center)
38 | }
39 |
40 | TextField(placeholder, text: $text)
41 | .keyboardType(keyboardType)
42 | .padding(.horizontal)
43 | .foregroundColor(colorScheme == .light ? Color(red: 0.208, green: 0.216, blue: 0.282) : .white)
44 | .font(.nunitoSemiBold(size: 20))
45 | .disabled(disabled)
46 | .frame(height: 40)
47 | .overlay(
48 | RoundedRectangle(cornerRadius: 5)
49 | .stroke(Color(#colorLiteral(red: 0.8470588326454163, green: 0.8470588326454163, blue: 0.8470588326454163, alpha: 1)), lineWidth: 1)
50 | )
51 | }
52 | }
53 | }
54 |
55 | struct AspireTextField_Previews: PreviewProvider {
56 | static var previews: some View {
57 | AspireTextField(
58 | text: .constant("Text Field"),
59 | placeHolder: "Placeholder",
60 | keyboardType: .numberPad,
61 | leftImage: Image.bankNote
62 | )
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/DashboardMetadataTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardGroupsAndCategoriesTests.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import XCTest
8 |
9 | final class DashboardTests: XCTestCase {
10 | var sampleData = [[String]]()
11 |
12 | override func setUp() {
13 | super.setUp()
14 | sampleData.append(["✦", "", "C1"])
15 | sampleData.append(["✧", "", "C1R1", "$10", "", "", "$5", "", "", "$15"])
16 | sampleData.append(["✧", "", "C1R2", "$10,000", "", "", "$5.089", "", "", "$15,700"])
17 | sampleData.append(["✦", "", "C2"])
18 | sampleData.append(["✧", "", "C2R1", "-$10", "", "", "$5.00", "", "", "$15"])
19 | sampleData.append(["✧", "", "C2R2", "$10,000.67", "", "", "$5.08", "", "", "$15,700"])
20 | }
21 |
22 | func testDashboardGroupsAndCategoriesParser() {
23 | let metadata = Dashboard(rows: sampleData)
24 | XCTAssertEqual(metadata.groupedAvailableTotal(idx: 0),
25 | AspireNumber(stringValue: "$10,010.00",
26 | decimalValue: 10010))
27 |
28 | XCTAssertEqual(metadata.groupedAvailableTotal(idx: 1),
29 | AspireNumber(stringValue: "$9,990.67",
30 | decimalValue: 9990.67))
31 |
32 | XCTAssertEqual(metadata.groupedBudgetedTotal(idx: 0),
33 | AspireNumber(stringValue: "$15,715.00",
34 | decimalValue: 15715))
35 |
36 | XCTAssertEqual(metadata.groupedBudgetedTotal(idx: 1),
37 | AspireNumber(stringValue: "$15,715.00",
38 | decimalValue: 15715))
39 |
40 | XCTAssertEqual(metadata.groupedSpentTotal(idx: 0),
41 | AspireNumber(stringValue: "$10.09",
42 | decimalValue: 10.089))
43 |
44 | XCTAssertEqual(metadata.groupedSpentTotal(idx: 1),
45 | AspireNumber(stringValue: "$10.08",
46 | decimalValue: 10.08))
47 |
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/CategoryTransferViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryTransferViewModel.swift
3 | // Aspire Budgeting
4 | //
5 | import Combine
6 |
7 | final class CategoryTransferViewModel: ObservableObject {
8 | let categoriesPublisher: AnyPublisher
9 | let submitter: (CategoryTransfer) -> AnyPublisher
10 |
11 | var getCategoriesSubscription: AnyCancellable?
12 | var submitTransferSubscription: AnyCancellable?
13 |
14 | @Published private(set) var categories: TrxCategories?
15 | @Published private(set) var error: Error?
16 | @Published private(set) var signal: ()?
17 |
18 | init(
19 | categoriesPublisher: AnyPublisher,
20 | submitter: @escaping (CategoryTransfer) -> AnyPublisher
21 | ) {
22 | self.categoriesPublisher = categoriesPublisher
23 | self.submitter = submitter
24 | }
25 |
26 | func getCategories() {
27 | getCategoriesSubscription = categoriesPublisher
28 | .subscribe {
29 | Logger.info("Categories fetched.")
30 | } onFailure: {
31 | self.error = $0
32 | } onValue: {
33 | self.categories = $0
34 | }
35 | }
36 |
37 | func submit(categoryTransfer: CategoryTransfer) {
38 | submitTransferSubscription =
39 | submitter(categoryTransfer)
40 | .subscribe {
41 | Logger.info("Category Transfer submitted.")
42 | } onFailure: { error in
43 | self.error = error
44 | } onValue: {
45 | self.signal = $0
46 | }
47 | }
48 | }
49 |
50 | extension Publisher {
51 | func subscribe(
52 | onCompletion: @escaping () -> Void,
53 | onFailure: @escaping (Error) -> Void,
54 | onValue: @escaping (Output) -> Void) -> AnyCancellable {
55 | self.sink { completion in
56 | switch completion {
57 | case .finished:
58 | onCompletion()
59 |
60 | case let .failure(error):
61 | onFailure(error)
62 | }
63 | } receiveValue: { value in
64 | onValue(value)
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/DashboardTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardTests.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import XCTest
8 |
9 | final class DashboardTests: XCTestCase {
10 | var sampleData = [[String]]()
11 |
12 | override func setUp() {
13 | super.setUp()
14 | sampleData.append(["✦", "", "C1", "", "", "", "", "", "", ""])
15 | sampleData.append(["✧", "", "C1R1", "$10", "", "", "$5", "", "", "$15"])
16 | sampleData.append(["✧", "", "C1R2", "$10,000", "", "", "$5.089", "", "", "$15,700"])
17 | sampleData.append(["✦", "", "C2", "", "", "", "", "", "", ""])
18 | sampleData.append(["✧", "", "C2R1", "-$10", "", "", "$5.00", "", "", "$15"])
19 | sampleData.append(["✧", "", "C2R2", "$10,000.67", "", "", "$5.08", "", "", "$15,700"])
20 | }
21 |
22 | func testDashboardGroupsAndCategoriesParser() {
23 | let dashboard = Dashboard(rows: sampleData)
24 | XCTAssertEqual(dashboard.availableTotalForGroup(at: 0),
25 | AspireNumber(stringValue: "$10,010.00",
26 | decimalValue: 10010))
27 |
28 | XCTAssertEqual(dashboard.availableTotalForGroup(at: 1),
29 | AspireNumber(stringValue: "$9,990.67",
30 | decimalValue: 9990.67))
31 |
32 | XCTAssertEqual(dashboard.budgetedTotalForGroup(at: 0),
33 | AspireNumber(stringValue: "$15,715.00",
34 | decimalValue: 15715))
35 |
36 | XCTAssertEqual(dashboard.budgetedTotalForGroup(at: 1),
37 | AspireNumber(stringValue: "$15,715.00",
38 | decimalValue: 15715))
39 |
40 | XCTAssertEqual(dashboard.spentTotalForGroup(at: 0),
41 | AspireNumber(stringValue: "$10.09",
42 | decimalValue: 10.089))
43 |
44 | XCTAssertEqual(dashboard.spentTotalForGroup(at: 1),
45 | AspireNumber(stringValue: "$10.08",
46 | decimalValue: 10.08))
47 |
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GreenButton.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireButton: View {
9 | enum `Type` {
10 | case red
11 | case green
12 | }
13 |
14 | struct ButtonBackgroundView: ViewModifier {
15 | let fillGradient: LinearGradient
16 |
17 | init(fillGradient: LinearGradient) {
18 | self.fillGradient = fillGradient
19 | }
20 |
21 | func body(content: Content) -> some View {
22 | content.padding()
23 | .frame(minWidth: 0, maxWidth: .infinity)
24 | .background(Capsule().fill(fillGradient))
25 | .foregroundColor(.white)
26 | }
27 | }
28 |
29 | let title: String
30 | let action: () -> Void
31 | let type: Type
32 |
33 | let imageName: String?
34 |
35 | func gradient(for type: Type) -> LinearGradient {
36 | switch type {
37 | case .green:
38 | return Colors.greenGradient
39 | default:
40 | return Colors.redGradient
41 | }
42 | }
43 |
44 | init(title: String,
45 | type: Type,
46 | imageName: String? = nil,
47 | action: @escaping () -> Void) {
48 | self.title = title
49 | self.type = type
50 | self.imageName = imageName
51 | self.action = action
52 | }
53 |
54 | var body: some View {
55 | Button(action: action) {
56 | if imageName == nil {
57 | Text(title).tracking(1.5)
58 | .font(.custom("Rubik-Regular", size: 16))
59 | .modifier(ButtonBackgroundView(fillGradient: self.gradient(for: self.type))
60 | )
61 | } else {
62 | HStack {
63 | Image(imageName!).resizable().aspectRatio(contentMode: .fit).padding(.horizontal)
64 | Text(title).tracking(1.5).font(.custom("Rubik-Regular", size: 16))
65 | Spacer()
66 | }.modifier(ButtonBackgroundView(fillGradient: self.gradient(for: self.type)))
67 | }
68 | }
69 | }
70 | }
71 |
72 | // struct AspireButton_Previews: PreviewProvider {
73 | // static var previews: some View {
74 | // AspireButton()
75 | // }
76 | // }
77 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/Dashboard/DashboardView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | struct DashboardView: View {
10 |
11 | @ObservedObject var viewModel: DashboardViewModel
12 | @State private var searchText = ""
13 | @State private(set) var showingAlert = false
14 |
15 | var body: some View {
16 | VStack {
17 | if self.viewModel.isLoading {
18 | GeometryReader {
19 | LoadingView(height: $0.frame(in: .local).size.height)
20 | }
21 | } else {
22 | SearchBar(text: $searchText)
23 | .ignoreKeyboard()
24 |
25 | if searchText.isEmpty {
26 | DashboardCardsListView(cardViewItems: viewModel.cardViewItems)
27 | .padding(.vertical, 10)
28 | } else {
29 | CategoryListView(
30 | categories: viewModel
31 | .filteredCategories(filter: searchText),
32 | tintColor: .materialGreen800)
33 | }
34 | }
35 | }
36 | .alert(isPresented: $showingAlert, content: {
37 | Alert(title: Text("Error Occured"),
38 | message: Text("\(viewModel.error?.localizedDescription ?? "")"),
39 | dismissButton: .cancel())
40 | })
41 | .onReceive(viewModel.$error, perform: { error in
42 | self.showingAlert = error != nil
43 | })
44 | .background(Color.primaryBackgroundColor)
45 | .onAppear {
46 | self.viewModel.refresh()
47 | }
48 | }
49 | }
50 |
51 | extension View {
52 | @ViewBuilder
53 | func ignoreKeyboard() -> some View {
54 | if #available(iOS 14.0, *) {
55 | ignoresSafeArea(.keyboard, edges: .all)
56 | } else {
57 | self
58 | }
59 | }
60 | }
61 |
62 | struct DashboardView_Previews: PreviewProvider {
63 | static var previews: some View {
64 | DashboardView(
65 | viewModel: DashboardViewModel(
66 | publisher: Just(MockProvider.dashboard)
67 | .setFailureType(to: Error.self)
68 | .eraseToAnyPublisher()
69 | )
70 | )
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/StateManagerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StateManagerTests.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import XCTest
8 |
9 | final class StateManagerTests: XCTestCase {
10 | func postNotification(notificationName: Notification.Name, userInfo: [AnyHashable: Any]? = nil) {
11 | let notification = Notification(name: notificationName, object: nil, userInfo: userInfo)
12 | NotificationCenter.default.post(notification)
13 | }
14 |
15 | func testStateManager() {
16 | let stateManager = StateManager()
17 |
18 | XCTAssertEqual(stateManager.currentState.value, .loggedOut)
19 |
20 | stateManager.processEvent(event: .enteredBackground)
21 | XCTAssertEqual(stateManager.currentState.value, .loggedOut)
22 |
23 | stateManager.processEvent(event: .verifiedExternally)
24 | XCTAssertEqual(stateManager.currentState.value, .verifiedExternally)
25 |
26 | stateManager.processEvent(event: .enteredBackground)
27 | XCTAssertEqual(stateManager.currentState.value, .verifiedExternally)
28 |
29 | stateManager.processEvent(event: .hasDefaultFile)
30 | XCTAssertEqual(stateManager.currentState.value, .verifiedExternally)
31 |
32 | stateManager.processEvent(event: .authenticatedLocally(result: true))
33 | XCTAssertEqual(stateManager.currentState.value, .authenticatedLocally)
34 |
35 | stateManager.processEvent(event: .enteredBackground)
36 | XCTAssertEqual(stateManager.currentState.value, .needsLocalAuthentication)
37 |
38 | stateManager.processEvent(event: .authenticatedLocally(result: false))
39 | XCTAssertEqual(stateManager.currentState.value, .localAuthFailed)
40 |
41 | stateManager.processEvent(event: .hasDefaultFile)
42 | XCTAssertEqual(stateManager.currentState.value, .localAuthFailed)
43 |
44 | stateManager.processEvent(event: .authenticatedLocally(result: true))
45 | XCTAssertEqual(stateManager.currentState.value, .authenticatedLocally)
46 |
47 | stateManager.processEvent(event: .hasDefaultFile)
48 | XCTAssertEqual(stateManager.currentState.value, .hasDefaultSheet)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/FileSelectorViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileSelectorViewModel.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import Foundation
8 |
9 | enum ViewModelState {
10 | case isLoading
11 | case dataRetrieved
12 | case error
13 | }
14 |
15 | final class FileSelectorViewModel: ObservableObject {
16 |
17 | @Published private(set) var files = [File]()
18 | @Published private(set) var error: Error?
19 | @Published private(set) var aspireSheet: AspireSheet?
20 |
21 | private var cancellables = Set()
22 | private let fileManager: RemoteFileManager
23 | private let fileValidator: FileValidator
24 | private let user: User
25 |
26 | init(
27 | fileManager: RemoteFileManager,
28 | fileValidator: FileValidator,
29 | user: User
30 | ) {
31 | self.fileManager = fileManager
32 | self.fileValidator = fileValidator
33 | self.user = user
34 | }
35 |
36 | func getFiles() {
37 | self.fileManager.getFileList(for: user)
38 | .sink { completion in
39 | switch completion {
40 | case let .failure(error):
41 | self.error = error
42 | Logger.info("Failed to retrieve files: \(error)")
43 | case .finished:
44 | Logger.info("Files retrieved")
45 | }
46 | } receiveValue: { files in
47 | self.files = files
48 | }
49 | .store(in: &cancellables)
50 | }
51 |
52 | func selected(file: File) {
53 | fileValidator
54 | .validate(file: file, for: user)
55 | .sink { [weak self] completion in
56 | guard let self = self else { return }
57 | switch completion {
58 | case let .failure(error):
59 | self.error = error
60 | self.aspireSheet = nil
61 | Logger.info("Failed to validate: \(file.name) with error: \(error)")
62 | case .finished:
63 | Logger.info("Aspire Sheet selected : \(file.name)")
64 | }
65 | } receiveValue: { [weak self] aspireSheet in
66 | guard let self = self else { return }
67 | self.aspireSheet = aspireSheet
68 | self.error = nil
69 | }
70 | .store(in: &cancellables)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | # rule identifiers to exclude from running
2 | disabled_rules:
3 | - class_delegate_protocol
4 | - discarded_notification_center_observer
5 | - force_cast
6 | - force_try
7 | - identifier_name
8 | - nesting
9 | - todo
10 | - type_name
11 | - xctfail_message
12 | # Ideally this we can make rule more customized. Perhaps 4-large.
13 | - large_tuple
14 |
15 | opt_in_rules:
16 | - array_init
17 | - closure_end_indentation
18 | - closure_spacing
19 | - collection_alignment
20 | - convenience_type
21 | - empty_count
22 | - empty_string
23 | - empty_xctest_method
24 | - fatal_error_message
25 | - first_where
26 | - for_where
27 | - function_default_parameter_at_end
28 | - identical_operands
29 | - implicit_return
30 | - joined_default_parameter
31 | - legacy_random
32 | - literal_expression_end_indentation
33 | - lower_acl_than_parent
34 | - modifier_order
35 | - multiline_arguments
36 | - multiline_function_chains
37 | - multiline_parameters
38 | - no_extension_access_modifier
39 | - overridden_super_call
40 | - override_in_extension
41 | - prohibited_interface_builder
42 | - prohibited_super_call
43 | - redundant_nil_coalescing
44 | - redundant_type_annotation
45 | - sorted_first_last
46 | - static_operator
47 | - toggle_bool
48 | - trailing_closure
49 | - unavailable_function
50 | - unneeded_parentheses_in_closure_argument
51 | - untyped_error_in_catch
52 | - vertical_parameter_alignment_on_call
53 | - yoda_condition
54 |
55 | excluded: # paths to ignore during linting. Takes precedence over `included`. These paths are
56 | # relative to the target's build directories.
57 | - Pods
58 | - Carthage
59 |
60 | line_length:
61 | warning: 100
62 | error: 120
63 |
64 | type_body_length:
65 | warning: 500
66 | error: 1000
67 |
68 | file_length:
69 | warning: 800
70 | error: 1200
71 |
72 | trailing_comma:
73 | severity: warning
74 | mandatory_comma: true
75 |
76 | function_body_length:
77 | warning: 80
78 |
79 | empty_count: warning
80 |
81 | function_parameter_count:
82 | warning: 10
83 | error: 12
84 |
85 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji, sonarqube)
86 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/UserManagerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserManagerTests.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | import Combine
7 | import GoogleAPIClientForREST
8 | import GoogleSignIn
9 | import GTMSessionFetcher
10 | import XCTest
11 |
12 | @testable import Aspire_Budgeting
13 |
14 | final class UserManagerTests: XCTestCase {
15 | let mockGoogleCredentials = GoogleSDKCredentials(
16 | CLIENT_ID: "dummy_client",
17 | REVERSED_CLIENT_ID: "client_dummy"
18 | )
19 |
20 | let mockGIDSignIn = MockGIDSignIn()
21 |
22 | lazy var userManager = GoogleUserManager(
23 | credentials: mockGoogleCredentials,
24 | gidSignInInstance: mockGIDSignIn
25 | )
26 |
27 | var cancellables = Set()
28 | override func setUp() {
29 | super.setUp()
30 | mockGIDSignIn.clientID = mockGoogleCredentials.CLIENT_ID
31 | }
32 |
33 | func testAuthenticateWithService() {
34 | userManager.authenticate()
35 | XCTAssertEqual(mockGIDSignIn.clientID, mockGoogleCredentials.CLIENT_ID)
36 | XCTAssertTrue(userManager === mockGIDSignIn.delegate)
37 | XCTAssertNotNil(mockGIDSignIn.scopes as? [String])
38 | XCTAssertEqual(
39 | mockGIDSignIn.scopes as! [String],
40 | [
41 | kGTLRAuthScopeDrive,
42 | kGTLRAuthScopeSheetsDrive,
43 | ]
44 | )
45 | XCTAssertTrue(mockGIDSignIn.restoreCalled)
46 | }
47 |
48 | func testSignIn() {
49 | let mockUser = MockUser()
50 |
51 | let exp = XCTestExpectation()
52 | userManager
53 | .userPublisher
54 | .compactMap { $0 }
55 | .sink { user in
56 | XCTAssertEqual(user.name, mockUser.profile.name)
57 | exp.fulfill()
58 | }
59 | .store(in: &cancellables)
60 |
61 | userManager.sign(nil, didSignInFor: mockUser, withError: nil)
62 | wait(for: [exp], timeout: 1)
63 | }
64 |
65 | func testSignOut() {
66 | let exp = XCTestExpectation()
67 | userManager
68 | .userPublisher
69 | .sink { user in
70 | XCTAssertNil(user)
71 | exp.fulfill()
72 | }
73 | .store(in: &cancellables)
74 |
75 | userManager.signOut()
76 | XCTAssertTrue(mockGIDSignIn.signOutCalled)
77 |
78 | wait(for: [exp], timeout: 1)
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Aspire Budgeting/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftyBeaver
7 | import UIKit
8 |
9 | let Logger = SwiftyBeaver.self
10 | var logURL: URL {
11 | var url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
12 | url.appendPathComponent("aspire_budgeting.log")
13 | return url
14 | }
15 |
16 | @UIApplicationMain
17 | class AppDelegate: UIResponder, UIApplicationDelegate {
18 | fileprivate func setupLogger() {
19 | let console = ConsoleDestination()
20 | let file = FileDestination(logFileURL: logURL)
21 |
22 | console.format = "$DHH:mm:ss$d $L $M $X"
23 | file.format += " $X"
24 |
25 | Logger.addDestination(console)
26 | Logger.addDestination(file)
27 | }
28 |
29 | func application(
30 | _ application: UIApplication,
31 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
32 | ) -> Bool {
33 | UITableView.appearance().backgroundColor = .clear
34 | UITableViewCell.appearance().backgroundColor = .clear
35 | UITableView.appearance().separatorColor = .clear
36 |
37 | setupLogger()
38 | return true
39 | }
40 |
41 | // MARK: UISceneSession Lifecycle
42 |
43 | func application(
44 | _ application: UIApplication,
45 | configurationForConnecting connectingSceneSession: UISceneSession,
46 | options: UIScene.ConnectionOptions
47 | ) -> UISceneConfiguration {
48 | // Called when a new scene session is being created.
49 | // Use this method to select a configuration to create the new scene with.
50 | return UISceneConfiguration(
51 | name: "Default Configuration",
52 | sessionRole: connectingSceneSession.role
53 | )
54 | }
55 |
56 | func application(
57 | _ application: UIApplication,
58 | didDiscardSceneSessions sceneSessions: Set
59 | ) {
60 | // Called when the user discards a scene session.
61 | // If any sessions were discarded while the application was not running, this will be called
62 | // shortly after application:didFinishLaunchingWithOptions.
63 | // Use this method to release any resources that were specific to the discarded scenes, as they
64 | // will not return.
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/TabBarView/TabBarItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarItemView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct TabBarItem {
9 | let imageName: String
10 | let title: String
11 | }
12 |
13 | struct TabBarItemView: View {
14 | let tabBarItem: TabBarItem
15 |
16 | let selectedIndex: Int
17 | let tabBarIndex: Int
18 |
19 | let defaultColor: Color
20 | let selectedColor: Color
21 |
22 | let font: Font
23 |
24 | private var displayColor: Color {
25 | selected ? selectedColor : defaultColor
26 | }
27 |
28 | private var selected: Bool {
29 | selectedIndex == tabBarIndex
30 | }
31 |
32 | var body: some View {
33 | VStack {
34 | Image(systemName: tabBarItem.imageName)
35 | .resizable()
36 | .foregroundColor(displayColor)
37 | .aspectRatio(contentMode: .fit)
38 | .frame(width: 30, height: 30)
39 | Text(tabBarItem.title)
40 | .font(font)
41 | .foregroundColor(displayColor)
42 | .frame(height: 10)
43 | }
44 | }
45 | }
46 |
47 | struct TabBarItemView_Previews: PreviewProvider {
48 | static var previews: some View {
49 | Group {
50 | TabBarItemView(tabBarItem: MockProvider.tabBarItems[0],
51 | selectedIndex: 0,
52 | tabBarIndex: 0,
53 | defaultColor: .tabBarItemDefaultTintColor,
54 | selectedColor: .tabBarItemSelectedTintColor,
55 | font: .nunitoBold(size: 14))
56 | TabBarItemView(tabBarItem: MockProvider.tabBarItems[1],
57 | selectedIndex: 0,
58 | tabBarIndex: 1,
59 | defaultColor: .tabBarItemDefaultTintColor,
60 | selectedColor: .tabBarItemSelectedTintColor,
61 | font: .nunitoBold(size: 14))
62 | TabBarItemView(tabBarItem: MockProvider.tabBarItems[0],
63 | selectedIndex: 0,
64 | tabBarIndex: 1,
65 | defaultColor: .tabBarItemDefaultTintColor,
66 | selectedColor: .tabBarItemSelectedTintColor,
67 | font: .nunitoBold(size: 14))
68 | .environment(\.colorScheme, .dark)
69 | }
70 |
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/ObjectFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ObjectFactory.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 | import GoogleSignIn
8 |
9 | final class ObjectFactory {
10 | private let credentialsFileName = "credentials"
11 |
12 | lazy var googleSDKCredentials: GoogleSDKCredentials! = {
13 | var sdkCredentials: GoogleSDKCredentials?
14 |
15 | do {
16 | sdkCredentials = try GoogleSDKCredentials.getCredentials(
17 | from: credentialsFileName,
18 | type: "plist",
19 | bundle: Bundle.main,
20 | decoder: PropertyListDecoder()
21 | )
22 | } catch {
23 | fatalError("Unable to instantiate GoogleSDKCredentials.")
24 | }
25 |
26 | return sdkCredentials!
27 | }()
28 |
29 | lazy var userManager: GoogleUserManager = {
30 | GoogleUserManager(credentials: googleSDKCredentials)
31 | }()
32 |
33 | lazy var driveManager: GoogleDriveManager = {
34 | let driveManager = GoogleDriveManager()
35 | return driveManager
36 | }()
37 |
38 | lazy var sheetsManager: GoogleSheetsManager = {
39 | let sheetsManager = GoogleSheetsManager()
40 | return sheetsManager
41 | }()
42 |
43 | lazy var localAuthorizationManager: LocalAuthorizationManager = {
44 | let localAuthManager = LocalAuthorizationManager()
45 | return localAuthManager
46 | }()
47 |
48 | lazy var stateManager: StateManager = {
49 | StateManager()
50 | }()
51 |
52 | lazy var appDefaultsManager: AppDefaultsManager = {
53 | AppDefaultsManager()
54 | }()
55 |
56 | lazy var googleValidator: GoogleSheetsValidator = {
57 | GoogleSheetsValidator()
58 | }()
59 |
60 | lazy var googleContentManager: GoogleContentManager = {
61 | GoogleContentManager(fileReader: sheetsManager, fileWriter: sheetsManager)
62 | }()
63 |
64 | lazy var authenticationManager: AuthenticationManager = {
65 | AuthenticationManager(userManager: userManager)
66 | }()
67 |
68 | lazy var appCoordinator: AppCoordinator = {
69 | AppCoordinator(stateManager: stateManager,
70 | localAuthorizer: localAuthorizationManager,
71 | appDefaults: appDefaultsManager,
72 | remoteFileManager: driveManager,
73 | userManager: userManager,
74 | fileValidator: googleValidator,
75 | contentProvider: googleContentManager)
76 | }()
77 | }
78 |
--------------------------------------------------------------------------------
/Aspire Budgeting/ViewModels/DashboardViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardViewModel.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 |
8 | final class DashboardViewModel: ObservableObject {
9 | let publisher: AnyPublisher
10 | var cancellables = Set()
11 |
12 | @Published private(set) var dashboard: Dashboard?
13 | @Published private(set) var error: Error?
14 |
15 | var isLoading: Bool {
16 | dashboard == nil && error == nil
17 | }
18 |
19 | init(publisher: AnyPublisher) {
20 | self.publisher = publisher
21 | }
22 |
23 | var cardViewItems: [DashboardCardView.DashboardCardItem] {
24 | guard let dashboard = dashboard else {
25 | return .init()
26 | }
27 | var items = [DashboardCardView.DashboardCardItem]()
28 | for (idx, group) in dashboard.groups.enumerated() {
29 | let title = group.title
30 | let availableTotal = dashboard.availableTotalForGroup(at: idx)
31 | let budgetedTotal = dashboard.budgetedTotalForGroup(at: idx)
32 | let spentTotal = dashboard.spentTotalForGroup(at: idx)
33 | let progressFactor = availableTotal /| budgetedTotal
34 |
35 | items.append(.init(title: title,
36 | availableTotal: availableTotal,
37 | budgetedTotal: budgetedTotal,
38 | spentTotal: spentTotal,
39 | progressFactor: progressFactor,
40 | categories: group.categories))
41 | }
42 | return items
43 | }
44 |
45 | func filteredCategories(filter: String) -> [DashboardCategory] {
46 | guard !filter.isEmpty, let dashboard = dashboard else { return .init() }
47 | var categories = [DashboardCategory]()
48 | categories = dashboard.groups.flatMap { $0.categories
49 | .filter { $0.categoryName
50 | .range(of: filter, options: .caseInsensitive) != nil
51 | }
52 | }
53 | return categories
54 | }
55 |
56 | func refresh() {
57 | cancellables.removeAll()
58 |
59 | publisher
60 | .sink { completion in
61 | switch completion {
62 | case let .failure(error):
63 | self.error = error
64 |
65 | case .finished:
66 | Logger.info("Dashboard fetched.")
67 | }
68 | } receiveValue: {
69 | self.dashboard = $0
70 | }
71 | .store(in: &cancellables)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/Dashboard/CollapsedCardView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CollapsedDashboardView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | @available(*, deprecated, message: "Replaced with new views in v2.0")
9 | struct CollapsedCardView: View {
10 | // let categoryName: String
11 | // let totals: DashboardCardView.Totals
12 | // let categoryRows: [Category]
13 |
14 | func getGradient(for number: AspireNumber) -> LinearGradient {
15 | if number.isNegative {
16 | return Colors.redGradient
17 | }
18 | return Colors.greenGradient
19 | }
20 |
21 | var body: some View {
22 | VStack {
23 | // HStack {
24 | // VStack(alignment: .leading) {
25 | // Text(categoryName)
26 | // .tracking(1)
27 | // .font(.rubikRegular(size: 20))
28 | // .padding([.top])
29 | // .foregroundColor(.white)
30 | // Text("Spent")
31 | // .tracking(1)
32 | // .font(.rubikLight(size: 13))
33 | // .padding([.top])
34 | // .foregroundColor(.white)
35 | // GradientTextView(
36 | // string: totals.spentTotals.stringValue,
37 | // tracking: 1.18,
38 | // font: .rubikMedium(size: 15),
39 | // paddingEdges: .top,
40 | // paddingLength: nil,
41 | // gradient: Colors.redGradient
42 | // )
43 | // }.padding([.horizontal])
44 | // Spacer()
45 | // VStack {
46 | // GradientTextView(
47 | // string: totals.availableTotal.stringValue,
48 | // tracking: 2.34,
49 | // font: .rubikMedium(size: 30),
50 | // paddingEdges: .trailing,
51 | // paddingLength: nil,
52 | // gradient: self.getGradient(for: totals.availableTotal)
53 | // )
54 | // Text("Available")
55 | // .tracking(1)
56 | // .font(.rubikLight(size: 13))
57 | // .foregroundColor(.white)
58 | // }
59 | // }
60 | // Text("View \(categoryRows.count) categories")
61 | // .tracking(1)
62 | // .font(.rubikLight(size: 13))
63 | // .foregroundColor(.white)
64 | // .padding(.bottom, 5)
65 | // .opacity(0.6)
66 | }
67 | }
68 | }
69 |
70 | // struct CollapsedCardView_Previews: PreviewProvider {
71 | // static var previews: some View {
72 | // CollapsedCardView()
73 | // }
74 | // }
75 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/GoogleSDKCredentialsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GoogleSDKCredentialsTest.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | @testable import Aspire_Budgeting
7 | import XCTest
8 |
9 | class GoogleSDKCredentialsTests: XCTestCase {
10 | var testBundle: Bundle {
11 | Bundle(for: type(of: self))
12 | }
13 |
14 | let decoder = PropertyListDecoder()
15 |
16 | func testGetCredentialsNoThrow() {
17 | // This is an example of a functional test case.
18 | // Use XCTAssert and related functions to verify your tests produce the correct results.
19 | let fileName = "credentials"
20 | let type = "plist"
21 |
22 | XCTAssertNoThrow(
23 | try GoogleSDKCredentials.getCredentials(
24 | from: fileName,
25 | type: type,
26 | bundle: testBundle,
27 | decoder: decoder
28 | )
29 | )
30 | }
31 |
32 | func testGetCredentialsThrowsMissingCredentialsPLIST() {
33 | XCTAssertThrowsError(
34 | try GoogleSDKCredentials.getCredentials(
35 | from: "no_file",
36 | type: "plist",
37 | bundle: testBundle,
38 | decoder: decoder
39 | ),
40 | ""
41 | ) { error in
42 | guard let error = error as? CredentialsError else {
43 | XCTFail("Type of error thrown is incorrect")
44 | return
45 | }
46 |
47 | XCTAssertEqual(error, CredentialsError.missingCredentialsPLIST)
48 | }
49 | }
50 |
51 | func testGetCredentialsThrowsCouldNotCreate() {
52 | XCTAssertThrowsError(
53 | try GoogleSDKCredentials.getCredentials(
54 | from: "bad_credentials",
55 | type: "plist",
56 | bundle: testBundle,
57 | decoder: decoder
58 | ),
59 | ""
60 | ) { error in
61 | guard let error = error as? CredentialsError else {
62 | XCTFail("Type of error thrown is incorrect")
63 | return
64 | }
65 |
66 | XCTAssertEqual(error, CredentialsError.couldNotCreate)
67 | }
68 | }
69 |
70 | // func testGoogleSDKErrorEquality() {
71 | // XCTAssertEqual(
72 | // CredentialsError.missingCredentialsPLIST,
73 | // CredentialsError.missingCredentialsPLIST
74 | // )
75 | // XCTAssertEqual(CredentialsError.couldNotCreate, CredentialsError.couldNotCreate)
76 | // XCTAssertNotEqual(
77 | // GoogleSDKCredentialsError.missingCredentialsPLIST,
78 | // GoogleSDKCredentialsError.couldNotCreate
79 | // )
80 | // }
81 | }
82 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/Transaction.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Transaction.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | extension String {
9 | func caseInsensitiveCompare(_ other: String) -> Bool {
10 | self.caseInsensitiveCompare(other) == .orderedSame
11 | }
12 | }
13 | extension Collection {
14 | subscript (safe index: Index) -> Element? {
15 | indices.contains(index) ? self[index] : nil
16 | }
17 | }
18 |
19 | enum ApprovalType {
20 | case pending
21 | case approved
22 | case reconcile
23 |
24 | static func approvalType(from: String) -> Self {
25 | switch from {
26 | case "✅", "🆗":
27 | return .approved
28 |
29 | case "🅿️", "⏺":
30 | return .pending
31 |
32 | default:
33 | return .reconcile
34 | }
35 | }
36 | }
37 |
38 | enum TransactionType {
39 | case inflow
40 | case outflow
41 | }
42 |
43 | struct Transaction: Hashable {
44 | let amount: String
45 | let memo: String
46 | let date: Date
47 | let account: String
48 | let category: String
49 | let transactionType: TransactionType
50 | let approvalType: ApprovalType
51 | }
52 |
53 | extension Transaction {
54 | func contains(_ text: String) -> Bool {
55 | self.amount.caseInsensitiveCompare(text) ||
56 | self.memo.contains(text) ||
57 | self.account.contains(text) ||
58 | self.category.contains(text)
59 | }
60 | }
61 |
62 | struct Transactions: ConstructableFromRows {
63 | let transactions: [Transaction]
64 |
65 | init(rows: [[String]]) {
66 | let dateFormatter = DateFormatter()
67 | dateFormatter.dateStyle = .short
68 | dateFormatter.timeStyle = .none
69 |
70 | transactions = rows.filter { $0.count == 7 }.map { row in
71 | let date = dateFormatter.date(from: row[0]) ?? Date()
72 | let (amount, transactionType) =
73 | row[1].isEmpty ? (row[2], TransactionType.inflow) : (row[1], TransactionType.outflow)
74 | let category = row[3]
75 | let account = row[4]
76 | let memo = row[5]
77 | let approvalType = ApprovalType.approvalType(from: row[6])
78 |
79 | return Transaction(amount: amount,
80 | memo: memo,
81 | date: date,
82 | account: account,
83 | category: category,
84 | transactionType: transactionType,
85 | approvalType: approvalType)
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/ValueRangeCreator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ValueRangeCreator.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 | import GoogleAPIClientForREST
8 |
9 | enum ValueRangeCreator {
10 | static func valuerange(from categoryTransfer: CategoryTransfer) -> GTLRSheets_ValueRange {
11 | let dateFormatter = DateFormatter()
12 | dateFormatter.dateStyle = .medium
13 | dateFormatter.timeStyle = .none
14 |
15 | let valueRange = GTLRSheets_ValueRange()
16 | valueRange.majorDimension = kGTLRSheets_ValueRange_MajorDimension_Rows
17 |
18 | var valuesToInsert = [String]()
19 | valuesToInsert.append(dateFormatter.string(from: Date()))
20 | valuesToInsert.append(categoryTransfer.amount)
21 | valuesToInsert.append(categoryTransfer.fromCategory.title)
22 | valuesToInsert.append(categoryTransfer.toCategory.title)
23 | valuesToInsert.append(categoryTransfer.memo ?? "")
24 |
25 | valueRange.values = [valuesToInsert]
26 | return valueRange
27 | }
28 |
29 | static func valueRange(from transaction: Transaction,
30 | for version: SupportedLegacyVersion) -> GTLRSheets_ValueRange {
31 |
32 | let dateFormatter = DateFormatter()
33 | dateFormatter.dateStyle = .medium
34 | dateFormatter.timeStyle = .none
35 |
36 | let valueRange = GTLRSheets_ValueRange()
37 | valueRange.majorDimension = kGTLRSheets_ValueRange_MajorDimension_Rows
38 |
39 | var valuesToInsert = [String]()
40 | valuesToInsert.append(dateFormatter.string(from: transaction.date))
41 |
42 | if transaction.transactionType == .inflow {
43 | valuesToInsert.append("")
44 | valuesToInsert.append(transaction.amount)
45 | } else {
46 | valuesToInsert.append(transaction.amount)
47 | valuesToInsert.append("")
48 | }
49 |
50 | valuesToInsert.append(transaction.category)
51 | valuesToInsert.append(transaction.account)
52 | valuesToInsert.append(transaction.memo)
53 |
54 | let approvalType = transaction.approvalType
55 |
56 | switch version {
57 | case .twoEight:
58 | if approvalType == .approved {
59 | valuesToInsert.append("🆗")
60 | } else {
61 | valuesToInsert.append("⏺")
62 | }
63 |
64 | case .three, .threeOne, .threeTwo, .threeThree:
65 | if approvalType == .approved {
66 | valuesToInsert.append("✅")
67 | } else {
68 | valuesToInsert.append("🅿️")
69 | }
70 | }
71 |
72 | valueRange.values = [valuesToInsert]
73 | return valueRange
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireProgressBar.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireProgressBar.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct AspireProgressBar: View {
9 | enum BarType {
10 | case minimal, detailed
11 | }
12 |
13 | let barType: BarType
14 | let shadowColor: Color
15 | let tintColor: Color
16 | let progressFactor: Double
17 |
18 | private var displayProgressFactor: CGFloat {
19 | if progressFactor == 0 && barType == .minimal {
20 | return 0
21 | }
22 |
23 | if progressFactor == 0 && barType == .detailed {
24 | return 0.1
25 | }
26 |
27 | return CGFloat(progressFactor)
28 | }
29 |
30 | private var barHeight: CGFloat {
31 | barType == .detailed ? 12 : 6
32 | }
33 |
34 | private var barColor: Color {
35 | barType == .detailed ? Color(#colorLiteral(red: 1, green: 1, blue: 1, alpha: 1)) : tintColor
36 | }
37 |
38 | var body: some View {
39 | ZStack(alignment: .leading) {
40 | GeometryReader { geo in
41 | RoundedRectangle(cornerRadius: 6)
42 | .fill(Color(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.12)))
43 |
44 | RoundedRectangle(cornerRadius: 6)
45 | .strokeBorder(shadowColor, lineWidth: 0.5)
46 |
47 | RoundedRectangle(cornerRadius: 6)
48 | .fill(barColor)
49 | .frame(width: geo.frame(in: .local).width * displayProgressFactor)
50 | .shadow(color: Color(#colorLiteral(red: 0.8198039531707764, green: 0.8295795917510986, blue: 0.8882334232330322, alpha: 0.5033401250839233))
51 | .opacity(barType == .detailed ? 1 :0.6),
52 | radius: 14,
53 | x: 0,
54 | y: 12)
55 | }
56 |
57 | if barType == .detailed {
58 | Text("\(String(format: "%.1f", progressFactor * 100))%")
59 | .font(.karlaBold(size: 10))
60 | .foregroundColor(tintColor)
61 | .lineSpacing(3)
62 | .padding(.leading, 10)
63 | }
64 | }
65 | .compositingGroup()
66 | .frame(height: barHeight)
67 | .shadow(color: Color(#colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.5)), radius: 1, x: 0, y: 2)
68 | }
69 | }
70 |
71 | struct AspireProgressBar_Previews: PreviewProvider {
72 | static var previews: some View {
73 | AspireProgressBar(barType: .detailed,
74 | shadowColor: .gray,
75 | tintColor: .blue,
76 | progressFactor: 0.4)
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AspireMasterView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AspireMasterView.swift
3 | // Aspire Budgeting
4 | // swiftlint:disable inclusive_language
5 |
6 | import SwiftUI
7 |
8 | struct AspireMasterView: View {
9 | @EnvironmentObject var appCoordinator: AppCoordinator
10 |
11 | let tabBarItems = [TabBarItem(imageName: "rectangle.grid.1x2", title: "Dashboard"),
12 | TabBarItem(imageName: "creditcard", title: "Accounts"),
13 | TabBarItem(imageName: "arrow.up.arrow.down", title: "Transactions"),
14 | TabBarItem(imageName: "gear", title: "Settings"),
15 | ]
16 |
17 | @State private var selectedTab = 0
18 | @State private var navTitle: String = ""
19 | @State private var addingTransaction = false
20 |
21 | var body: some View {
22 | GeometryReader { _ in
23 | VStack {
24 | AspireNavigationBar(title: $navTitle)
25 | .edgesIgnoringSafeArea(.all)
26 | .frame(height: 50)
27 | Group {
28 | if selectedTab == 0 {
29 | DashboardView(viewModel: appCoordinator.dashboardVM)
30 | .onAppear {
31 | self.navTitle = "Dashboard"
32 | }
33 | } else if selectedTab == 1 {
34 | AccountBalancesView(viewModel: appCoordinator.accountBalancesVM)
35 | .onAppear {
36 | self.navTitle = "Accounts"
37 | }
38 | } else if selectedTab == 2 {
39 | TransactionsView(viewModel: appCoordinator.transactionsVM)
40 | .onAppear {
41 | self.navTitle = "Transactions"
42 | }
43 | } else if selectedTab == 3 {
44 | SettingsView(viewModel: appCoordinator.settingsVM)
45 | .onAppear {
46 | self.navTitle = "Settings"
47 | }
48 | }
49 | }
50 | .frame(height: UIScreen.main.bounds.height - 200)
51 |
52 | TabBarView(selectedTab: $selectedTab,
53 | tabBarItems: tabBarItems,
54 | prominentItemImageName: "plus") {
55 | self.addingTransaction.toggle()
56 | }
57 | .frame(height: 95)
58 | .padding(.horizontal, 5)
59 | .background(Color.primaryBackgroundColor)
60 | .sheet(isPresented: $addingTransaction) {
61 | AddTransactionView(viewModel: self.appCoordinator.addTransactionVM)
62 | }
63 | }
64 | }
65 | }
66 | }
67 |
68 | struct AspireMasterView_Previews: PreviewProvider {
69 | static var previews: some View {
70 | AspireMasterView()
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/Dashboard/DashboardRow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DashboardRow.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | #warning("Remove DashboardRow")
9 | struct DashboardRow: View {
10 | var categoryRow: DashboardCategory
11 |
12 | var body: some View {
13 | VStack(alignment: .leading) {
14 | Text(categoryRow.categoryName)
15 | .tracking(1)
16 | .font(.rubikRegular(size: 18))
17 | .padding([.horizontal])
18 | .padding(.bottom, 5)
19 | .foregroundColor(.white)
20 | HStack {
21 | VStack {
22 | GradientTextView(
23 | string: categoryRow.budgeted.stringValue,
24 | tracking: 1,
25 | font: .rubikRegular(size: 16),
26 | paddingEdges: .horizontal,
27 | paddingLength: nil,
28 | gradient: Colors.yellowGradient
29 | )
30 |
31 | GradientTextView(
32 | string: "Budgeted",
33 | tracking: 1,
34 | font: .rubikLight(size: 12),
35 | paddingEdges: .horizontal,
36 | paddingLength: nil,
37 | gradient: Colors.yellowGradient
38 | )
39 | }
40 | Spacer()
41 | VStack {
42 | GradientTextView(
43 | string: categoryRow.available.stringValue,
44 | tracking: 1,
45 | font: .rubikRegular(size: 16),
46 | paddingEdges: .horizontal,
47 | paddingLength: nil,
48 | gradient: Colors.greenGradient
49 | )
50 |
51 | GradientTextView(
52 | string: "Available",
53 | tracking: 1,
54 | font: .rubikLight(size: 12),
55 | paddingEdges: .horizontal,
56 | paddingLength: nil,
57 | gradient: Colors.greenGradient
58 | )
59 | }
60 | Spacer()
61 | VStack {
62 | GradientTextView(
63 | string: categoryRow.spent.stringValue,
64 | tracking: 1,
65 | font: .rubikRegular(size: 16),
66 | paddingEdges: .horizontal,
67 | paddingLength: nil,
68 | gradient: Colors.redGradient
69 | )
70 | GradientTextView(
71 | string: "Spent",
72 | tracking: 1,
73 | font: .rubikLight(size: 12),
74 | paddingEdges: .horizontal,
75 | paddingLength: nil,
76 | gradient: Colors.redGradient
77 | )
78 | }
79 | }
80 | }
81 | }
82 | }
83 |
84 | // struct DashboardRow_Previews: PreviewProvider {
85 | // static var previews: some View {
86 | // DashboardRow()
87 | // }
88 | // }
89 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Models/Dashboard.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Dashboard.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | struct Dashboard: ConstructableFromRows {
9 | struct Group: Equatable {
10 | let title: String
11 | let categories: [DashboardCategory]
12 | }
13 |
14 | private enum TotalType {
15 | case available, budgeted, spent
16 | }
17 |
18 | let groups: [Group]
19 | private let numFormatter = NumberFormatter()
20 |
21 | init(rows: [[String]]) {
22 | groups = Dashboard.parse(rows: rows)
23 | numFormatter.numberStyle = .currency
24 | numFormatter.minimumFractionDigits = 2
25 | }
26 |
27 | private static func parse(rows: [[String]]) -> [Group] {
28 | var title = ""
29 | var categories = [DashboardCategory]()
30 | var groups = [Group]()
31 |
32 | for row in rows where row.count == 10 {
33 | if row[0] == "✦" { // Group Row
34 | if !title.isEmpty {
35 | groups.append(Group(title: title, categories: categories))
36 | categories.removeAll()
37 | }
38 | title = row[2]
39 | } else {
40 | let categoryRow = DashboardCategory(row: row)
41 | categories.append(categoryRow)
42 | }
43 | }
44 |
45 | groups.append(Group(title: title, categories: categories))
46 | return groups
47 | }
48 | }
49 |
50 | // MARK: Computing Functions
51 | extension Dashboard {
52 | private func getTotalOf(type: TotalType, at idx: Int) -> AspireNumber {
53 | guard idx < groups.count else { fatalError("Index out of bounds") }
54 |
55 | let categories = groups[idx].categories
56 | var total: Decimal = 0
57 | var numberString = "0"
58 |
59 | for category in categories {
60 | let categoryNumber: AspireNumber
61 | switch type {
62 | case .available:
63 | categoryNumber = category.available
64 | case .budgeted:
65 | categoryNumber = category.budgeted
66 | case .spent:
67 | categoryNumber = category.spent
68 | }
69 |
70 | total += categoryNumber.decimalValue
71 | }
72 |
73 | if let numString = numFormatter.string(from: total as NSDecimalNumber) {
74 | numberString = numString
75 | }
76 |
77 | return AspireNumber(stringValue: numberString, decimalValue: total)
78 | }
79 |
80 | func availableTotalForGroup(at idx: Int) -> AspireNumber {
81 | getTotalOf(type: .available, at: idx)
82 | }
83 |
84 | func budgetedTotalForGroup(at idx: Int) -> AspireNumber {
85 | getTotalOf(type: .budgeted, at: idx)
86 | }
87 |
88 | func spentTotalForGroup(at idx: Int) -> AspireNumber {
89 | getTotalOf(type: .spent, at: idx)
90 | }
91 | }
92 |
93 | extension Dashboard: Equatable {
94 | static func == (lhs: Dashboard, rhs: Dashboard) -> Bool {
95 | lhs.groups == rhs.groups
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/AppDefaults.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDefaults.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Foundation
7 |
8 | protocol AppDefaults {
9 | func addDefault(sheet: AspireSheet)
10 | func getDefaultSheet() -> AspireSheet?
11 | func clearDefaultFile()
12 | }
13 |
14 | protocol AppUserDefaults {
15 | func data(forKey defaultName: String) -> Data?
16 | func set(_ value: Any?, forKey defaultName: String)
17 | func removeObject(forKey defaultName: String)
18 | func dictionary(forKey defaultName: String) -> [String: Any]?
19 | }
20 |
21 | extension UserDefaults: AppUserDefaults {}
22 |
23 | struct AppDefaultsManager: AppDefaults {
24 | private let userDefaults: AppUserDefaults
25 | private let defaultSheetKey: String
26 |
27 | init(
28 | userDefaults: AppUserDefaults = UserDefaults(),
29 | defaultSheetKey: String = "Aspire_Sheet"
30 | ) {
31 | self.userDefaults = userDefaults
32 | self.defaultSheetKey = defaultSheetKey
33 | }
34 |
35 | func addDefault(sheet: AspireSheet) {
36 | do {
37 | let data = try JSONEncoder().encode(sheet)
38 | userDefaults.set(data, forKey: defaultSheetKey)
39 | } catch {
40 | fatalError("This should've never happened!!")
41 | }
42 | }
43 |
44 | func getDefaultSheet() -> AspireSheet? {
45 | guard let data = userDefaults.data(forKey: defaultSheetKey),
46 | let sheet = try? JSONDecoder().decode(AspireSheet.self, from: data) else {
47 | Logger.info(
48 | "No default sheet found"
49 | )
50 | return nil
51 | }
52 |
53 | Logger.info(
54 | "Default sheet found: \(sheet.file.name)"
55 | )
56 | return sheet
57 | }
58 | //
59 | // func getDefaultFile() -> File? {
60 | // guard let data = userDefaults.data(forKey: defaultFileKey),
61 | // let file = try? JSONDecoder().decode(File.self, from: data) else {
62 | // Logger.info(
63 | // "No default file found"
64 | // )
65 | // return nil
66 | // }
67 | //
68 | // Logger.info(
69 | // "Default file found."
70 | // )
71 | // return file
72 | // }
73 | //
74 | // func addDefault(file: File) {
75 | // do {
76 | // let data = try JSONEncoder().encode(file)
77 | // userDefaults.set(data, forKey: defaultFileKey)
78 | // } catch {
79 | // fatalError("This should've never happened!!")
80 | // }
81 | // }
82 | //
83 | // func addDataLocationMap(map: [String: String]) {
84 | // userDefaults.set(map, forKey: dataMapKey)
85 | // }
86 | //
87 | // func getDataLocationMap() -> [String: String] {
88 | // guard let map = userDefaults
89 | // .dictionary(forKey: self.dataMapKey) as? [String: String] else {
90 | // return [String: String]()
91 | // }
92 | // return map
93 | // }
94 |
95 | func clearDefaultFile() {
96 | userDefaults.removeObject(forKey: defaultSheetKey)
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/Dashboard/ExpandedCardView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExpandedCardView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | @available(*, deprecated, message: "Replaced with new views in v2.0")
9 | struct ExpandedCardView: View {
10 | // let categoryName: String
11 | // let totals: DashboardCardView.Totals
12 | // let categoryRows: [Category]
13 |
14 | var body: some View {
15 | VStack {
16 | // Text(categoryName)
17 | // .tracking(1)
18 | // .font(.rubikRegular(size: 20))
19 | // .padding(.vertical)
20 | // .foregroundColor(.white)
21 | // HStack {
22 | // VStack {
23 | // GradientTextView(
24 | // string: totals.budgetedTotal.stringValue,
25 | // tracking: 1.35,
26 | // font: .rubikMedium(size: 18),
27 | // paddingEdges: .horizontal,
28 | // paddingLength: nil,
29 | // gradient: Colors.yellowGradient
30 | // )
31 | // Text("Budgeted")
32 | // .tracking(0.5)
33 | // .font(.rubikLight(size: 7.5))
34 | // .padding(.top, 2)
35 | // .foregroundColor(.white)
36 | // .opacity(0.6)
37 | // }
38 | // VStack {
39 | // GradientTextView(
40 | // string: totals.availableTotal.stringValue,
41 | // tracking: 1.35,
42 | // font: .rubikMedium(size: 18),
43 | // paddingEdges: .horizontal,
44 | // paddingLength: nil,
45 | // gradient: Colors.greenGradient
46 | // )
47 | //
48 | // Text("Available")
49 | // .tracking(0.5)
50 | // .font(.rubikLight(size: 7.5))
51 | // .padding(.top, 2)
52 | // .foregroundColor(.white)
53 | // .opacity(0.6)
54 | // }
55 | // VStack {
56 | // GradientTextView(
57 | // string: totals.spentTotals.stringValue,
58 | // tracking: 1.35,
59 | // font: .rubikMedium(size: 18),
60 | // paddingEdges: .horizontal,
61 | // paddingLength: nil,
62 | // gradient: Colors.redGradient
63 | // )
64 | //
65 | // Text("Spent")
66 | // .tracking(0.5)
67 | // .font(.rubikLight(size: 7.5))
68 | // .padding(.top, 2)
69 | // .foregroundColor(.white)
70 | // .opacity(0.6)
71 | // }
72 | // }
73 | // Divider().padding(.horizontal)
74 | // ForEach(self.categoryRows, id: \.self) { row in
75 | // VStack {
76 | // DashboardRow(categoryRow: row).padding().transition(.identity)
77 | // Divider().padding(.horizontal)
78 | // }
79 | // }
80 | }
81 | }
82 | }
83 |
84 | // struct ExpandedCardView_Previews: PreviewProvider {
85 | // static var previews: some View {
86 | // ExpandedCardView()
87 | // }
88 | // }
89 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 | LSRequiresIPhoneOS
22 |
23 | NSFaceIDUsageDescription
24 | This is necessary to protect your information from unauthorized access.
25 | NSMicrophoneUsageDescription
26 | Aspire Budgeting needs access to the microphone to be able to attach voice notes to bug reports.
27 | NSPhotoLibraryUsageDescription
28 | Aspire Budgeting needs access to the photo library to be able to attach images to bug reports.
29 | UIAppFonts
30 |
31 | Karla-Bold.ttf
32 | Karla-Regular.ttf
33 | Nunito-Bold.ttf
34 | Nunito-SemiBold.ttf
35 | Nunito-Regular.ttf
36 | Rubik-Medium.ttf
37 | Rubik-Regular.ttf
38 | Rubik-Light.ttf
39 |
40 | UIApplicationSceneManifest
41 |
42 | UIApplicationSupportsMultipleScenes
43 |
44 | UISceneConfigurations
45 |
46 | UIWindowSceneSessionRoleApplication
47 |
48 |
49 | UISceneConfigurationName
50 | Default Configuration
51 | UISceneDelegateClassName
52 | $(PRODUCT_MODULE_NAME).SceneDelegate
53 |
54 |
55 |
56 |
57 | UILaunchStoryboardName
58 | LaunchScreen
59 | UIRequiredDeviceCapabilities
60 |
61 | armv7
62 |
63 | UISupportedInterfaceOrientations
64 |
65 | UIInterfaceOrientationPortrait
66 |
67 | UISupportedInterfaceOrientations~ipad
68 |
69 | UIInterfaceOrientationPortrait
70 | UIInterfaceOrientationPortraitUpsideDown
71 | UIInterfaceOrientationLandscapeLeft
72 | UIInterfaceOrientationLandscapeRight
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/AccountBalancesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountBalancesView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import SwiftUI
8 |
9 | struct AccountBalancesView: View {
10 | @ObservedObject var viewModel: AccountBalancesViewModel
11 | @State private(set) var showingAlert = false
12 |
13 | func getColorForNumber(number: AspireNumber) -> Color {
14 | if number.isNegative {
15 | return Color(red: 0.784, green: 0.416, blue: 0.412)
16 | }
17 | return Color(red: 0.196, green: 0.682, blue: 0.482)
18 | }
19 |
20 | var body: some View {
21 | VStack {
22 | if self.viewModel.isLoading {
23 | GeometryReader {
24 | LoadingView(height: $0.frame(in: .local).size.height)
25 | }
26 | } else {
27 | ScrollView {
28 | ForEach(
29 | self.viewModel.accountBalances.accountBalances,
30 | id: \.self
31 | ) { accountBalance in
32 | BaseCardView(minY: 0, curY: 0, baseColor: .accountBalanceCardColor) {
33 | GeometryReader { geo in
34 | ZStack {
35 | Image.bankIcon
36 | .resizable()
37 | .scaledToFit()
38 | .frame(width: geo.frame(in: .global).width,
39 | height: geo.frame(in: .global).height,
40 | alignment: .center)
41 |
42 | VStack {
43 | Text(accountBalance.accountName)
44 | .foregroundColor(Color.white)
45 | .font(.nunitoSemiBold(size: 20))
46 |
47 | Text(accountBalance.balance.stringValue)
48 | .foregroundColor(self.getColorForNumber(number: accountBalance.balance))
49 | .font(.nunitoSemiBold(size: 25))
50 |
51 | Text(accountBalance.additionalText)
52 | .foregroundColor(Color.white)
53 | .font(.nunitoRegular(size: 12))
54 | }
55 | }
56 | }
57 | }
58 | .padding(.horizontal)
59 | }
60 | }
61 | }
62 | }
63 | .alert(isPresented: $showingAlert, content: {
64 | Alert(title: Text("Error Occured"),
65 | message: Text("\(viewModel.error?.localizedDescription ?? "")"),
66 | dismissButton: .cancel())
67 | })
68 | .onReceive(viewModel.$error, perform: { error in
69 | self.showingAlert = error != nil
70 | })
71 | .background(Color.primaryBackgroundColor)
72 | .onAppear {
73 | self.viewModel.refresh()
74 | }
75 | }
76 | }
77 |
78 | struct AccountBalancesView_Previews: PreviewProvider {
79 | static var previews: some View {
80 | AccountBalancesView(viewModel: .init(
81 | publisher: Just(MockProvider.accountBalances)
82 | .setFailureType(to: Error.self)
83 | .eraseToAnyPublisher()
84 | )
85 | )
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |  [](https://codecov.io/gh/aspirebudgetingmobile/aspirebudgeting_ios)
2 | # What is Aspire Budgeting?
3 | This is explained best by [u/Sapphire_Rapids](https://www.reddit.com/user/Sapphire_Rapids/), the creator of the Aspire Budgeting Google Sheet on his website [AspireBudget.com](https://aspirebudget.com/)
4 | > Aspire is an envelope-style budgeting spreadsheet. Its primary goal is to give you the power and ability to be proactive with your finances - all in a delightfully designed Google Sheet. With Aspire, you can see your budget with just a glance, quickly add transactions as you make them, and run reports to get new insights on your spending.
5 |
6 | # Aspire iOS App
7 | This is an independent project to develop a cleaner mobile interface to interact with the Aspire Google Sheet. The official [Google Sheets](https://apps.apple.com/us/app/google-sheets/id842849113) app while truly powerful does not provide a good experience for a sheet of this kind.
8 |
9 | # Project Goals
10 | The goal of this project is simple. It is to foster a positive learning environment for myself and others in the field of iOS development.
11 |
12 | The project should use the latest APIs available and have a clean, simple and a smooth user experience.
13 |
14 | The project will be split up into 4 phases.
15 |
16 | ## Phase 1
17 |
18 | 1. Ability to connect to Google Drive via The Google iOS SDK.
19 | 2. Ability to read data points of interest from the Dashboard tab of the Aspire Sheet.
20 | 3. Siri Integration. For example, “Hey Siri, how much can I spend on groceries?”
21 | 4. Integration with iOS widgets.
22 | 5. Fastlane integration for beta deployments, CI/CD, screenshot creation and beta tester sign up sheet.
23 | 6. No data will be cached in Phase 1. The goal of the next few phases will be to build a solid privacy guideline and strategy.
24 |
25 | ## Phase 2
26 |
27 | 1. Ability to add Transactions.
28 | 2. Ability to perform Category Transfers.
29 | 3. Building a privacy guideline and strategy with the community enhance user experience using caching.
30 |
31 | ## Phase 3
32 |
33 | 1. Data synchronization.
34 | 2. Efficient strategy for merging data and conflict resolution between data on the cloud and on the device.
35 |
36 | ## Phase 4
37 |
38 | 1. A complete on boarding flow right from copying the sheet from the Aspire website to the users account and entering all the required data in the Configurations tab.
39 |
40 | Phase 2(3) and Phase 3(1) and Phase 3(2) are too high level at this point and will require some research and expertise.
41 |
42 | # Code Quality
43 |
44 | The project will use the highest standards in terms of code quality and architecture. We should strive for ~85% code coverage using Unit Tests and UI Tests.
45 |
46 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/FileSelectorView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileSelectorView.swift
3 | // Aspire Budgeting
4 | // swiftlint:disable trailing_closure
5 |
6 | import GoogleSignIn
7 | import SwiftUI
8 |
9 | struct FileSelectorView: View {
10 | @ObservedObject var viewModel: FileSelectorViewModel
11 |
12 | @State private(set) var showingAlert = false
13 | var files: [File] {
14 | viewModel.files
15 | }
16 |
17 | var error: Error? {
18 | viewModel.error
19 | }
20 |
21 | var filteredFiles: [File] {
22 | files.filter {
23 | searchText.isEmpty ? true : $0.name.contains(searchText)
24 | }
25 | }
26 |
27 | @State private var searchText = ""
28 |
29 | var body: some View {
30 | VStack {
31 | if viewModel.files.isEmpty {
32 | LoadingView()
33 | }
34 |
35 | if !viewModel.files.isEmpty {
36 | NavigationView {
37 | VStack {
38 | SearchBar(text: $searchText)
39 | List(filteredFiles) { file in
40 | Button(
41 | action: {
42 | self.viewModel.selected(file: file)
43 | }, label: {
44 | HStack {
45 | Image.sheetsIcon
46 | .renderingMode(.original)
47 | Text(file.name)
48 | .font(.nunitoBold(size: 16))
49 | }.frame(height: 60)
50 | }
51 | )
52 | }.padding(.bottom, 20).navigationBarTitle("Link your Aspire sheet")
53 | }.background(Color.primaryBackgroundColor.edgesIgnoringSafeArea(.all))
54 | }
55 | }
56 | }.alert(isPresented: $showingAlert, content: {
57 | Alert(title: Text("Error Occured"),
58 | message: Text("\(viewModel.error?.localizedDescription ?? "")"),
59 | dismissButton: .cancel())
60 | })
61 | .onReceive(viewModel.$error, perform: { error in
62 | self.showingAlert = error != nil
63 | })
64 | .onAppear {
65 | viewModel.getFiles()
66 | }
67 | }
68 | }
69 |
70 | struct FileSelectorView_Previews: PreviewProvider {
71 | static let files = [File(id: "abc", name: "File 1"),
72 | File(id: "def", name: "File 2"),
73 | ]
74 | static let fileManager = PreviewFileManager(files: files, error: nil)
75 | static let aspireSheet = AspireSheet(
76 | file: files[0],
77 | dataMap: [String: String]()
78 | )
79 | static let fileValidator = PreviewValidator(aspireSheet: aspireSheet, error: nil)
80 |
81 | static let viewModel =
82 | FileSelectorViewModel(
83 | fileManager: fileManager,
84 | fileValidator: fileValidator,
85 | user: User(name: "First lasr", authorizer: MockAuthorizer())
86 | )
87 | static var previews: some View {
88 | Group {
89 | FileSelectorView(viewModel: FileSelectorView_Previews.viewModel)
90 |
91 | FileSelectorView(viewModel: FileSelectorView_Previews.viewModel)
92 | .environment(\.colorScheme, .dark)
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/TabBarView/TabBarView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct TabBarView: View {
9 |
10 | private let cornerRadius: CGFloat = 16
11 | private let height: CGFloat = 95
12 | private let shadowRadius: CGFloat = 5
13 |
14 | private let prominentItemWidth: CGFloat = 70
15 |
16 | private var prominentItemTopPadding: CGFloat {
17 | prominentItemWidth
18 | }
19 |
20 | @Binding var selectedTab: Int
21 |
22 | let tabBarItems: [TabBarItem]
23 | let prominentItemImageName: String
24 | let prominentItemAction: () -> Void
25 |
26 | var body: some View {
27 | ZStack {
28 | containerBox
29 | prominentItemView
30 | tabBarItemsView.frame(height: 60)
31 | }
32 | }
33 | }
34 |
35 | extension TabBarView {
36 | private var containerBox: some View {
37 | Rectangle()
38 | .fill(Color.tabBarColor)
39 | .cornerRadius(cornerRadius)
40 | .frame(height: height)
41 | .shadow(radius: shadowRadius)
42 | }
43 |
44 | private var prominentItemView: some View {
45 | ProminentTabBarItemView(systemImageName: prominentItemImageName) {
46 | self.prominentItemAction()
47 | }
48 | .padding(.bottom, prominentItemTopPadding)
49 | }
50 |
51 | private func tabBarItemWidth(from proxy: GeometryProxy) -> CGFloat {
52 | (proxy.frame(in: .global).width - prominentItemWidth) / 4
53 | }
54 |
55 | private var tabBarItemsView: some View {
56 | GeometryReader { geo in
57 | HStack {
58 | ForEach(0..<2) { idx in
59 | TabBarItemView(tabBarItem: self.tabBarItems[idx],
60 | selectedIndex: self.selectedTab,
61 | tabBarIndex: idx,
62 | defaultColor: .tabBarItemDefaultTintColor,
63 | selectedColor: .tabBarItemSelectedTintColor,
64 | font: .nunitoBold(size: 14))
65 | .frame(width: self.tabBarItemWidth(from: geo))
66 | .onTapGesture {
67 | self.selectedTab = idx
68 | }
69 | }
70 |
71 | Spacer()
72 |
73 | ForEach(2.. AnyView {
88 | switch type {
89 | case .inflow:
90 | return AnyView(arrowDown)
91 | case .outflow:
92 | return AnyView(arrowUp)
93 | }
94 | }
95 | }
96 |
97 | // struct TransactionsView_Previews: PreviewProvider {
98 | // static var previews: some View {
99 | // TransactionsView()
100 | // }
101 | // }
102 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 |
86 | # Created by https://www.gitignore.io/api/macos
87 | # Edit at https://www.gitignore.io/?templates=macos
88 |
89 | ### macOS ###
90 | # General
91 | .DS_Store
92 | .AppleDouble
93 | .LSOverride
94 |
95 | # Icon must end with two \r
96 | Icon
97 |
98 | # Thumbnails
99 | ._*
100 |
101 | # Files that might appear in the root of a volume
102 | .DocumentRevisions-V100
103 | .fseventsd
104 | .Spotlight-V100
105 | .TemporaryItems
106 | .Trashes
107 | .VolumeIcon.icns
108 | .com.apple.timemachine.donotpresent
109 |
110 | # Directories potentially created on remote AFP share
111 | .AppleDB
112 | .AppleDesktop
113 | Network Trash Folder
114 | Temporary Items
115 | .apdisk
116 |
117 | # End of https://www.gitignore.io/api/macos
118 |
119 | # Code Injection
120 | #
121 | # After new code Injection tools there's a generated folder /iOSInjectionProject
122 | # https://github.com/johnno1962/injectionforxcode
123 |
124 | iOSInjectionProject
125 | /Aspire\ Budgeting/Resources/credentials.plist
126 | /Aspire\ Budgeting/Resources/instabug.plist
127 |
--------------------------------------------------------------------------------
/Aspire BudgetingTests/GoogleDriveManagerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GoogleDriveManagerTests.swift
3 | // Aspire BudgetingTests
4 | //
5 |
6 | import Combine
7 | import GoogleAPIClientForREST
8 | import GoogleSignIn
9 | import GTMSessionFetcher
10 | import XCTest
11 |
12 | @testable import Aspire_Budgeting
13 |
14 | final class GoogleDriveManagerTests: XCTestCase {
15 | lazy var mockGTLRFileList: GTLRDrive_FileList = {
16 | let file1 = createFile(name: "file1", identifier: "id1")
17 | let file2 = createFile(name: "file2", identifier: "id2")
18 |
19 | let list = GTLRDrive_FileList()
20 | list.files = [file1, file2]
21 |
22 | return list
23 | }()
24 |
25 | var mockFileList: [File] {
26 | mockGTLRFileList.files!.map { File(driveFile: $0) }
27 | }
28 |
29 | var mockQuery: GTLRDriveQuery_FilesList {
30 | GTLRDriveQuery_FilesList.query()
31 | }
32 |
33 | var mockAuthorizer: MockAuthorizer {
34 | MockAuthorizer()
35 | }
36 |
37 | var cancellables = Set()
38 |
39 | func createFile(name: String, identifier: String) -> GTLRDrive_File {
40 | let file = GTLRDrive_File()
41 | file.name = name
42 | file.identifier = identifier
43 | return file
44 | }
45 |
46 | func createMockGTLRService(with fakedObject: Any?, error: Error?) -> GTLRService {
47 | GTLRService.mockService(withFakedObject: fakedObject, fakedError: error)
48 | }
49 |
50 | func testGetFileListPublishesError() {
51 | let user = User(name: "dummy", authorizer: mockAuthorizer)
52 | let mockError = NSError(domain: "aspire_tests", code: 42, userInfo: nil)
53 | let mockDriveService = createMockGTLRService(with: nil, error: mockError)
54 | let driveManager = GoogleDriveManager(
55 | driveService: mockDriveService,
56 | googleFilesListQuery: mockQuery
57 | )
58 |
59 | let exp = XCTestExpectation()
60 | driveManager
61 | .getFileList(for: user)
62 | .sink { completion in
63 | switch completion {
64 | case let .failure(error):
65 | let nsError = error as NSError
66 | XCTAssertEqual(mockError, nsError)
67 | exp.fulfill()
68 | case .finished:
69 | XCTFail("Unexpected success")
70 | }
71 | } receiveValue: { _ in
72 |
73 | }
74 | .store(in: &cancellables)
75 |
76 | wait(for: [exp], timeout: 1)
77 | }
78 |
79 | func testGetFileListPublishesFiles() {
80 | let user = User(name: "dummy", authorizer: mockAuthorizer)
81 | let mockQuery = self.mockQuery
82 | let mockDriveService = createMockGTLRService(with: mockGTLRFileList, error: nil)
83 | let driveManager = GoogleDriveManager(
84 | driveService: mockDriveService,
85 | googleFilesListQuery: mockQuery
86 | )
87 |
88 | let exp = XCTestExpectation()
89 | driveManager
90 | .getFileList(for: user)
91 | .sink { completion in
92 | switch completion {
93 | case .failure:
94 | XCTFail("Unexpected Failure")
95 | case .finished:
96 | exp.fulfill()
97 | }
98 | } receiveValue: { files in
99 | XCTAssertEqual(files, self.mockFileList)
100 | XCTAssertFalse(mockQuery.isQueryInvalid)
101 | }
102 | .store(in: &cancellables)
103 |
104 | XCTAssertEqual(mockQuery.fields, GoogleDriveManager.queryFields)
105 | XCTAssertEqual(mockQuery.q, "mimeType='\(GoogleDriveManager.spreadsheetMIME)'")
106 | wait(for: [exp], timeout: 1)
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
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 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at mohit.athwani@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Facilities/GoogleUserManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserManager.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import Combine
7 | import Foundation
8 | import GoogleAPIClientForREST
9 | import GoogleSignIn
10 | import GTMSessionFetcher
11 |
12 | protocol IGIDSignIn: AnyObject {
13 | var clientID: String! { get set }
14 | var delegate: GIDSignInDelegate! { get set }
15 | var presentingViewController: UIViewController! { get set }
16 | var scopes: [Any]! { get set }
17 | func restorePreviousSignIn()
18 | func signOut()
19 | func signIn()
20 | }
21 |
22 | extension GIDSignIn: IGIDSignIn {}
23 |
24 | protocol AspireNotificationCenter: AnyObject {
25 | func post(
26 | name aName: NSNotification.Name,
27 | object anObject: Any?,
28 | userInfo aUserInfo: [AnyHashable: Any]?
29 | )
30 | }
31 |
32 | extension NotificationCenter: AspireNotificationCenter {}
33 |
34 | enum UserManagerState {
35 | case notAuthenticated
36 | case authenticated(User)
37 | case error(Error)
38 | }
39 |
40 | protocol UserManager {
41 | var userPublisher: AnyPublisher { get }
42 | func authenticate()
43 | }
44 |
45 | final class GoogleUserManager: NSObject, GIDSignInDelegate, UserManager {
46 | private let gidSignInInstance: IGIDSignIn
47 | private let credentials: GoogleSDKCredentials
48 |
49 | private let userSubject = PassthroughSubject()
50 | var userPublisher: AnyPublisher {
51 | userSubject
52 | .eraseToAnyPublisher()
53 | }
54 |
55 | init(
56 | credentials: GoogleSDKCredentials,
57 | gidSignInInstance: IGIDSignIn = GIDSignIn.sharedInstance()
58 | ) {
59 | self.credentials = credentials
60 | self.gidSignInInstance = gidSignInInstance
61 | }
62 |
63 | func authenticate() {
64 | Logger.info(
65 | "Attempting to authenticate with Google"
66 | )
67 | fetchUser()
68 | }
69 |
70 | private func fetchUser() {
71 | Logger.info(
72 | "Attempting to restore previous Google SignIn"
73 | )
74 | gidSignInInstance.clientID = credentials.CLIENT_ID
75 | gidSignInInstance.delegate = self
76 | gidSignInInstance.scopes = [kGTLRAuthScopeDrive, kGTLRAuthScopeSheetsDrive]
77 | gidSignInInstance.restorePreviousSignIn()
78 | }
79 |
80 | func sign(
81 | _ signIn: GIDSignIn!,
82 | didSignInFor user: GIDGoogleUser!,
83 | withError error: Error!
84 | ) {
85 | if let error = error {
86 | if (error as NSError).code == GIDSignInErrorCode.hasNoAuthInKeychain.rawValue {
87 | Logger.info(
88 | // swiftlint:disable line_length
89 | "The user has not signed in before or has since signed out. Proceed with normal sign in flow."
90 | // swiftlint:enable line_length
91 | )
92 | } else {
93 | Logger.error(
94 | "A generic error occured. %{public}s",
95 | context: error.localizedDescription
96 | )
97 | }
98 | return
99 | }
100 |
101 | self.signIn(user: user)
102 | }
103 |
104 | private func signIn(user gUser: GIDGoogleUser) {
105 | Logger.info(
106 | "User authenticated with Google successfully."
107 | )
108 |
109 | let user = User(
110 | name: gUser.profile.name,
111 | authorizer: gUser.authentication.fetcherAuthorizer()
112 | )
113 |
114 | userSubject.send(user)
115 | }
116 |
117 | func signOut() {
118 | gidSignInInstance.signOut()
119 | Logger.info(
120 | "Logging out user from Google and locally"
121 | )
122 | userSubject.send(nil)
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/Aspire Budgeting/Views/CategoryTransferView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryTransferView.swift
3 | // Aspire Budgeting
4 | //
5 |
6 | import SwiftUI
7 |
8 | struct CategoryTransferView: View {
9 | @ObservedObject var viewModel: CategoryTransferViewModel
10 |
11 | @State private var amountString = ""
12 | @State private var memoString = ""
13 | @State private var fromCategory = -1
14 | @State private var toCategory = -1
15 | @State private var showSuccessAlert = false
16 | @State private var showError = false
17 |
18 | let alertText = "Category Transfer submitted"
19 |
20 | var showAddButton: Bool {
21 | !amountString.isEmpty &&
22 | fromCategory != -1 &&
23 | toCategory != -1
24 | }
25 |
26 | var body: some View {
27 | Form {
28 | AspireTextField(
29 | text: $amountString,
30 | placeHolder: "Amount",
31 | keyboardType: .decimalPad,
32 | leftImage: Image.bankNote
33 | )
34 |
35 | AspireTextField(
36 | text: $memoString,
37 | placeHolder: "Memo",
38 | keyboardType: .default,
39 | leftImage: Image.scribble
40 | )
41 |
42 | if self.viewModel.categories != nil {
43 | Picker(
44 | selection: $fromCategory,
45 | label: HStack {
46 | Image.envelope
47 | .resizable()
48 | .scaledToFit()
49 | .frame(width: 30, height: 30, alignment: .center)
50 | Text("From Category")
51 | .font(.nunitoSemiBold(size: 20))
52 | }
53 | ) {
54 | ForEach(0.. AnyPublisher<[File], Error>
14 | }
15 |
16 | enum RemoteFileManagerState {
17 | case isLoading
18 | case error(error: Error)
19 | case filesRetrieved(files: [File])
20 | }
21 |
22 | enum GoogleDriveManagerError: String, Error {
23 | case inconsistentSheet = "Inconsistency found in the selected sheet."
24 | case noInternet = "No Internet connection available"
25 | }
26 |
27 | // TODO: Remove conformance to ObservableObject
28 | final class GoogleDriveManager: ObservableObject, RemoteFileManager {
29 | static let queryFields: String = "kind,nextPageToken,files(mimeType,id,kind,name)"
30 | static let spreadsheetMIME: String = "application/vnd.google-apps.spreadsheet"
31 |
32 | private let driveService: GTLRService
33 | private let googleFilesListQuery: GTLRDriveQuery_FilesList
34 |
35 | private var authorizer: GTMFetcherAuthorizationProtocol?
36 | private var authorizerNotificationObserver: NSObjectProtocol?
37 |
38 | private var ticket: GTLRServiceTicket?
39 |
40 | // TODO: Remove @Published properties
41 | @Published private(set) var fileList = [File]()
42 | @Published private(set) var error: Error?
43 |
44 | init(
45 | driveService: GTLRService = GTLRDriveService(),
46 | googleFilesListQuery: GTLRDriveQuery_FilesList = GTLRDriveQuery_FilesList.query()
47 | ) {
48 | self.driveService = driveService
49 | self.googleFilesListQuery = googleFilesListQuery
50 | }
51 |
52 | func getFileList(for user: User) -> AnyPublisher<[File], Error> {
53 | Deferred {
54 | Future { [weak self] promise in
55 | guard let self = self else { return }
56 | self.driveService.authorizer = user.authorizer
57 | self.driveService.shouldFetchNextPages = true
58 |
59 | self.googleFilesListQuery.fields = GoogleDriveManager.queryFields
60 | self.googleFilesListQuery.q = "mimeType='\(GoogleDriveManager.spreadsheetMIME)'"
61 | self.ticket =
62 | self.driveService
63 | .executeQuery(self.googleFilesListQuery) { [weak self] _, driveFileList, error in
64 | guard let self = self else {
65 | return
66 | }
67 | self.googleFilesListQuery.isQueryInvalid = false
68 |
69 | if let error = error {
70 | Logger.error(
71 | "Error while getting list of files from Google Drive.",
72 | context: error.localizedDescription
73 | )
74 | promise(.failure(error))
75 | } else {
76 | if let driveFileList = driveFileList as? GTLRDrive_FileList,
77 | let files = driveFileList.files {
78 | Logger.info(
79 | "File list retrieved. Converting to local model."
80 | )
81 | promise(.success(files.map(File.init)))
82 | }
83 | }
84 | }
85 | }
86 | }.eraseToAnyPublisher()
87 | }
88 | }
89 |
90 | final class PreviewFileManager: RemoteFileManager {
91 | let files: [File]?
92 | let error: Error?
93 |
94 | internal init(files: [File]?, error: Error?) {
95 | self.files = files
96 | self.error = error
97 | }
98 |
99 | func getFileList(for user: User) -> AnyPublisher<[File], Error> {
100 | if let error = error {
101 | return Fail(error: error).eraseToAnyPublisher()
102 | }
103 |
104 | if let files = files {
105 | return Just(files).setFailureType(to: Error.self).eraseToAnyPublisher()
106 | }
107 |
108 | fatalError("One or the other param must be set")
109 | }
110 | }
111 |
--------------------------------------------------------------------------------