├── 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 | ![Aspire Budgeting](https://github.com/aspirebudgetingmobile/aspirebudgeting_ios/workflows/Aspire%20Budgeting%20Build%20and%20Test/badge.svg?branch=master) [![codecov](https://codecov.io/gh/aspirebudgetingmobile/aspirebudgeting_ios/branch/master/graph/badge.svg)](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 | --------------------------------------------------------------------------------