├── .github └── workflows │ └── run_test.yml ├── .gitignore ├── .swift-version ├── .swiftformat ├── .swiftlint.yml ├── BaseSwiftUI ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ └── icon.png │ ├── Contents.json │ ├── colors │ │ ├── Contents.json │ │ ├── background │ │ │ ├── Contents.json │ │ │ ├── backgroundPrimary.colorset │ │ │ │ └── Contents.json │ │ │ └── todoCardBackground.colorset │ │ │ │ └── Contents.json │ │ ├── button │ │ │ ├── Contents.json │ │ │ └── backButtonPrimary.colorset │ │ │ │ └── Contents.json │ │ ├── label │ │ │ ├── Contents.json │ │ │ └── labelPrimary.colorset │ │ │ │ └── Contents.json │ │ ├── orangeFlush.colorset │ │ │ └── Contents.json │ │ ├── primary.colorset │ │ │ └── Contents.json │ │ └── separator │ │ │ ├── Contents.json │ │ │ └── separatorPrimary.colorset │ │ │ └── Contents.json │ └── images │ │ ├── Contents.json │ │ ├── icon_back.imageset │ │ ├── Contents.json │ │ └── icon_back.svg │ │ ├── icon_date.imageset │ │ ├── Contents.json │ │ └── icon_date.svg │ │ ├── icon_email.imageset │ │ ├── Contents.json │ │ └── icon_email.svg │ │ ├── icon_password.imageset │ │ ├── Contents.json │ │ └── icon_password.svg │ │ ├── icon_rating.imageset │ │ ├── Contents.json │ │ └── icon_rating.svg │ │ ├── onboarding │ │ ├── Contents.json │ │ ├── onboarding_page1.imageset │ │ │ ├── Contents.json │ │ │ └── onboarding_page1.svg │ │ ├── onboarding_page2.imageset │ │ │ ├── Contents.json │ │ │ └── onboarding_page2.svg │ │ └── onboarding_page3.imageset │ │ │ ├── Contents.json │ │ │ └── onboarding_page3.svg │ │ ├── splash.imageset │ │ ├── Contents.json │ │ └── splash.jpg │ │ ├── task │ │ ├── Contents.json │ │ ├── task_all.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_cooking.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_empty.imageset │ │ │ ├── Contents.json │ │ │ └── undraw_completed_tasks_vs6q.png │ │ ├── task_finance.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_gift.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_health.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_home.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_ideas.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_music.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_others.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_payment.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_shopping.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_study.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ ├── task_travel.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ └── task_work.imageset │ │ │ ├── Contents.json │ │ │ └── task_all.svg │ │ └── todos_empty.imageset │ │ ├── Contents.json │ │ └── todos_empty.png ├── BaseSwiftUIApp.swift ├── Config │ ├── AppConfig.swift │ ├── Appearance.swift │ └── xcconfigs │ │ ├── dev.xcconfig │ │ ├── production.xcconfig │ │ └── staging.xcconfig ├── Data │ ├── DI │ │ └── Data+Injection.swift │ ├── DataSource │ │ ├── API │ │ │ ├── API+Movie.swift │ │ │ ├── APIInput.swift │ │ │ ├── APIOutput.swift │ │ │ ├── APIService.swift │ │ │ ├── APIUrls.swift │ │ │ └── Base │ │ │ │ ├── APIErrorBase.swift │ │ │ │ ├── APIInputBase.swift │ │ │ │ ├── APIResponse.swift │ │ │ │ ├── APIServiceBase.swift │ │ │ │ ├── CacheManager.swift │ │ │ │ ├── LogOptions.swift │ │ │ │ └── String+Base64.swift │ │ └── Storage │ │ │ └── UserDefaults.swift │ └── RepositoryImpl │ │ ├── AuthRepositoryImpl.swift │ │ ├── MovieRepositoryImpl.swift │ │ ├── SettingsRepositoryImpl.swift │ │ └── TodosRepositoryImpl.swift ├── Domain │ ├── DI │ │ └── Domain+Injection.swift │ ├── Entity │ │ ├── Movie.swift │ │ ├── TodoCategory.swift │ │ ├── TodoItem.swift │ │ └── TodoList.swift │ ├── Repository │ │ ├── AuthRepository.swift │ │ ├── MovieRepository.swift │ │ ├── SettingsRepository.swift │ │ └── TodosRepository.swift │ ├── UseCase │ │ ├── AuthUseCase.swift │ │ ├── MovieUseCase.swift │ │ ├── SettingsUseCase.swift │ │ └── TodosUseCase.swift │ └── Validation │ │ ├── Base │ │ ├── ValidatedProperty.swift │ │ ├── Validation+Collection.swift │ │ ├── Validation+Comparable.swift │ │ └── Validation+String.swift │ │ ├── Validation+.swift │ │ ├── ValidationResult.swift │ │ └── Validator.swift ├── GoogleService-Info.plist ├── Info.plist ├── Presentation │ ├── Component │ │ ├── BaseButton.swift │ │ ├── BaseTextField.swift │ │ ├── Loading │ │ │ ├── ActivityIndicator.swift │ │ │ └── LoadingView.swift │ │ ├── Movie │ │ │ ├── HorizontalMovieCard.swift │ │ │ ├── Section.swift │ │ │ └── VerticalMovieCard.swift │ │ ├── Screen.swift │ │ ├── SettingItem.swift │ │ ├── Style │ │ │ └── Spacing.swift │ │ ├── Todo │ │ │ └── TodoListItem.swift │ │ └── VScroll.swift │ ├── MainApp.swift │ ├── Navigator │ │ ├── Auth │ │ │ ├── AuthRouteBuilder.swift │ │ │ └── AuthRouteGroup.swift │ │ ├── Home │ │ │ ├── HomeRouteBuilder.swift │ │ │ └── HomeRouteGroup.swift │ │ ├── LinkNavigator+.swift │ │ ├── NavigatorDependency.swift │ │ └── RoutePath.swift │ ├── Scene │ │ ├── Auth │ │ │ ├── Login │ │ │ │ ├── LoginNavigator.swift │ │ │ │ ├── LoginScreen.swift │ │ │ │ └── LoginViewModel.swift │ │ │ ├── Onboarding │ │ │ │ ├── OnboardingNavigator.swift │ │ │ │ ├── OnboardingScreen.swift │ │ │ │ └── OnboardingViewModel.swift │ │ │ └── Register │ │ │ │ ├── RegisterNavigator.swift │ │ │ │ ├── RegisterScreen.swift │ │ │ │ └── RegisterViewModel.swift │ │ └── Home │ │ │ ├── Movie │ │ │ ├── MovieDetail │ │ │ │ ├── MovieDetailNavigator.swift │ │ │ │ ├── MovieDetailScreen.swift │ │ │ │ └── MovieDetailViewModel.swift │ │ │ └── TopMovies │ │ │ │ ├── TopMoviesNavigator.swift │ │ │ │ ├── TopMoviesScreen.swift │ │ │ │ └── TopMoviesViewModel.swift │ │ │ ├── Settings │ │ │ ├── SettingsNavigator.swift │ │ │ ├── SettingsScreen.swift │ │ │ └── SettingsViewModel.swift │ │ │ └── Todo │ │ │ ├── ListTodo │ │ │ ├── ListTodoNavigator.swift │ │ │ ├── ListTodoScreen.swift │ │ │ └── ListTodoViewModel.swift │ │ │ ├── NewTodo │ │ │ ├── NewTodoNavigator.swift │ │ │ ├── NewTodoScreen.swift │ │ │ └── NewTodoViewModel.swift │ │ │ └── Todos │ │ │ ├── TodosNavigator.swift │ │ │ ├── TodosScreen.swift │ │ │ └── TodosViewModel.swift │ └── Type │ │ ├── Language.swift │ │ ├── Onboarding.swift │ │ └── TabbarItemType.swift └── Utils │ ├── Architecture │ ├── ActivityTracker.swift │ ├── ErrorTracker.swift │ └── ViewModel.swift │ ├── Extension │ ├── CancelBag.swift │ ├── Combine+Rx.swift │ ├── Language+.swift │ ├── Publishers+Unwrap.swift │ └── String+.swift │ ├── Resources │ ├── en.lproj │ │ └── Localizable.strings │ └── ja.lproj │ │ └── Localizable.strings │ └── Utils.swift ├── BaseSwiftUITests ├── Info.plist ├── Mock │ ├── DI.swift │ └── UseCase │ │ ├── AuthUseCaseMock.swift │ │ └── SettingsUseCaseMock.swift ├── Scene │ ├── Login │ │ ├── LoginNavigatorMock.swift │ │ └── LoginViewModelTests.swift │ ├── Register │ │ ├── RegisterNavigatorMock.swift │ │ └── RegisterViewModelTests.swift │ └── Settings │ │ ├── SettingsNavigatorMock.swift │ │ └── SettingsViewModelTests.swift └── Utils │ ├── Extension │ └── XCTestCase+.swift │ └── TestError.swift ├── Gemfile ├── Gemfile.lock ├── README.md ├── fastlane ├── Appfile ├── Fastfile ├── README.md ├── report.xml └── test_output │ ├── report.html │ └── report.junit ├── project.yml └── screenshots ├── add_todo.png ├── login.png ├── movie_detail.png ├── onboarding.png ├── register.png ├── settings.png ├── todo_list.png └── top_movie.png /.github/workflows/run_test.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | run-name: ${{ github.actor }} is running unit tests 3 | 4 | on: 5 | push: 6 | branches: 7 | - develop 8 | pull_request: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | build-and-test: 14 | name: Build and test 15 | runs-on: macos-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | - name: Select Xcode version 20 | run: sudo xcode-select -s '/Applications/Xcode_15.0.app/Contents/Developer' 21 | - name: Setup 22 | run: | 23 | brew install xcodegen 24 | bundle install 25 | xcodegen 26 | - name: Test 27 | run: | 28 | bundle exec fastlane test_dev 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | Pods/ 50 | # 51 | # Xcode project 52 | BaseSwiftUI.xcodeproj 53 | # Add this line if you want to avoid checking in source code from the Xcode workspace 54 | *.xcworkspace 55 | 56 | # Carthage 57 | # 58 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 59 | # Carthage/Checkouts 60 | 61 | Carthage/Build 62 | 63 | # fastlane 64 | # 65 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 66 | # screenshots whenever they are needed. 67 | # For more information about the recommended setup visit: 68 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 69 | 70 | .bundle/ 71 | 72 | .scannerwork 73 | sonar-reports 74 | DerivedData 75 | 76 | *.DS_Store 77 | 78 | html/ 79 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.10 -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # file options 2 | 3 | --exclude Generated, **/*.generated.swift 4 | 5 | --disable all 6 | 7 | # rules 8 | --enable duplicateImports 9 | --enable andOperator 10 | --enable anyObjectProtocol 11 | --enable blankLineAfterImports 12 | --enable blankLinesAtEndOfScope 13 | --enable blankLinesAtStartOfScope 14 | --enable blankLinesBetweenImports 15 | --enable blankLinesBetweenScopes 16 | --enable blockComments 17 | --enable consecutiveBlankLines 18 | --enable consecutiveSpaces 19 | --enable docComments 20 | --enable duplicateImports 21 | --enable emptyBraces 22 | --enable elseOnSameLine 23 | --enable indent 24 | --enable redundantSelf 25 | --enable trailingSpace 26 | --enable semicolons 27 | --enable typeSugar 28 | --enable trailingClosures 29 | --enable unusedArguments 30 | --enable wrapAttributes 31 | --enable wrapConditionalBodies 32 | --enable spaceAroundParens 33 | --enable spaceInsideBraces 34 | --enable spaceInsideBrackets 35 | --enable spaceInsideComments 36 | --enable spaceInsideGenerics 37 | --enable spaceInsideParens 38 | --enable spaceInsideParens 39 | --enable sortDeclarations 40 | 41 | # options 42 | --indent 4 43 | --trimwhitespace always 44 | --semicolons never 45 | --shortoptionals always 46 | --trailingclosures 47 | --stripunusedargs always 48 | --funcattributes prev-line 49 | --importgrouping alpha -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/AppIcon.appiconset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/BaseSwiftUI/Assets.xcassets/AppIcon.appiconset/icon.png -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/background/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/background/backgroundPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.961", 9 | "green" : "0.961", 10 | "red" : "0.961" 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.196", 27 | "green" : "0.165", 28 | "red" : "0.141" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/background/todoCardBackground.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.254", 27 | "green" : "0.211", 28 | "red" : "0.181" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/button/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/button/backButtonPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.057", 9 | "green" : "0.057", 10 | "red" : "0.056" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.994", 27 | "green" : "0.994", 28 | "red" : "0.982" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/label/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/label/labelPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.088", 9 | "green" : "0.088", 10 | "red" : "0.088" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.984", 27 | "green" : "0.984", 28 | "red" : "0.973" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/orangeFlush.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.000", 9 | "green" : "0.529", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/primary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.667", 9 | "green" : "0.408", 10 | "red" : "0.408" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.754", 27 | "green" : "0.468", 28 | "red" : "0.469" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/separator/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/colors/separator/separatorPrimary.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.841", 9 | "green" : "0.841", 10 | "red" : "0.831" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0.900", 27 | "green" : "0.900", 28 | "red" : "0.890" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_back.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_back.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_back.imageset/icon_back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_date.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_date.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_date.imageset/icon_date.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_email.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_email.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_email.imageset/icon_email.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_password.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_password.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_password.imageset/icon_password.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_rating.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_rating.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/icon_rating.imageset/icon_rating.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/onboarding/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/onboarding/onboarding_page1.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboarding_page1.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/onboarding/onboarding_page2.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboarding_page2.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/onboarding/onboarding_page3.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "onboarding_page3.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/splash.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "splash.jpg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "original" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/splash.imageset/splash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/BaseSwiftUI/Assets.xcassets/images/splash.imageset/splash.jpg -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_all.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_all.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_cooking.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_cooking.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_empty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "undraw_completed_tasks_vs6q.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_empty.imageset/undraw_completed_tasks_vs6q.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/BaseSwiftUI/Assets.xcassets/images/task/task_empty.imageset/undraw_completed_tasks_vs6q.png -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_finance.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_finance.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_gift.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_gift.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_health.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_health.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_home.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_home.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_ideas.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_ideas.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_music.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_music.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_others.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_others.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_payment.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_payment.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_shopping.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_shopping.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_study.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_study.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_travel.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_travel.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_work.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "task_all.svg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/task/task_work.imageset/task_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/todos_empty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "todos_empty.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 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 | -------------------------------------------------------------------------------- /BaseSwiftUI/Assets.xcassets/images/todos_empty.imageset/todos_empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/BaseSwiftUI/Assets.xcassets/images/todos_empty.imageset/todos_empty.png -------------------------------------------------------------------------------- /BaseSwiftUI/BaseSwiftUIApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseSwiftUIApp.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 15/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Firebase 10 | 11 | @main 12 | struct BaseSwiftUIApp: App { 13 | init() { 14 | FirebaseApp.configure() 15 | Appearance.configure() 16 | } 17 | 18 | var body: some Scene { 19 | MainApp() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BaseSwiftUI/Config/AppConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppConfig.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | enum AppConfig { 11 | enum API { 12 | static var endPoint: String { 13 | return infoForKey("API_ENDPOINT") 14 | } 15 | 16 | static var version: String { 17 | return infoForKey("API_VERSION") 18 | } 19 | } 20 | 21 | enum MovieDB { 22 | static var apiKey: String { 23 | return "000be3fbe452a9afe32f314596801204" 24 | } 25 | 26 | static var imageUrl: String { 27 | return "https://image.tmdb.org/t/p/w500" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /BaseSwiftUI/Config/Appearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Appearance.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Then 9 | import UIKit 10 | 11 | struct Appearance { 12 | static func configure() { 13 | configNavigation() 14 | configTabbar() 15 | } 16 | 17 | private static func configNavigation() { 18 | let navigationBar = UINavigationBar.appearance() 19 | let backImage = R.image.icon_back() 20 | 21 | navigationBar.do { 22 | $0.isTranslucent = false 23 | $0.tintColor = R.color.backButtonPrimary() 24 | } 25 | 26 | let appearance = UINavigationBarAppearance().with { 27 | $0.configureWithOpaqueBackground() 28 | $0.backgroundColor = R.color.backgroundPrimary() 29 | $0.shadowColor = .clear 30 | $0.shadowImage = UIImage() 31 | $0.titleTextAttributes = [.foregroundColor: R.color.primary() ?? .black] 32 | $0.setBackIndicatorImage(backImage, transitionMaskImage: backImage) 33 | // MARK: - clear back title 34 | $0.backButtonAppearance.focused.titleTextAttributes = [.foregroundColor: UIColor.clear] 35 | $0.backButtonAppearance.disabled.titleTextAttributes = [.foregroundColor: UIColor.clear] 36 | $0.backButtonAppearance.highlighted.titleTextAttributes = [.foregroundColor: UIColor.clear] 37 | $0.backButtonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.clear] 38 | } 39 | navigationBar.do { 40 | $0.standardAppearance = appearance 41 | $0.scrollEdgeAppearance = appearance 42 | } 43 | } 44 | 45 | private static func configTabbar() { 46 | let tabBar = UITabBar.appearance() 47 | tabBar.do { 48 | $0.tintColor = R.color.primary() 49 | $0.barTintColor = .black 50 | $0.unselectedItemTintColor = .gray 51 | $0.isTranslucent = false 52 | } 53 | let appearance = UITabBarAppearance() 54 | appearance.configureWithOpaqueBackground() 55 | appearance.backgroundColor = R.color.backgroundPrimary() 56 | tabBar.standardAppearance = appearance 57 | tabBar.scrollEdgeAppearance = tabBar.standardAppearance 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /BaseSwiftUI/Config/xcconfigs/dev.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // dev.xcconfig 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 15/04/2024. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | APP_NAME = [DEV]SwiftUI 12 | PRODUCT_BUNDLE_IDENTIFIER = ngocpd.SwiftUIBase.dev 13 | APP_VERSION = 1.0.0 14 | APP_BUILD_VERSION = 1 15 | 16 | // API: 17 | API_ENDPOINT = https:\/\/api.themoviedb.org 18 | API_VERSION = 3 19 | -------------------------------------------------------------------------------- /BaseSwiftUI/Config/xcconfigs/production.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // production.xcconfig 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 15/04/2024. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | APP_NAME = [PROD]SwiftUI 12 | PRODUCT_BUNDLE_IDENTIFIER = ngocpd.SwiftUIBase.production 13 | APP_VERSION = 0.0.9 14 | APP_BUILD_VERSION = 1 15 | 16 | // API: 17 | API_ENDPOINT = https:\/\/api.themoviedb.org 18 | API_VERSION = 3 19 | -------------------------------------------------------------------------------- /BaseSwiftUI/Config/xcconfigs/staging.xcconfig: -------------------------------------------------------------------------------- 1 | // 2 | // staging.xcconfig 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 15/04/2024. 6 | // 7 | 8 | // Configuration settings file format documentation can be found at: 9 | // https://help.apple.com/xcode/#/dev745c5c974 10 | 11 | APP_NAME = [STG]SwiftUI 12 | PRODUCT_BUNDLE_IDENTIFIER = ngocpd.SwiftUIBase.staging 13 | APP_VERSION = 0.0.9 14 | APP_BUILD_VERSION = 1 15 | 16 | // API: 17 | API_ENDPOINT = https:\/\/api.themoviedb.org 18 | API_VERSION = 3 19 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DI/Data+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Data+Injection.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Factory 9 | import FirebaseAuth 10 | import SwiftData 11 | 12 | extension Container { 13 | var auth: Factory { 14 | Factory(self) { Auth.auth() } 15 | } 16 | 17 | var authRepository: Factory { 18 | Factory(self) { AuthRepositoryImpl() } 19 | } 20 | 21 | var movieRepository: Factory { 22 | Factory(self) { MovieRepositoryImpl() } 23 | } 24 | 25 | var settingsRepository: Factory { 26 | Factory(self) { SettingsRepositoryImpl() } 27 | } 28 | 29 | var todosRepository: Factory { 30 | Factory(self) { TodosRepositoryImpl() } 31 | } 32 | 33 | @MainActor 34 | var modelContext: Factory { 35 | Factory(self) { 36 | return (try? ModelContainer(for: TodoItem.self))?.mainContext 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/API+Movie.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API+Movie.swift 3 | // clean_architechture 4 | // 5 | // Created by phan.duong.ngoc on 2023/03/10. 6 | // 7 | 8 | import Foundation 9 | import Alamofire 10 | 11 | extension API { 12 | func getTopRatedMovies(_ input: GetTopRatedMoviesInput) -> Observable { 13 | return request(input) 14 | } 15 | 16 | func getUpcomingMovies(_ input: GetUpcomingMoviesInput) -> Observable { 17 | return request(input) 18 | } 19 | 20 | func getMovieDetail(_ input: GetMovieDetailInput) -> Observable { 21 | return request(input) 22 | } 23 | } 24 | 25 | extension API { 26 | final class GetTopRatedMoviesInput: APIInput { 27 | init() { 28 | super.init(urlString: API.Urls.Movie.topRated, 29 | parameters: nil, 30 | method: .get, 31 | requireAccessToken: false) 32 | } 33 | } 34 | 35 | final class GetTopRatedMoviesOutput: APIOutput { 36 | private(set) var movies: [Movie] = [] 37 | 38 | private enum CodingKeys: String, CodingKey { 39 | case movies = "results" 40 | } 41 | } 42 | } 43 | 44 | extension API { 45 | final class GetUpcomingMoviesInput: APIInput { 46 | init() { 47 | super.init(urlString: API.Urls.Movie.upcoming, 48 | parameters: nil, 49 | method: .get, 50 | requireAccessToken: false) 51 | } 52 | } 53 | 54 | final class GetUpcomingMoviesOutput: APIOutput { 55 | private(set) var movies: [Movie] = [] 56 | 57 | private enum CodingKeys: String, CodingKey { 58 | case movies = "results" 59 | } 60 | } 61 | } 62 | 63 | extension API { 64 | final class GetMovieDetailInput: APIInput { 65 | init(id: Int) { 66 | super.init(urlString: String(format: API.Urls.Movie.movieDetail, id), 67 | parameters: nil, 68 | method: .get, 69 | requireAccessToken: false) 70 | } 71 | } 72 | 73 | final class GetMovieDetailOutput: APIOutput { 74 | private(set) var movie: Movie? 75 | 76 | required init(from decoder: Decoder) throws { 77 | let container = try decoder.singleValueContainer() 78 | movie = try container.decode(Movie.self) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/APIInput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIInput.swift 3 | // clean_architechture 4 | // 5 | // Created by phan.duong.ngoc on 2023/03/10. 6 | // 7 | 8 | import Alamofire 9 | 10 | class APIInput: APIInputBase { 11 | override init(urlString: String, 12 | parameters: [String: Any]?, 13 | method: HTTPMethod, 14 | requireAccessToken: Bool) { 15 | super.init(urlString: urlString, 16 | parameters: parameters, 17 | method: method, 18 | requireAccessToken: requireAccessToken) 19 | self.urlString.append("?api_key=\(AppConfig.MovieDB.apiKey)") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/APIOutput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIOutput.swift 3 | // clean_architechture 4 | // 5 | // Created by phan.duong.ngoc on 2023/03/10. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias APIOutput = Decodable 11 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/APIService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIService.swift 3 | // clean_architechture 4 | // 5 | // Created by phan.duong.ngoc on 2023/03/10. 6 | // 7 | 8 | import Foundation 9 | 10 | final class API: APIBase { 11 | static var shared = API() 12 | } 13 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/APIUrls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIUrls.swift 3 | // clean_architechture 4 | // 5 | // Created by phan.duong.ngoc on 2023/03/10. 6 | // 7 | 8 | import Foundation 9 | 10 | extension API { 11 | enum Urls { 12 | static var host = AppConfig.API.endPoint 13 | static var version = AppConfig.API.version 14 | static var baseUrl: String { return host / version } 15 | } 16 | } 17 | 18 | extension API.Urls { 19 | enum Movie { 20 | static var topRated: String { return baseUrl / "movie" / "top_rated" } 21 | static var upcoming: String { return baseUrl / "movie" / "upcoming" } 22 | static var movieDetail: String { return baseUrl / "movie" / "%d" } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/Base/APIErrorBase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Tuan Truong on 7/30/20. 3 | // Copyright © 2020 Tuan Truong. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol APIError: LocalizedError { 9 | var statusCode: Int? { get } 10 | } 11 | 12 | public extension APIError { // swiftlint:disable:this no_extension_access_modifier 13 | var statusCode: Int? { return nil } 14 | } 15 | 16 | public struct APIInvalidResponseError: APIError { 17 | public init() {} 18 | 19 | public var errorDescription: String? { 20 | return NSLocalizedString("api.invalidResponseError", 21 | value: "Invalid server response", 22 | comment: "") 23 | } 24 | } 25 | 26 | public struct APIUnknownError: APIError { 27 | public let statusCode: Int? 28 | 29 | public init(statusCode: Int?) { 30 | self.statusCode = statusCode 31 | } 32 | 33 | public var errorDescription: String? { 34 | return NSLocalizedString("api.unknownError", 35 | value: "Unknown API error", 36 | comment: "") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/Base/APIInputBase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Tuan Truong on 7/30/20. 3 | // Copyright © 2020 Tuan Truong. All rights reserved. 4 | // 5 | 6 | import Alamofire 7 | import Foundation 8 | 9 | open class APIInputBase { 10 | public var headers: HTTPHeaders? 11 | public var urlString: String 12 | public var method: HTTPMethod 13 | public var encoding: ParameterEncoding 14 | public var parameters: Parameters? 15 | public var requireAccessToken: Bool 16 | public var accessToken: String? 17 | 18 | public var usingCache = false { 19 | didSet { 20 | if method != .get || self is APIUploadInputBase { 21 | fatalError() // swiftlint:disable:this fatal_error_message 22 | } 23 | } 24 | } 25 | 26 | public var username: String? 27 | public var password: String? 28 | 29 | public init(urlString: String, 30 | parameters: Parameters?, 31 | method: HTTPMethod, 32 | requireAccessToken: Bool) { 33 | self.urlString = urlString 34 | self.parameters = parameters 35 | self.method = method 36 | encoding = method == .get ? URLEncoding.default : JSONEncoding.default 37 | self.requireAccessToken = requireAccessToken 38 | } 39 | } 40 | 41 | extension APIInputBase { 42 | var urlEncodingString: String { 43 | guard 44 | let url = URL(string: urlString), 45 | var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), 46 | let parameters = parameters, 47 | method == .get 48 | else { 49 | return urlString 50 | } 51 | 52 | urlComponents.queryItems = [] 53 | 54 | for name in parameters.keys.sorted() { 55 | if let value = parameters[name] { 56 | let item = URLQueryItem( 57 | name: "\(name)", 58 | value: "\(value)" 59 | ) 60 | 61 | urlComponents.queryItems?.append(item) 62 | } 63 | } 64 | 65 | return urlComponents.url?.absoluteString ?? urlString 66 | } 67 | 68 | func description(isIncludedParameters: Bool) -> String { 69 | if method == .get || !isIncludedParameters { 70 | return "🌎 \(method.rawValue) \(urlEncodingString)" 71 | } 72 | 73 | return [ 74 | "🌎 \(method.rawValue) \(urlString)", 75 | "Parameters: \(String(describing: parameters ?? JSONDictionary()))" 76 | ] 77 | .joined(separator: "\n") 78 | } 79 | } 80 | 81 | public struct APIUploadData { 82 | public let data: Data 83 | public let name: String 84 | public let fileName: String 85 | public let mimeType: String 86 | 87 | public init(data: Data, name: String, fileName: String, mimeType: String) { 88 | self.data = data 89 | self.name = name 90 | self.fileName = fileName 91 | self.mimeType = mimeType 92 | } 93 | } 94 | 95 | open class APIUploadInputBase: APIInputBase { 96 | public let data: [APIUploadData] 97 | 98 | public init(data: [APIUploadData], 99 | urlString: String, 100 | parameters: [String: Encodable]?, 101 | method: HTTPMethod, 102 | requireAccessToken: Bool) { 103 | self.data = data 104 | 105 | super.init( 106 | urlString: urlString, 107 | parameters: parameters, 108 | method: method, 109 | requireAccessToken: requireAccessToken 110 | ) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/Base/APIResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Tuan Truong on 7/30/20. 3 | // Copyright © 2020 Tuan Truong. All rights reserved. 4 | // 5 | 6 | public struct APIResponse { 7 | public var header: ResponseHeader? 8 | public var data: T 9 | 10 | public init(header: ResponseHeader?, data: T) { 11 | self.header = header 12 | self.data = data 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/Base/CacheManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Tuan Truong on 7/30/20. 3 | // Copyright © 2020 Tuan Truong. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public enum CacheManagerError: Error { 9 | case invalidFileName(urlString: String) 10 | case invalidFileData 11 | } 12 | 13 | open class CacheManager { 14 | public static var sharedInstance = CacheManager() 15 | 16 | public var fileExtension = "cache" 17 | private let dataKey = "__DATA__" 18 | private let headerKey = "__HEADER__" 19 | 20 | public func fileURL(fileName: String) -> URL? { 21 | return try? FileManager.default 22 | .url(for: .cachesDirectory, 23 | in: .userDomainMask, 24 | appropriateFor: nil, 25 | create: false) 26 | .appendingPathComponent(fileName) 27 | .appendingPathExtension(fileExtension) 28 | } 29 | 30 | public func encodedFileName(urlString: String) -> String? { 31 | return urlString 32 | .toBase64() 33 | .addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) 34 | } 35 | 36 | public func read(urlString: String) throws -> Any { 37 | if let fileName = encodedFileName(urlString: urlString), 38 | let url = fileURL(fileName: fileName) { 39 | if let data = try? Data(contentsOf: url), 40 | let dictionary = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSDictionary.self, from: data) { 41 | return dictionary 42 | } else { 43 | throw CacheManagerError.invalidFileData 44 | } 45 | } else { 46 | throw CacheManagerError.invalidFileName(urlString: urlString) 47 | } 48 | } 49 | 50 | public func read(urlString: String) throws -> (Any, ResponseHeader?) { 51 | if let fileName = encodedFileName(urlString: urlString), 52 | let url = fileURL(fileName: fileName) { 53 | if let data = try? Data(contentsOf: url), 54 | let dictionary = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSDictionary.self, from: data) { 55 | return (dictionary[dataKey] as Any, dictionary[headerKey] as? ResponseHeader) 56 | } else { 57 | throw CacheManagerError.invalidFileData 58 | } 59 | } else { 60 | throw CacheManagerError.invalidFileName(urlString: urlString) 61 | } 62 | } 63 | 64 | public func write(urlString: String, data: Any, header: ResponseHeader? = nil) throws { 65 | guard let fileName = encodedFileName(urlString: urlString), 66 | let fileURL = fileURL(fileName: fileName) else { 67 | throw CacheManagerError.invalidFileName(urlString: urlString) 68 | } 69 | 70 | let dataToWrite: Data 71 | 72 | if let header = header { 73 | let dataAndHeader: [String: Any] = [ 74 | dataKey: data, 75 | headerKey: header 76 | ] 77 | dataToWrite = try NSKeyedArchiver.archivedData(withRootObject: dataAndHeader, requiringSecureCoding: false) 78 | } else { 79 | dataToWrite = try NSKeyedArchiver.archivedData(withRootObject: data, requiringSecureCoding: false) 80 | } 81 | 82 | try dataToWrite.write(to: fileURL, options: .atomic) 83 | } 84 | 85 | public func clear() { 86 | let paths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true) 87 | guard let folderPath = paths.first else { 88 | return 89 | } 90 | 91 | let fileManager = FileManager.default 92 | let enumerator = fileManager.enumerator(atPath: folderPath) 93 | 94 | while let element = enumerator?.nextObject() as? String { 95 | let url = URL(fileURLWithPath: folderPath).appendingPathComponent(element) 96 | if url.pathExtension == fileExtension { 97 | try? fileManager.removeItem(at: url) 98 | } 99 | } 100 | } 101 | 102 | public func removeCache(for urlString: String) { 103 | guard let fileName = encodedFileName(urlString: urlString), 104 | let fileURL = fileURL(fileName: fileName) else { 105 | return 106 | } 107 | 108 | let fileManager = FileManager.default 109 | try? fileManager.removeItem(at: fileURL) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/Base/LogOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Tuan Truong on 7/30/20. 3 | // Copyright © 2020 Tuan Truong. All rights reserved. 4 | // 5 | 6 | public struct LogOptions: OptionSet { 7 | public let rawValue: Int 8 | 9 | public static let request = LogOptions(rawValue: 1 << 0) 10 | public static let requestParameters = LogOptions(rawValue: 1 << 1) 11 | public static let rawRequest = LogOptions(rawValue: 1 << 2) 12 | public static let dataResponse = LogOptions(rawValue: 1 << 3) 13 | public static let responseStatus = LogOptions(rawValue: 1 << 4) 14 | public static let responseData = LogOptions(rawValue: 1 << 5) 15 | public static let error = LogOptions(rawValue: 1 << 6) 16 | public static let cache = LogOptions(rawValue: 1 << 7) 17 | 18 | public static let `default`: [LogOptions] = [ 19 | .request, 20 | .requestParameters, 21 | .responseStatus, 22 | .error 23 | ] 24 | 25 | public static let none: [LogOptions] = [] 26 | 27 | public static let all: [LogOptions] = [ 28 | .request, 29 | .requestParameters, 30 | .rawRequest, 31 | .dataResponse, 32 | .responseStatus, 33 | .responseData, 34 | .error, 35 | .cache 36 | ] 37 | 38 | public init(rawValue: Int) { 39 | self.rawValue = rawValue 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/API/Base/String+Base64.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Tuan Truong on 7/30/20. 3 | // Copyright © 2020 Tuan Truong. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension String { 9 | /// Encode a String to Base64 10 | func toBase64() -> String { 11 | return Data(utf8).base64EncodedString() 12 | } 13 | 14 | /// Decode a String from Base64. Returns nil if unsuccessful. 15 | func fromBase64() -> String? { 16 | guard let data = Data(base64Encoded: self) else { 17 | return nil 18 | } 19 | return String(data: data, encoding: .utf8) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/DataSource/Storage/UserDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 17/04/2024. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | 11 | extension Defaults.Keys { 12 | static let isOnboardingCompleted = Key("isOnboardingCompleted", default: false) 13 | static let isLoggedIn = Key("isLoggedIn", default: false) 14 | static let isDarkMode = Key("isDarkMode", default: true) 15 | static let language = Key("language", default: "en") 16 | } 17 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/RepositoryImpl/AuthRepositoryImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRepositoryImpl.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | import Defaults 12 | 13 | struct AuthRepositoryImpl: AuthRepository { 14 | @Injected(\.auth) private var auth 15 | 16 | func updateOnboardingStatus(isDone: Bool) { 17 | Defaults[.isOnboardingCompleted] = isDone 18 | } 19 | 20 | func validateEmail(email: String) -> ValidationResult { 21 | return Validator.validateEmail(email).mapToVoid() 22 | } 23 | 24 | func validatePassword(password: String) -> ValidationResult { 25 | return Validator.validatePassword(password).mapToVoid() 26 | } 27 | 28 | func validateConfirmPassword(password: String, confirmPassword: String) -> ValidationResult { 29 | return Validator.validateConfirmPassword(password, confirmPassword: confirmPassword).mapToVoid() 30 | } 31 | 32 | func login(email: String, password: String) -> Observable { 33 | Future { promise in 34 | auth.signIn(withEmail: email, password: password) { _, error in 35 | if let error = error { 36 | promise(.failure(error)) 37 | return 38 | } 39 | promise(.success(())) 40 | } 41 | } 42 | .eraseToAnyPublisher() 43 | } 44 | 45 | func setIsLoggedIn(_ value: Bool) { 46 | Defaults[.isLoggedIn] = value 47 | } 48 | 49 | func register(email: String, password: String) -> Observable { 50 | Future { promise in 51 | auth.createUser(withEmail: email, password: password) { _, error in 52 | if let error = error { 53 | promise(.failure(error)) 54 | return 55 | } 56 | promise(.success(())) 57 | } 58 | } 59 | .eraseToAnyPublisher() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/RepositoryImpl/MovieRepositoryImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieRepositoryImpl.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | 12 | struct MovieRepositoryImpl: MovieRepository { 13 | func getTopRatedMovies(page _: Int) -> Observable<[Movie]> { 14 | let input = API.GetTopRatedMoviesInput() 15 | return API.shared.getTopRatedMovies(input) 16 | .map { $0.movies } 17 | .eraseToAnyPublisher() 18 | } 19 | 20 | func getUpcomingMovies(page _: Int) -> Observable<[Movie]> { 21 | let input = API.GetUpcomingMoviesInput() 22 | return API.shared.getUpcomingMovies(input) 23 | .map { $0.movies } 24 | .eraseToAnyPublisher() 25 | } 26 | 27 | func getMovieDetail(id: Int) -> Observable { 28 | let input = API.GetMovieDetailInput(id: id) 29 | return API.shared.getMovieDetail(input) 30 | .map { $0.movie } 31 | .eraseToAnyPublisher() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/RepositoryImpl/SettingsRepositoryImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsRepositoryImpl.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | 11 | struct SettingsRepositoryImpl: SettingsRepository { 12 | func getCurrentLanguage() -> String { 13 | Defaults[.language] 14 | } 15 | 16 | func getDarkModeStatus() -> Bool { 17 | Defaults[.isDarkMode] 18 | } 19 | 20 | func setLanguage(_ language: String) { 21 | Defaults[.language] = language 22 | } 23 | 24 | func toggleDarkMode() { 25 | Defaults[.isDarkMode].toggle() 26 | } 27 | 28 | func logout() { 29 | Defaults[.isLoggedIn] = false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /BaseSwiftUI/Data/RepositoryImpl/TodosRepositoryImpl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosRepositoryImpl.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | import SwiftData 12 | 13 | struct TodosRepositoryImpl: TodosRepository { 14 | @Injected(\.modelContext) private var modelContext 15 | 16 | func addTodo(name: String, date: Date, note: String, category: TodoCategory) -> Observable { 17 | Future { promise in 18 | do { 19 | let item = TodoItem(name: name, note: note, categoryId: category.id, date: date) 20 | modelContext?.insert(item) 21 | try modelContext?.save() 22 | promise(.success(())) 23 | } catch { 24 | promise(.failure(error)) 25 | } 26 | } 27 | .eraseToAnyPublisher() 28 | } 29 | 30 | func getTodoLists() -> Observable<[TodoList]> { 31 | Future<[TodoList], Error> { promise in 32 | do { 33 | let descriptor = FetchDescriptor() 34 | let items = try modelContext?.fetch(descriptor) ?? [] 35 | promise(.success(groupTodoItemsToTodoLists(items: items))) 36 | } catch { 37 | promise(.failure(error)) 38 | } 39 | } 40 | .eraseToAnyPublisher() 41 | } 42 | 43 | func getTodos(category: TodoCategory) -> Observable<[TodoItem]> { 44 | Future<[TodoItem], Error> { promise in 45 | do { 46 | let descriptor = FetchDescriptor(predicate: #Predicate { 47 | $0.categoryId == category.id 48 | }) 49 | let items = try modelContext?.fetch(descriptor) ?? [] 50 | promise(.success(items)) 51 | } catch { 52 | promise(.failure(error)) 53 | } 54 | } 55 | .eraseToAnyPublisher() 56 | } 57 | 58 | func deleteTodo(item: TodoItem) -> Observable { 59 | Future { promise in 60 | do { 61 | modelContext?.delete(item) 62 | try modelContext?.save() 63 | promise(.success(())) 64 | } catch { 65 | promise(.failure(error)) 66 | } 67 | } 68 | .eraseToAnyPublisher() 69 | } 70 | 71 | func updateCompleted(item: TodoItem) { 72 | item.isCompleted.toggle() 73 | } 74 | 75 | private func groupTodoItemsToTodoLists(items: [TodoItem]) -> [TodoList] { 76 | var dict: [TodoCategory: TodoList] = [:] 77 | 78 | for todoItem in items { 79 | dict[TodoCategory.byId(todoItem.categoryId), default: TodoList(category: TodoCategory.byId(todoItem.categoryId))].items.append(todoItem) 80 | } 81 | 82 | return Array(dict.values) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/DI/Domain+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Domain+Injection.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Factory 9 | 10 | extension Container { 11 | var authUseCase: Factory { 12 | Factory(self) { AuthUseCase() } 13 | } 14 | 15 | var movieUseCase: Factory { 16 | Factory(self) { MovieUseCase() } 17 | } 18 | 19 | var settingsUseCase: Factory { 20 | Factory(self) { SettingsUseCase() } 21 | } 22 | 23 | var todoUseCase: Factory { 24 | Factory(self) { TodosUseCase() } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Entity/Movie.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Movie.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Then 10 | 11 | struct Movie: Identifiable { 12 | var id: Int 13 | var adult = false 14 | var title = "" 15 | var overview = "" 16 | var popularity = 0.0 17 | var posterPath = "" 18 | var backdropPath = "" 19 | var voteCount = 0 20 | var voteAverage = 0.0 21 | var video = false 22 | var releaseDate = "" 23 | 24 | var posterUrl: String { 25 | return AppConfig.MovieDB.imageUrl / posterPath 26 | } 27 | 28 | var backdropUrl: String { 29 | return AppConfig.MovieDB.imageUrl / backdropPath 30 | } 31 | } 32 | 33 | extension Movie: Then, Equatable { 34 | static func == (lhs: Movie, rhs: Movie) -> Bool { 35 | return lhs.id == rhs.id 36 | } 37 | 38 | init() { 39 | self.init(id: 0) 40 | } 41 | } 42 | 43 | extension Movie: Decodable { 44 | private enum CodingKeys: String, CodingKey { 45 | case id, adult, title, overview, popularity, video 46 | case posterPath = "poster_path" 47 | case backdropPath = "backdrop_path" 48 | case voteCount = "vote_count" 49 | case voteAverage = "vote_average" 50 | case releaseDate = "release_date" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Entity/TodoCategory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoCategory.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 10/05/2024. 6 | // 7 | 8 | import SwiftData 9 | import Foundation 10 | 11 | enum TodoCategory: String, CaseIterable, Identifiable, Codable { 12 | case all 13 | case cooking 14 | case finance 15 | case gift 16 | case health 17 | case home 18 | case ideas 19 | case music 20 | case others 21 | case payment 22 | case shopping 23 | case study 24 | case travel 25 | case work 26 | 27 | var name: String { 28 | return rawValue.prefix(1).capitalized + rawValue.dropFirst() 29 | } 30 | 31 | var image: String { 32 | return "task_\(rawValue)" 33 | } 34 | 35 | var id: String { 36 | return rawValue 37 | } 38 | 39 | static func byId(_ name: String) -> TodoCategory { 40 | for category in TodoCategory.allCases where category.name.lowercased() == name.lowercased() { 41 | return category 42 | } 43 | return .all 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Entity/TodoItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoItem.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 10/05/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | @Model class TodoItem { 12 | var id: String 13 | var name: String 14 | var note: String 15 | var categoryId: String 16 | var isCompleted = false 17 | var date: Date 18 | 19 | init(id: String = UUID().uuidString, name: String, note: String, categoryId: String, isCompleted: Bool = false, date: Date) { 20 | self.id = id 21 | self.name = name 22 | self.note = note 23 | self.categoryId = categoryId 24 | self.isCompleted = isCompleted 25 | self.date = date 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Entity/TodoList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoList.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 10/05/2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | struct TodoList { 12 | var category: TodoCategory 13 | var items: [TodoItem] = [] 14 | } 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Repository/AuthRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRepository.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol AuthRepository { 11 | func updateOnboardingStatus(isDone: Bool) 12 | func validateEmail(email: String) -> ValidationResult 13 | func validatePassword(password: String) -> ValidationResult 14 | func validateConfirmPassword(password: String, confirmPassword: String) -> ValidationResult 15 | func login(email: String, password: String) -> Observable 16 | func setIsLoggedIn(_ value: Bool) 17 | func register(email: String, password: String) -> Observable 18 | } 19 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Repository/MovieRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieRepository.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MovieRepository { 11 | func getTopRatedMovies(page: Int) -> Observable<[Movie]> 12 | func getUpcomingMovies(page: Int) -> Observable<[Movie]> 13 | func getMovieDetail(id: Int) -> Observable 14 | } 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Repository/SettingsRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsRepository.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SettingsRepository { 11 | func getCurrentLanguage() -> String 12 | func getDarkModeStatus() -> Bool 13 | func setLanguage(_ language: String) 14 | func toggleDarkMode() 15 | func logout() 16 | } 17 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Repository/TodosRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosRepository.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol TodosRepository { 11 | func getTodoLists() -> Observable<[TodoList]> 12 | func getTodos(category: TodoCategory) -> Observable<[TodoItem]> 13 | func addTodo(name: String, date: Date, note: String, category: TodoCategory) -> Observable 14 | func deleteTodo(item: TodoItem) -> Observable 15 | func updateCompleted(item: TodoItem) 16 | } 17 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/UseCase/AuthUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthUseCase.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | protocol AuthUseCaseType { 12 | func updateOnboardingStatus(isDone: Bool) 13 | func validateEmail(email: String) -> ValidationResult 14 | func validatePassword(password: String) -> ValidationResult 15 | func validateConfirmPassword(password: String, confirmPassword: String) -> ValidationResult 16 | func login(email: String, password: String) -> Observable 17 | func setIsLoggedIn(_ value: Bool) 18 | func register(email: String, password: String) -> Observable 19 | } 20 | 21 | struct AuthUseCase: AuthUseCaseType { 22 | @Injected(\.authRepository) private var authRepository 23 | 24 | func updateOnboardingStatus(isDone: Bool) { 25 | authRepository.updateOnboardingStatus(isDone: isDone) 26 | } 27 | 28 | func validateEmail(email: String) -> ValidationResult { 29 | authRepository.validateEmail(email: email) 30 | } 31 | 32 | func validatePassword(password: String) -> ValidationResult { 33 | authRepository.validatePassword(password: password) 34 | } 35 | 36 | func validateConfirmPassword(password: String, confirmPassword: String) -> ValidationResult { 37 | authRepository.validateConfirmPassword(password: password, confirmPassword: confirmPassword) 38 | } 39 | 40 | func login(email: String, password: String) -> Observable { 41 | authRepository.login(email: email, password: password) 42 | } 43 | 44 | func setIsLoggedIn(_ value: Bool) { 45 | authRepository.setIsLoggedIn(value) 46 | } 47 | 48 | func register(email: String, password: String) -> Observable { 49 | authRepository.register(email: email, password: password) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/UseCase/MovieUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieUseCase.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | protocol MovieUseCaseType { 12 | func getTopRatedMovies(page: Int) -> Observable<[Movie]> 13 | func getUpcomingMovies(page: Int) -> Observable<[Movie]> 14 | func getMovieDetail(id: Int) -> Observable 15 | } 16 | 17 | struct MovieUseCase: MovieUseCaseType { 18 | @Injected(\.movieRepository) private var movieRepository 19 | 20 | func getTopRatedMovies(page: Int) -> Observable<[Movie]> { 21 | return movieRepository.getTopRatedMovies(page: page) 22 | } 23 | 24 | func getUpcomingMovies(page: Int) -> Observable<[Movie]> { 25 | return movieRepository.getUpcomingMovies(page: page) 26 | } 27 | 28 | func getMovieDetail(id: Int) -> Observable { 29 | return movieRepository.getMovieDetail(id: id) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/UseCase/SettingsUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsUseCase.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | protocol SettingsUseCaseType { 12 | func getCurrentLanguage() -> String 13 | func getDarkModeStatus() -> Bool 14 | func setLanguage(_ language: String) 15 | func toggleDarkMode() 16 | func logout() 17 | } 18 | 19 | struct SettingsUseCase: SettingsUseCaseType { 20 | @Injected(\.settingsRepository) private var settingsRepository 21 | 22 | func getCurrentLanguage() -> String { 23 | settingsRepository.getCurrentLanguage() 24 | } 25 | 26 | func getDarkModeStatus() -> Bool { 27 | settingsRepository.getDarkModeStatus() 28 | } 29 | 30 | func setLanguage(_ language: String) { 31 | settingsRepository.setLanguage(language) 32 | } 33 | 34 | func toggleDarkMode() { 35 | settingsRepository.toggleDarkMode() 36 | } 37 | 38 | func logout() { 39 | settingsRepository.logout() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/UseCase/TodosUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosUseCase.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | 11 | protocol TodosUseCaseType { 12 | func getTodoLists() -> Observable<[TodoList]> 13 | func getTodos(category: TodoCategory) -> Observable<[TodoItem]> 14 | func addTodo(name: String, date: Date, note: String, category: TodoCategory) -> Observable 15 | func deleteTodo(item: TodoItem) -> Observable 16 | func updateCompleted(item: TodoItem) 17 | } 18 | 19 | struct TodosUseCase: TodosUseCaseType { 20 | @Injected(\.todosRepository) private var todosRepository 21 | 22 | func getTodoLists() -> Observable<[TodoList]> { 23 | todosRepository.getTodoLists() 24 | } 25 | 26 | func getTodos(category: TodoCategory) -> Observable<[TodoItem]> { 27 | todosRepository.getTodos(category: category) 28 | } 29 | 30 | func addTodo(name: String, date: Date, note: String, category: TodoCategory) -> Observable { 31 | todosRepository.addTodo(name: name, date: date, note: note, category: category) 32 | } 33 | 34 | func deleteTodo(item: TodoItem) -> Observable { 35 | todosRepository.deleteTodo(item: item) 36 | } 37 | 38 | func updateCompleted(item: TodoItem) { 39 | todosRepository.updateCompleted(item: item) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/Base/ValidatedProperty.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidatedProperty.swift 3 | // 4 | // Created by phan.duong.ngoc on 2023/04/04. 5 | // 6 | 7 | import ValidatedPropertyKit 8 | 9 | protocol ValidatedProperty { 10 | var isValid: Bool { get } 11 | var validationError: ValidationError? { get } 12 | } 13 | 14 | extension Validated: ValidatedProperty {} 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/Base/Validation+Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validation+Collection.swift 3 | // 4 | // Created by phan.duong.ngoc on 2023/04/04. 5 | // 6 | 7 | import ValidatedPropertyKit 8 | 9 | extension Validation where Value: Collection { 10 | public static func nonEmpty(message: String) -> Validation { 11 | return .init { value in 12 | if !value.isEmpty { 13 | return .success(()) 14 | } else { 15 | return .failure(ValidationError(message: message)) 16 | } 17 | } 18 | } 19 | 20 | public static func minLength(min: Int, message: String) -> Validation { 21 | return .init { value in 22 | if value.count >= min { 23 | return .success(()) 24 | } else { 25 | return .failure(ValidationError(message: message)) 26 | } 27 | } 28 | } 29 | 30 | public static func maxLength(max: Int, message: String) -> Validation { 31 | return .init { value in 32 | if value.count <= max { 33 | return .success(()) 34 | } else { 35 | return .failure(ValidationError(message: message)) 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/Base/Validation+Comparable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validation+Comparable.swift 3 | // 4 | // Created by phan.duong.ngoc on 2023/04/04. 5 | // 6 | 7 | import ValidatedPropertyKit 8 | 9 | extension Validation where Value: Comparable { 10 | public static func greaterOrEqual(_ comparableValue: Value, message: String) -> Validation { 11 | return .init { value in 12 | if value >= comparableValue { 13 | return .success(()) 14 | } else { 15 | return .failure(ValidationError(message: message)) 16 | } 17 | } 18 | } 19 | 20 | public static func greater(_ comparableValue: Value, message: String) -> Validation { 21 | return .init { value in 22 | if value > comparableValue { 23 | return .success(()) 24 | } else { 25 | return .failure(ValidationError(message: message)) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/Base/Validation+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validation+String.swift 3 | // 4 | // Created by phan.duong.ngoc on 2023/04/04. 5 | // 6 | 7 | import ValidatedPropertyKit 8 | 9 | extension Validation where Value == String { 10 | public static func matches(_ pattern: String, 11 | options: NSRegularExpression.Options = .init(), 12 | matchingOptions: NSRegularExpression.MatchingOptions = .init(), 13 | message: String) -> Validation { 14 | guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { 15 | return .init { _ in .failure("Invalid regular expression: \(pattern)") } 16 | } 17 | 18 | return matches( 19 | regularExpression, 20 | matchingOptions: matchingOptions, 21 | message: message 22 | ) 23 | } 24 | 25 | public static func matches(_ regex: NSRegularExpression, 26 | matchingOptions: NSRegularExpression.MatchingOptions = .init(), 27 | message: String) -> Validation { 28 | return .init { value in 29 | let firstMatchIsAvailable = regex.firstMatch( 30 | in: value, 31 | options: matchingOptions, 32 | range: .init(value.startIndex..., in: value) 33 | ) != nil 34 | 35 | if firstMatchIsAvailable { 36 | return .success(()) 37 | } else { 38 | return .failure(ValidationError(message: message)) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/Validation+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validation+.swift 3 | // 4 | // Created by phan.duong.ngoc on 2023/04/04. 5 | // 6 | 7 | import Foundation 8 | import ValidatedPropertyKit 9 | 10 | extension Validation where Value == String { 11 | static func matchEmail(message: String) -> Validation { 12 | return .init { value in 13 | let regexEmail = "^(?![.\\-\\@])(?!.*((\\.\\.)|(\\.\\-)|(\\.\\@)|(\\-\\@)))[A-Za-z0-9'.\\/\\{\\}\\|\\`!#\\$%&*+\\-\\=?^_~]*+\\@[A-Za-z0-9]+([-.][A-Za-z0-9]{2,}+)*\\.[A-Za-z]{2,}+$" 14 | let validationStatus = NSPredicate(format: "SELF MATCHES %@", regexEmail) 15 | if validationStatus.evaluate(with: value) { 16 | return .success(()) 17 | } 18 | return .failure(ValidationError(message: message)) 19 | } 20 | } 21 | 22 | static func matchPassword(message: String) -> Validation { 23 | return .init { value in 24 | let regexPassword = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#\\$%^&*()_+{}\\[\\]:;<>,.?~=`/\\\\|\\\"'-])[A-Za-z\\d!@#\\$%^&*()_+{}\\[\\]:;<>,.?~=`/\\\\|\\\"'-]{8,255}$" 25 | let validationStatus = NSPredicate(format: "SELF MATCHES %@", regexPassword) 26 | if validationStatus.evaluate(with: value) { 27 | return .success(()) 28 | } 29 | return .failure(ValidationError(message: message)) 30 | } 31 | } 32 | } 33 | 34 | extension Result where Failure == ValidationError { 35 | var message: String { 36 | switch self { 37 | case .success: 38 | return "" 39 | case .failure(let error): 40 | return error.description.components(separatedBy: "\n").first ?? "" 41 | } 42 | } 43 | 44 | var isValid: Bool { 45 | switch self { 46 | case .success: 47 | return true 48 | case .failure: 49 | return false 50 | } 51 | } 52 | 53 | func mapToVoid() -> ValidationResult { 54 | return map { _ in () } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/ValidationResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationResulr.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Foundation 9 | import ValidatedPropertyKit 10 | 11 | typealias ValidationResult = Result 12 | -------------------------------------------------------------------------------- /BaseSwiftUI/Domain/Validation/Validator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validator.swift 3 | // 4 | // Created by phan.duong.ngoc on 2023/04/04. 5 | // 6 | 7 | import ValidatedPropertyKit 8 | 9 | struct Validator { 10 | @Validated(.minLength(min: 1, message: R.string.localizable.validationEmailEmpty()) 11 | && .maxLength(max: 128, message: R.string.localizable.validationEmailMaxlength()) 12 | && .matchEmail(message: R.string.localizable.validationEmailInvalid())) 13 | 14 | var email: String? 15 | 16 | @Validated(.minLength(min: 1, message: R.string.localizable.validationPasswordEmpty()) 17 | && .maxLength(max: 255, message: R.string.localizable.validationPasswordMaxlength()) 18 | && .matchPassword(message: R.string.localizable.validationPasswordInvalid())) 19 | 20 | var password: String? 21 | 22 | var validatedProperties: [ValidatedProperty] { 23 | return [ 24 | _email, 25 | _password 26 | ] 27 | } 28 | } 29 | 30 | extension Validator { 31 | static func validateEmail(_ email: String) -> Result { 32 | return Validator()._email.isValid(value: email) 33 | } 34 | 35 | static func validatePassword(_ password: String) -> Result { 36 | return Validator()._password.isValid(value: password) 37 | } 38 | 39 | static func validateConfirmPassword(_ password: String, 40 | confirmPassword: String) -> Result { 41 | if confirmPassword.isEmpty { 42 | return .failure(ValidationError(message: R.string.localizable.validationConfirmPasswordEmpty())) 43 | } 44 | return password == confirmPassword ? 45 | .success("") : .failure(ValidationError(message: R.string.localizable.validationConfirmPasswordInvalid())) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /BaseSwiftUI/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_KEY 6 | AIzaSyCOJY9mkjOLH7srOe3nQHbEX-9qEJLmdKg 7 | GCM_SENDER_ID 8 | 155760957773 9 | PLIST_VERSION 10 | 1 11 | BUNDLE_ID 12 | ngocpd.SwiftUIBase.dev 13 | PROJECT_ID 14 | fir-swiftui-4f49f 15 | STORAGE_BUCKET 16 | fir-swiftui-4f49f.appspot.com 17 | IS_ADS_ENABLED 18 | 19 | IS_ANALYTICS_ENABLED 20 | 21 | IS_APPINVITE_ENABLED 22 | 23 | IS_GCM_ENABLED 24 | 25 | IS_SIGNIN_ENABLED 26 | 27 | GOOGLE_APP_ID 28 | 1:155760957773:ios:31c542af4dcfb62661bf04 29 | 30 | -------------------------------------------------------------------------------- /BaseSwiftUI/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | API_ENDPOINT 6 | $(API_ENDPOINT) 7 | API_VERSION 8 | $(API_VERSION) 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(APP_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | $(APP_VERSION) 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleURLSchemes 27 | 28 | testlink 29 | 30 | 31 | 32 | CFBundleVersion 33 | $(APP_BUILD_VERSION) 34 | LSRequiresIPhoneOS 35 | 36 | UIApplicationSceneManifest 37 | 38 | UIApplicationSupportsMultipleScenes 39 | 40 | 41 | UIApplicationSupportsIndirectInputEvents 42 | 43 | UILaunchScreen 44 | 45 | UIImageName 46 | splash 47 | 48 | UISupportedInterfaceOrientations~ipad 49 | 50 | UIInterfaceOrientationPortrait 51 | 52 | UISupportedInterfaceOrientations~iphone 53 | 54 | UIInterfaceOrientationPortrait 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/BaseButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseButton.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BaseButton: View { 11 | var title: String 12 | var isEnabled = true 13 | var action: () -> Void 14 | 15 | var body: some View { 16 | Button(action: action) { 17 | Text(title) 18 | .foregroundStyle(isEnabled ? .white : Color.white.opacity(0.5)) 19 | .frame(maxWidth: .infinity, maxHeight: .infinity) 20 | .transaction { transaction in 21 | transaction.animation = nil 22 | } 23 | } 24 | .foregroundStyle(.white) 25 | .background(Color(R.color.primary)) 26 | .clipShape(RoundedRectangle(cornerRadius: 20)) 27 | .disabled(!isEnabled) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/BaseTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseTextField.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 16/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BaseTextField: View { 11 | @Binding var text: String 12 | 13 | private let placeholder: String 14 | private let image: Image 15 | private var isSecure = false 16 | private var errorMessage = "" 17 | 18 | init(text: Binding, placeholder: String, image: Image, isSecure: Bool = false, errorMessage: String = "") { 19 | _text = text 20 | self.placeholder = placeholder 21 | self.image = image 22 | self.isSecure = isSecure 23 | self.errorMessage = errorMessage 24 | } 25 | 26 | var body: some View { 27 | VStack { 28 | HStack { 29 | image 30 | .foregroundColor(.gray) 31 | .padding(.leading, Spacing.small.value) 32 | if isSecure { 33 | SecureField(placeholder, text: $text) 34 | .padding(Spacing.small.value) 35 | .font(.subheadline) 36 | .autocapitalization(.none) 37 | .disableAutocorrection(true) 38 | .tint(Color(R.color.labelPrimary)) 39 | .foregroundStyle(Color(R.color.labelPrimary)) 40 | } else { 41 | TextField(placeholder, text: $text) 42 | .padding(Spacing.small.value) 43 | .font(.subheadline) 44 | .autocapitalization(.none) 45 | .disableAutocorrection(true) 46 | .tint(Color(R.color.labelPrimary)) 47 | .foregroundStyle(Color(R.color.labelPrimary)) 48 | } 49 | } 50 | Rectangle() 51 | .frame(height: 1) 52 | .foregroundColor(Color(R.color.separatorPrimary)) 53 | .padding(.trailing, Spacing.small.value) 54 | 55 | if !errorMessage.isEmpty { 56 | Text(errorMessage) 57 | .foregroundStyle(.red) 58 | .font(.footnote) 59 | .frame(maxWidth: .infinity, alignment: .leading) 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Loading/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import UIKit 9 | import SwiftUI 10 | 11 | struct ActivityIndicator: UIViewRepresentable { 12 | @Binding var isAnimating: Bool 13 | let style: UIActivityIndicatorView.Style 14 | 15 | func makeUIView(context _: UIViewRepresentableContext) -> UIActivityIndicatorView { 16 | return UIActivityIndicatorView(style: style) 17 | } 18 | 19 | func updateUIView(_ uiView: UIActivityIndicatorView, context _: UIViewRepresentableContext) { 20 | if isAnimating { 21 | uiView.startAnimating() 22 | } else { 23 | uiView.stopAnimating() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Loading/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoadingView: View where Content: View { 11 | @Binding var isShowing: Bool 12 | 13 | var content: () -> Content 14 | 15 | var body: some View { 16 | GeometryReader { geometry in 17 | ZStack(alignment: .center) { 18 | content() 19 | .disabled(isShowing) 20 | ActivityIndicator(isAnimating: .constant(true), style: .large) 21 | .frame(minWidth: 78, 22 | idealWidth: nil, 23 | maxWidth: nil, 24 | minHeight: 78, 25 | idealHeight: nil, 26 | maxHeight: nil, 27 | alignment: .center) 28 | .background(Color(R.color.backgroundPrimary)) 29 | .cornerRadius(6) 30 | .opacity(isShowing ? 1 : 0) 31 | } 32 | .frame(width: geometry.size.width, height: geometry.size.height) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Movie/HorizontalMovieCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HorizontalMovieCard.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 23/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Kingfisher 10 | 11 | private enum Constant { 12 | static let imageSize = CGSize(width: 150, height: 190) 13 | } 14 | 15 | struct HorizontalMovieCard: View { 16 | let movie: Movie 17 | 18 | var body: some View { 19 | VStack(alignment: .leading, spacing: 4) { 20 | if let url = URL(string: movie.posterUrl) { 21 | KFImage(url) 22 | .resizable() 23 | .aspectRatio(contentMode: .fill) 24 | .frame(width: Constant.imageSize.width, height: Constant.imageSize.height) 25 | .cornerRadius(10) 26 | } 27 | Text(movie.title) 28 | .foregroundStyle(Color(R.color.labelPrimary)) 29 | .font(.headline) 30 | .lineLimit(1) 31 | HStack { 32 | Image(R.image.icon_rating) 33 | .frame(width: 16, height: 16) 34 | Text("\(Int(movie.voteAverage))/10") 35 | .font(.subheadline) 36 | .fontWeight(.bold) 37 | .foregroundColor(Color(R.color.orangeFlush)) 38 | } 39 | Text("\(formatDate(movie.releaseDate))") 40 | .foregroundStyle(Color(R.color.labelPrimary)) 41 | .font(.subheadline) 42 | Spacer() 43 | } 44 | .frame(width: Constant.imageSize.width) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Movie/Section.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Section.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 23/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Section: View { 11 | let title: String 12 | let content: () -> Content 13 | var moreAction: () -> Void = {} 14 | 15 | var body: some View { 16 | VStack(alignment: .leading, spacing: 10) { 17 | HStack { 18 | Text(title) 19 | .fontWeight(.semibold) 20 | .font(.title2) 21 | .foregroundStyle(Color(R.color.labelPrimary)) 22 | 23 | Spacer() 24 | 25 | Button(R.string.localizable.commonSeeMore(), action: moreAction) 26 | .tint(Color(R.color.labelPrimary)) 27 | } 28 | content() 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Movie/VerticalMovieCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VerticalMovieCard.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 23/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Kingfisher 10 | 11 | private enum Constant { 12 | static let imageSize = CGSize(width: 120, height: 165) 13 | } 14 | 15 | struct VerticalMovieCard: View { 16 | let movie: Movie 17 | 18 | var body: some View { 19 | HStack(alignment: .top, spacing: 16) { 20 | if let url = URL(string: movie.posterUrl) { 21 | KFImage(url) 22 | .resizable() 23 | .aspectRatio(contentMode: .fill) 24 | .frame(width: Constant.imageSize.width, height: Constant.imageSize.height) 25 | .cornerRadius(10) 26 | } 27 | VStack(alignment: .leading, spacing: 10) { 28 | Text(movie.title) 29 | .font(.headline) 30 | .lineLimit(2) 31 | .multilineTextAlignment(.leading) 32 | .foregroundStyle(Color(R.color.labelPrimary)) 33 | HStack { 34 | Image(R.image.icon_rating) 35 | .frame(width: 16, height: 16) 36 | Text("\(Int(movie.voteAverage))/10") 37 | .font(.subheadline) 38 | .fontWeight(.bold) 39 | .foregroundColor(Color(R.color.orangeFlush)) 40 | } 41 | Text("\(formatDate(movie.releaseDate))") 42 | .font(.subheadline) 43 | .foregroundStyle(Color(R.color.labelPrimary)) 44 | Spacer() 45 | } 46 | } 47 | .frame(maxWidth: .infinity, alignment: .leading) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Screen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Screen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import RswiftResources 11 | 12 | struct Screen: View where Content: View { 13 | @Binding private var isLoading: Bool 14 | @State private var language = Defaults[.language] 15 | @Default(.isDarkMode) var isDarkMode 16 | private var title: String? 17 | 18 | var content: () -> Content 19 | 20 | init(isLoading: Binding = .constant(false), 21 | title: String? = nil, 22 | content: @escaping () -> Content) { 23 | _isLoading = isLoading 24 | self.title = title 25 | self.content = content 26 | } 27 | 28 | var body: some View { 29 | LoadingView(isShowing: $isLoading) { 30 | content() 31 | } 32 | .navigationTitle(title ?? "") 33 | .background(Color(R.color.backgroundPrimary)) 34 | .environment(\.colorScheme, isDarkMode ? .dark : .light) 35 | .preferredColorScheme(isDarkMode ? .dark : .light) 36 | .onAppear { 37 | Task { 38 | for await value in Defaults.updates(.language) { 39 | language = value 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/SettingItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingItem.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingItem: View { 11 | let title: String 12 | var value: Bool? 13 | let action: () -> Void 14 | 15 | var body: some View { 16 | HStack { 17 | if let value = value { 18 | Text(title) 19 | .fontWeight(.medium) 20 | .foregroundStyle(Color(R.color.labelPrimary)) 21 | Spacer() 22 | Toggle(isOn: Binding( 23 | get: { value }, 24 | set: { _ in action() } 25 | )) { 26 | EmptyView() 27 | } 28 | .labelsHidden() 29 | .tint(Color(R.color.primary)) 30 | } else { 31 | Button(action: action) { 32 | HStack { 33 | Text(title) 34 | .fontWeight(.medium) 35 | .foregroundStyle(Color(R.color.labelPrimary)) 36 | Spacer() 37 | Image(systemName: "chevron.right") 38 | .foregroundColor(.gray) 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Style/Spacing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Spacing.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Spacing { 11 | case small 12 | case normal 13 | case medium 14 | case large 15 | case extraLarge 16 | 17 | var value: CGFloat { 18 | switch self { 19 | case .small: 20 | return 8 21 | case .normal: 22 | return 16 23 | case .medium: 24 | return 40 25 | case .large: 26 | return 70 27 | case .extraLarge: 28 | return 100 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/Todo/TodoListItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodoListItem.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 10/05/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TodoListItem: View { 11 | var todoList: TodoList 12 | 13 | var body: some View { 14 | HStack { 15 | VStack(alignment: .leading) { 16 | Image(todoList.category.image) 17 | .resizable() 18 | .frame(width: 30, height: 30) 19 | 20 | Spacer() 21 | .frame(height: 20) 22 | 23 | VStack(alignment: .leading) { 24 | Text(todoList.category.name) 25 | .fontWeight(.medium) 26 | .foregroundColor(Color(R.color.labelPrimary)) 27 | 28 | Text("\(todoList.items.count) \(R.string.localizable.commonTasks())") 29 | .foregroundColor(Color(R.color.labelPrimary)) 30 | } 31 | } 32 | Spacer() 33 | } 34 | .frame(width: 140, height: 140) 35 | .padding(.horizontal) 36 | .background(Color(R.color.todoCardBackground)) 37 | .cornerRadius(10) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Component/VScroll.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VScroll.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 16/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct VScrollView: View where Content: View { 11 | @ViewBuilder let content: Content 12 | 13 | var body: some View { 14 | GeometryReader { geometry in 15 | ScrollView(.vertical) { 16 | content 17 | .frame(width: geometry.size.width) 18 | .frame(minHeight: geometry.size.height) 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/MainApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainApp.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import LinkNavigator 11 | import Then 12 | 13 | struct MainApp: Scene { 14 | @Default(.isLoggedIn) var isLoggedIn 15 | @Default(.isDarkMode) var isDarkMode 16 | 17 | var body: some Scene { 18 | WindowGroup { 19 | if NSClassFromString("XCTest") != nil { 20 | unitTestView() 21 | } else { 22 | if isLoggedIn { 23 | HomeRoute() 24 | } else { 25 | AuthRoute() 26 | } 27 | } 28 | } 29 | } 30 | 31 | @ViewBuilder 32 | func unitTestView() -> some View { 33 | VStack { 34 | Text("Testing...") 35 | .font(.title) 36 | } 37 | .frame(maxWidth: .infinity, maxHeight: .infinity) 38 | .preferredColorScheme(isDarkMode ? .dark : .light) 39 | } 40 | } 41 | 42 | private struct AuthRoute: View { 43 | private let linkNavigator = SingleLinkNavigator( 44 | routeBuilderItemList: AuthRouteGroup().routers, 45 | dependency: NavigatorDependency()) 46 | 47 | var body: some View { 48 | LinkNavigationView( 49 | linkNavigator: linkNavigator, 50 | item: .init(routePath: Defaults[.isOnboardingCompleted] ? RoutePath.login : RoutePath.onboarding) 51 | ) 52 | .background(Color(R.color.backgroundPrimary)) 53 | .onOpenURL { url in 54 | // You can test deep links by setting the URL Scheme to "testlink". 55 | // Example: testlink://host?email=foo@gmail.com&password=Aa@12345 56 | let action = ActionButton(title: "OK", style: .cancel) 57 | let alert = Alert(title: "Deeplink URL:", message: url.absoluteString, buttons: [action], flagType: .error) 58 | linkNavigator.alert(target: .root, model: alert) 59 | } 60 | } 61 | } 62 | 63 | private struct HomeRoute: View { 64 | @Default(.language) var language 65 | 66 | private let tabLinkNavigator = TabLinkNavigator( 67 | routeBuilderItemList: HomeRouteGroup().routers, 68 | dependency: NavigatorDependency()) 69 | 70 | private let tabItems: [TabItem] = [ 71 | .init( 72 | tag: TabBarItemType.movies.rawValue, 73 | tabItem: TabBarItemType.movies.tabbarItem, 74 | linkItem: .init(routePath: .movies)), 75 | .init( 76 | tag: TabBarItemType.todos.rawValue, 77 | tabItem: TabBarItemType.todos.tabbarItem, 78 | linkItem: .init(routePath: .todos)), 79 | .init( 80 | tag: TabBarItemType.settings.rawValue, 81 | tabItem: TabBarItemType.settings.tabbarItem, 82 | linkItem: .init(routePath: .settings)) 83 | ] 84 | 85 | var body: some View { 86 | return TabLinkNavigationView( 87 | linkNavigator: tabLinkNavigator, 88 | isHiddenDefaultTabbar: false, 89 | tabItemList: tabItems, 90 | isAnimatedForUpdateTabbar: false 91 | ) 92 | .background(Color(R.color.backgroundPrimary)) 93 | .onAppear { 94 | observerLanguageChange() 95 | } 96 | } 97 | 98 | private func observerLanguageChange() { 99 | Task { 100 | for await _ in Defaults.updates(.language) { 101 | reloadTabbarTitle() 102 | } 103 | } 104 | } 105 | 106 | private func reloadTabbarTitle() { 107 | let tabbarItems = tabLinkNavigator.mainController?.tabBar.items 108 | tabbarItems?.do { 109 | $0[TabBarItemType.movies.rawValue].title = TabBarItemType.movies.title 110 | $0[TabBarItemType.todos.rawValue].title = TabBarItemType.todos.title 111 | $0[TabBarItemType.settings.rawValue].title = TabBarItemType.settings.title 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Navigator/Auth/AuthRouteBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouteBuilder.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 16/04/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | import SwiftUI 11 | 12 | struct OnboardingRouteBuilder { 13 | static func generate() -> RouteBuilderOf { 14 | var matchPath: String { RoutePath.onboarding.rawValue } 15 | return .init(matchPath: matchPath) { navigator, _, _ -> RouteViewController? in 16 | WrappingController(matchPath: matchPath) { 17 | let navigator = OnboardingNavigator(navigation: navigator) 18 | let viewModel = OnboardingViewModel(navigator: navigator) 19 | OnboardingScreen(viewModel: viewModel) 20 | } 21 | } 22 | } 23 | } 24 | 25 | struct LoginRouteBuilder { 26 | static func generate() -> RouteBuilderOf { 27 | var matchPath: String { RoutePath.login.rawValue } 28 | return .init(matchPath: matchPath) { navigator, _, _ -> RouteViewController? in 29 | WrappingController(matchPath: matchPath) { 30 | let navigator = LoginNavigator(navigation: navigator) 31 | let viewModel = LoginViewModel(navigator: navigator) 32 | LoginScreen(viewModel: viewModel) 33 | } 34 | } 35 | } 36 | } 37 | 38 | struct RegisterRouteBuilder { 39 | static func generate() -> RouteBuilderOf { 40 | var matchPath: String { RoutePath.register.rawValue } 41 | return .init(matchPath: matchPath) { navigator, _, _ -> RouteViewController? in 42 | WrappingController(matchPath: matchPath) { 43 | let navigator = RegisterNavigator(navigation: navigator) 44 | let viewModel = RegisterViewModel(navigator: navigator) 45 | RegisterScreen(viewModel: viewModel) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Navigator/Auth/AuthRouteGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthRouteGroup.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 16/04/2024. 6 | // 7 | 8 | import LinkNavigator 9 | 10 | public typealias AuthRootNavigatorType = LinkNavigatorFindLocationUsable & LinkNavigatorProtocol 11 | 12 | // MARK: - AppRouterBuilderGroup 13 | struct AuthRouteGroup { 14 | init() {} 15 | 16 | var routers: [RouteBuilderOf] { 17 | [ 18 | OnboardingRouteBuilder.generate(), 19 | LoginRouteBuilder.generate(), 20 | RegisterRouteBuilder.generate() 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Navigator/Home/HomeRouteGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HomeRouteGroup.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import LinkNavigator 9 | 10 | public typealias HomeRootNavigatorType = LinkNavigatorFindLocationUsable & TabLinkNavigatorProtocol 11 | 12 | // MARK: - AppRouterBuilderGroup 13 | 14 | struct HomeRouteGroup { 15 | init() {} 16 | 17 | var routers: [RouteBuilderOf] { 18 | [ 19 | TopMoviesRouteBuilder.generate(), 20 | MoviesDetailRouteBuilder.generate(), 21 | TodosRouteBuilder.generate(), 22 | NewTodoRouteBuilder.generate(), 23 | ListTodoRouteBuilder.generate(), 24 | SettingsRouteBuilder.generate() 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Navigator/LinkNavigator+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinkNavigator+.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 17/04/2024. 6 | // 7 | 8 | import LinkNavigator 9 | 10 | extension TabLinkNavigator { 11 | var selectedTabPartialNavigator: TabPartialNavigator { 12 | return tabRootPartialNavigators[mainController?.selectedIndex ?? 0] 13 | } 14 | } 15 | 16 | extension LinkItem { 17 | init(routePaths: [RoutePath], items: String = "") { 18 | self.init(pathList: routePaths.map { $0.rawValue }, itemsString: items) 19 | } 20 | 21 | init(routePath: RoutePath, items: String = "") { 22 | self.init(path: routePath.rawValue, itemsString: items) 23 | } 24 | 25 | init(routePath: RoutePath, items: Codable?) { 26 | self.init(path: routePath.rawValue, items: items) 27 | } 28 | 29 | init(routePaths: [RoutePath], items: Codable?) { 30 | self.init(pathList: routePaths.map { $0.rawValue }, items: items) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Navigator/NavigatorDependency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigatorDependency.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 16/04/2024. 6 | // 7 | 8 | import LinkNavigator 9 | 10 | struct NavigatorDependency: DependencyType {} 11 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Navigator/RoutePath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoutePath.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | enum RoutePath: String { 11 | case onboarding 12 | case login 13 | case register 14 | case movies 15 | case movieDetail 16 | case todos 17 | case newTodo 18 | case todoList 19 | case settings 20 | } 21 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Login/LoginNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol LoginNavigatorType { 12 | func toRegister() 13 | func showError(message: String) 14 | } 15 | 16 | struct LoginNavigator: LoginNavigatorType { 17 | let navigation: AuthRootNavigatorType 18 | 19 | func toRegister() { 20 | navigation.next(linkItem: .init(routePath: .register), isAnimated: true) 21 | } 22 | 23 | func showError(message: String) { 24 | let action = ActionButton(title: "OK", style: .cancel) 25 | let alert = Alert(message: message, buttons: [action], flagType: .error) 26 | navigation.alert(target: .root, model: alert) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Login/LoginScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 15/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoginScreen: View { 11 | @ObservedObject private var input: LoginViewModel.Input 12 | @ObservedObject private var output: LoginViewModel.Output 13 | 14 | private let cancelBag = CancelBag() 15 | private let loginTrigger = PublishRelay() 16 | private let toRegisterTrigger = PublishRelay() 17 | 18 | var body: some View { 19 | return Screen(isLoading: $output.isLoading) { 20 | VScrollView { 21 | VStack { 22 | Text(R.string.localizable.commonLogin()) 23 | .font(.title) 24 | .padding(.bottom, Spacing.medium.value) 25 | .foregroundStyle(Color(R.color.primary)) 26 | 27 | BaseTextField(text: $input.username, 28 | placeholder: R.string.localizable.commonUsername(), 29 | image: Image(R.image.icon_email), 30 | errorMessage: output.usernameValidationMessage) 31 | .padding(.bottom) 32 | 33 | BaseTextField(text: $input.password, 34 | placeholder: R.string.localizable.commonPassword(), 35 | image: Image(R.image.icon_password), 36 | isSecure: true, 37 | errorMessage: output.passwordValidationMessage) 38 | .padding(.bottom) 39 | 40 | BaseButton(title: R.string.localizable.commonLogin(), isEnabled: output.isLoginEnabled) { 41 | loginTrigger.send(()) 42 | } 43 | .frame(maxWidth: .infinity) 44 | .frame(height: 52) 45 | .padding(.top, Spacing.normal.value) 46 | 47 | Text(R.string.localizable.commonDontHaveAccount()) 48 | .padding(.top, Spacing.extraLarge.value) 49 | 50 | Button { 51 | toRegisterTrigger.send(()) 52 | } label: { 53 | Text(R.string.localizable.commonRegister()) 54 | .foregroundStyle(Color(R.color.primary)) 55 | } 56 | } 57 | .padding(.horizontal) 58 | } 59 | } 60 | .navigationBarHidden(true) 61 | } 62 | 63 | init(viewModel: LoginViewModel) { 64 | let input = LoginViewModel.Input(loginTrigger: loginTrigger.asDriver(), 65 | toRegisterTrigger: toRegisterTrigger.asDriver()) 66 | output = viewModel.transform(input, cancelBag: cancelBag) 67 | self.input = input 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | struct LoginViewModel { 13 | let navigator: LoginNavigatorType 14 | @Injected(\.authUseCase) var authUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension LoginViewModel: ViewModel { 19 | final class Input: ObservableObject { 20 | @Published var username = "" 21 | @Published var password = "" 22 | let loginTrigger: Driver 23 | let toRegisterTrigger: Driver 24 | 25 | init(loginTrigger: Driver, toRegisterTrigger: Driver) { 26 | self.loginTrigger = loginTrigger 27 | self.toRegisterTrigger = toRegisterTrigger 28 | } 29 | } 30 | 31 | final class Output: ObservableObject { 32 | @Published var isLoginEnabled = true 33 | @Published var isLoading = false 34 | @Published var usernameValidationMessage = "" 35 | @Published var passwordValidationMessage = "" 36 | } 37 | 38 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 39 | let errorTracker = ErrorTracker() 40 | let activityTracker = ActivityTracker() 41 | let output = Output() 42 | 43 | let usernameValidation = Publishers 44 | .CombineLatest(input.$username, input.loginTrigger) 45 | .map { $0.0 } 46 | .map(authUseCase.validateEmail(email:)) 47 | 48 | usernameValidation 49 | .asDriver() 50 | .map { $0.message } 51 | .assign(to: \.usernameValidationMessage, on: output) 52 | .cancel(with: cancelBag) 53 | 54 | let passwordValidation = Publishers 55 | .CombineLatest(input.$password, input.loginTrigger) 56 | .map { $0.0 } 57 | .map(authUseCase.validatePassword(password:)) 58 | 59 | passwordValidation 60 | .asDriver() 61 | .map { $0.message } 62 | .assign(to: \.passwordValidationMessage, on: output) 63 | .cancel(with: cancelBag) 64 | 65 | Publishers 66 | .CombineLatest(usernameValidation, passwordValidation) 67 | .map { $0.0.isValid && $0.1.isValid } 68 | .assign(to: \.isLoginEnabled, on: output) 69 | .cancel(with: cancelBag) 70 | 71 | input.loginTrigger 72 | .delay(for: 0.1, scheduler: RunLoop.main) 73 | .filter { output.isLoginEnabled } 74 | .map { _ in 75 | self.authUseCase.login(email: input.username, password: input.password) 76 | .trackError(errorTracker) 77 | .trackActivity(activityTracker) 78 | .asDriver() 79 | } 80 | .switchToLatest() 81 | .sink(receiveValue: { 82 | authUseCase.setIsLoggedIn(true) 83 | }) 84 | .cancel(with: cancelBag) 85 | 86 | input.toRegisterTrigger 87 | .sink(receiveValue: navigator.toRegister) 88 | .cancel(with: cancelBag) 89 | 90 | activityTracker.isLoading 91 | .receive(on: RunLoop.main) 92 | .assign(to: \.isLoading, on: output) 93 | .cancel(with: cancelBag) 94 | 95 | errorTracker 96 | .receive(on: RunLoop.main) 97 | .unwrap() 98 | .sink(receiveValue: { error in 99 | navigator.showError(message: error.localizedDescription) 100 | }) 101 | .cancel(with: cancelBag) 102 | 103 | return output 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Onboarding/OnboardingNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol OnboardingNavigatorType { 12 | func toLogin() 13 | } 14 | 15 | struct OnboardingNavigator: OnboardingNavigatorType { 16 | let navigation: AuthRootNavigatorType 17 | 18 | func toLogin() { 19 | navigation.replace(linkItem: .init(routePath: .login), isAnimated: false) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Onboarding/OnboardingScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingScreen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 16/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnboardingScreen: View { 11 | private var input: OnboardingViewModel.Input 12 | @ObservedObject private var output: OnboardingViewModel.Output 13 | 14 | private let cancelBag = CancelBag() 15 | private let setIsDoneOnboarding = PublishRelay() 16 | 17 | @State var selectedPage = 0 18 | private var pages = OnboardingPage.allCases 19 | private var isLastPage: Bool { 20 | return selectedPage == pages.count - 1 21 | } 22 | 23 | var body: some View { 24 | Screen { 25 | TabView(selection: $selectedPage) { 26 | ForEach(pages.indices, id: \.self) { index in 27 | page(pages[index]) 28 | .tag(index) 29 | } 30 | } 31 | .tabViewStyle(.page) 32 | .indexViewStyle(.page(backgroundDisplayMode: .always)) 33 | .navigationBarHidden(true) 34 | } 35 | } 36 | 37 | @ViewBuilder 38 | func page(_ page: OnboardingPage) -> some View { 39 | VStack { 40 | page.image 41 | .padding(.vertical, Spacing.small.value) 42 | 43 | Text(page.title) 44 | .font(.title) 45 | .foregroundStyle(Color(R.color.labelPrimary)) 46 | 47 | Text(page.description) 48 | .font(.callout) 49 | .multilineTextAlignment(.center) 50 | .foregroundStyle(Color(R.color.labelPrimary)) 51 | 52 | if isLastPage { 53 | BaseButton(title: R.string.localizable.onboardingGetStarted()) { 54 | setIsDoneOnboarding.send(()) 55 | } 56 | .frame(width: 240, height: 52) 57 | .padding(.top, Spacing.normal.value) 58 | } 59 | } 60 | .padding(Spacing.normal.value) 61 | } 62 | 63 | init(viewModel: OnboardingViewModel) { 64 | let input = OnboardingViewModel.Input(setIsDoneOnboarding: setIsDoneOnboarding.asDriver()) 65 | output = viewModel.transform(input, cancelBag: cancelBag) 66 | self.input = input 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Onboarding/OnboardingViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | struct OnboardingViewModel { 13 | let navigator: OnboardingNavigatorType 14 | @Injected(\.authUseCase) var authUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension OnboardingViewModel: ViewModel { 19 | struct Input { 20 | let setIsDoneOnboarding: Driver 21 | } 22 | 23 | final class Output: ObservableObject {} 24 | 25 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 26 | let output = Output() 27 | 28 | input.setIsDoneOnboarding 29 | .sink(receiveValue: { 30 | authUseCase.updateOnboardingStatus(isDone: true) 31 | navigator.toLogin() 32 | }) 33 | .cancel(with: cancelBag) 34 | 35 | return output 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Register/RegisterNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegisterNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 21/04/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol RegisterNavigatorType { 12 | func toLogin() 13 | func showError(message: String) 14 | func showRegistrationSuccess() 15 | } 16 | 17 | struct RegisterNavigator: RegisterNavigatorType { 18 | let navigation: AuthRootNavigatorType 19 | 20 | func toLogin() { 21 | navigation.backOrNext(linkItem: .init(routePath: .login), isAnimated: true) 22 | } 23 | 24 | func showError(message: String) { 25 | let action = ActionButton(title: "OK", style: .cancel) 26 | let alert = Alert(message: message, buttons: [action], flagType: .error) 27 | navigation.alert(target: .root, model: alert) 28 | } 29 | 30 | func showRegistrationSuccess() { 31 | let action = ActionButton(title: "OK", style: .default) { 32 | navigation.backOrNext(linkItem: .init(routePath: .login), isAnimated: true) 33 | } 34 | let alert = Alert(message: "Registration complete! Please sign in.", buttons: [action], flagType: .default) 35 | navigation.alert(target: .root, model: alert) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Register/RegisterScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 15/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | struct RegisterScreen: View { 12 | @ObservedObject private var input: RegisterViewModel.Input 13 | @ObservedObject private var output: RegisterViewModel.Output 14 | 15 | private let cancelBag = CancelBag() 16 | private let registerTrigger = PublishRelay() 17 | private let toLoginTrigger = PublishRelay() 18 | 19 | var body: some View { 20 | Screen(isLoading: $output.isLoading) { 21 | VScrollView { 22 | VStack { 23 | Text(R.string.localizable.commonRegister()) 24 | .font(.title) 25 | .padding(.bottom, Spacing.medium.value) 26 | .foregroundStyle(Color(R.color.primary)) 27 | 28 | BaseTextField(text: $input.email, 29 | placeholder: R.string.localizable.commonUsername(), 30 | image: Image(R.image.icon_email), 31 | errorMessage: output.usernameValidationMessage) 32 | .padding(.bottom, Spacing.small.value) 33 | 34 | BaseTextField(text: $input.password, 35 | placeholder: R.string.localizable.commonPassword(), 36 | image: Image(R.image.icon_password), 37 | isSecure: true, 38 | errorMessage: output.passwordValidationMessage) 39 | .padding(.bottom, Spacing.small.value) 40 | 41 | BaseTextField(text: $input.confirmPassword, 42 | placeholder: R.string.localizable.commonConfirmPassword(), 43 | image: Image(R.image.icon_password), 44 | isSecure: true, 45 | errorMessage: output.confirmPasswordValidationMessage) 46 | .padding(.bottom, Spacing.small.value) 47 | 48 | BaseButton(title: R.string.localizable.commonRegister(), isEnabled: output.isRegisterEnabled) { 49 | registerTrigger.send(()) 50 | } 51 | .frame(maxWidth: .infinity) 52 | .frame(height: 52) 53 | 54 | Text(R.string.localizable.commonAlreadyHaveAccount()) 55 | .padding(.top, Spacing.extraLarge.value) 56 | 57 | Button { 58 | toLoginTrigger.send(()) 59 | } label: { 60 | Text(R.string.localizable.commonLogin()) 61 | .foregroundStyle(Color(R.color.primary)) 62 | } 63 | } 64 | .padding(.horizontal) 65 | } 66 | } 67 | .navigationBarHidden(true) 68 | } 69 | 70 | init(viewModel: RegisterViewModel) { 71 | let input = RegisterViewModel.Input(registerTrigger: registerTrigger.asDriver(), 72 | toLoginTrigger: toLoginTrigger.asDriver()) 73 | output = viewModel.transform(input, cancelBag: cancelBag) 74 | self.input = input 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Auth/Register/RegisterViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegisterViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 21/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | struct RegisterViewModel { 13 | let navigator: RegisterNavigatorType 14 | @Injected(\.authUseCase) var authUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension RegisterViewModel: ViewModel { 19 | final class Input: ObservableObject { 20 | @Published var email = "" 21 | @Published var password = "" 22 | @Published var confirmPassword = "" 23 | 24 | let registerTrigger: Driver 25 | let toLoginTrigger: Driver 26 | 27 | init(registerTrigger: Driver, toLoginTrigger: Driver) { 28 | self.registerTrigger = registerTrigger 29 | self.toLoginTrigger = toLoginTrigger 30 | } 31 | } 32 | 33 | final class Output: ObservableObject { 34 | @Published var isRegisterEnabled = true 35 | @Published var isLoading = false 36 | @Published var usernameValidationMessage = "" 37 | @Published var passwordValidationMessage = "" 38 | @Published var confirmPasswordValidationMessage = "" 39 | } 40 | 41 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 42 | let errorTracker = ErrorTracker() 43 | let activityTracker = ActivityTracker() 44 | let output = Output() 45 | 46 | let usernameValidation = Publishers 47 | .CombineLatest(input.$email, input.registerTrigger) 48 | .map { $0.0 } 49 | .map(authUseCase.validateEmail(email:)) 50 | 51 | usernameValidation 52 | .asDriver() 53 | .map { $0.message } 54 | .assign(to: \.usernameValidationMessage, on: output) 55 | .cancel(with: cancelBag) 56 | 57 | let passwordValidation = Publishers 58 | .CombineLatest(input.$password, input.registerTrigger) 59 | .map { $0.0 } 60 | .map(authUseCase.validatePassword(password:)) 61 | 62 | passwordValidation 63 | .asDriver() 64 | .map { $0.message } 65 | .assign(to: \.passwordValidationMessage, on: output) 66 | .cancel(with: cancelBag) 67 | 68 | let confirmPasswordValidation = Publishers 69 | .CombineLatest3(input.$password.prepend(""), input.$confirmPassword, input.registerTrigger) 70 | .map { 71 | authUseCase.validateConfirmPassword(password: $0.0, confirmPassword: $0.1) 72 | } 73 | 74 | confirmPasswordValidation 75 | .asDriver() 76 | .map { $0.message } 77 | .assign(to: \.confirmPasswordValidationMessage, on: output) 78 | .cancel(with: cancelBag) 79 | 80 | Publishers 81 | .CombineLatest3(usernameValidation, passwordValidation, confirmPasswordValidation) 82 | .map { $0.0.isValid && $0.1.isValid && $0.2.isValid } 83 | .assign(to: \.isRegisterEnabled, on: output) 84 | .cancel(with: cancelBag) 85 | 86 | input.registerTrigger 87 | .delay(for: 0.1, scheduler: RunLoop.main) 88 | .filter { output.isRegisterEnabled } 89 | .map { _ in 90 | self.authUseCase.register(email: input.email, password: input.password) 91 | .trackError(errorTracker) 92 | .trackActivity(activityTracker) 93 | .asDriver() 94 | } 95 | .switchToLatest() 96 | .sink(receiveValue: navigator.showRegistrationSuccess) 97 | .cancel(with: cancelBag) 98 | 99 | input.toLoginTrigger 100 | .sink(receiveValue: navigator.toLogin) 101 | .cancel(with: cancelBag) 102 | 103 | activityTracker.isLoading 104 | .receive(on: RunLoop.main) 105 | .assign(to: \.isLoading, on: output) 106 | .cancel(with: cancelBag) 107 | 108 | errorTracker 109 | .receive(on: RunLoop.main) 110 | .unwrap() 111 | .sink(receiveValue: { error in 112 | navigator.showError(message: error.localizedDescription) 113 | }) 114 | .cancel(with: cancelBag) 115 | 116 | return output 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Movie/MovieDetail/MovieDetailNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieDetailNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 23/04/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol MovieDetailNavigatorType { 12 | func showError(message: String) 13 | } 14 | 15 | struct MovieDetailNavigator: MovieDetailNavigatorType { 16 | let navigation: HomeRootNavigatorType 17 | 18 | func showError(message: String) { 19 | let action = ActionButton(title: "OK", style: .cancel) 20 | let alert = Alert(message: message, buttons: [action], flagType: .error) 21 | navigation.alert(model: alert) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Movie/MovieDetail/MovieDetailScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieDetailScreen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 23/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Kingfisher 10 | 11 | struct MovieDetailScreen: View { 12 | private var input: MovieDetailViewModel.Input 13 | @ObservedObject private var output: MovieDetailViewModel.Output 14 | 15 | private let cancelBag = CancelBag() 16 | private let loadTrigger = PublishRelay() 17 | 18 | @ViewBuilder 19 | func backdrop(movie: Movie) -> some View { 20 | ZStack(alignment: .bottom) { 21 | if let url = URL(string: movie.backdropUrl) { 22 | VStack(alignment: .leading) { 23 | KFImage(url) 24 | .resizable() 25 | .aspectRatio(contentMode: .fill) 26 | .frame(height: 210) 27 | .frame(maxWidth: .infinity) 28 | 29 | Spacer() 30 | .frame(height: 40) 31 | } 32 | } 33 | if let url = URL(string: movie.posterUrl) { 34 | HStack(alignment: .bottom) { 35 | KFImage(url) 36 | .resizable() 37 | .aspectRatio(contentMode: .fill) 38 | .frame(width: 90, height: 120) 39 | .cornerRadius(8) 40 | 41 | Spacer() 42 | } 43 | .padding(.horizontal, Spacing.normal.value) 44 | } 45 | } 46 | } 47 | 48 | @ViewBuilder 49 | func info(movie: Movie) -> some View { 50 | VStack { 51 | VStack(alignment: .leading) { 52 | HStack { 53 | Image(R.image.icon_rating) 54 | .frame(width: 16, height: 16) 55 | Text("\(Int(movie.voteAverage))/10") 56 | .font(.subheadline) 57 | .fontWeight(.bold) 58 | .foregroundColor(Color(R.color.orangeFlush)) 59 | } 60 | 61 | Text("\(R.string.localizable.movieRelease()): \(formatDate(movie.releaseDate))") 62 | .fontWeight(.bold) 63 | 64 | .foregroundStyle(Color(R.color.labelPrimary)) 65 | 66 | Text("\(movie.overview)") 67 | .padding(.top, Spacing.small.value) 68 | .foregroundStyle(Color(R.color.labelPrimary)) 69 | } 70 | .padding(.horizontal, Spacing.normal.value) 71 | 72 | Spacer() 73 | } 74 | .frame(maxWidth: .infinity, maxHeight: .infinity) 75 | } 76 | 77 | var body: some View { 78 | Screen(isLoading: $output.isLoading, title: output.movie?.title) { 79 | ScrollView { 80 | Group { 81 | if let movie = output.movie { 82 | VStack(spacing: 16) { 83 | backdrop(movie: movie) 84 | info(movie: movie) 85 | } 86 | } else { 87 | VStack {} 88 | .frame(maxWidth: .infinity, maxHeight: .infinity) 89 | } 90 | } 91 | } 92 | .refreshable { 93 | loadTrigger.send(true) 94 | } 95 | } 96 | .onAppear { 97 | loadTrigger.send(false) 98 | } 99 | } 100 | 101 | init(viewModel: MovieDetailViewModel) { 102 | let input = MovieDetailViewModel.Input(loadTrigger: loadTrigger.asDriver()) 103 | output = viewModel.transform(input, cancelBag: cancelBag) 104 | self.input = input 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Movie/MovieDetail/MovieDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieDetailViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 23/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | import Defaults 12 | 13 | struct MovieDetailViewModel { 14 | let navigator: MovieDetailNavigatorType 15 | let id: Int 16 | @Injected(\.movieUseCase) private var movieUseCase 17 | } 18 | 19 | // MARK: - ViewModelType 20 | extension MovieDetailViewModel: ViewModel { 21 | struct Input { 22 | let loadTrigger: Driver 23 | } 24 | 25 | final class Output: ObservableObject { 26 | @Published var isLoading = false 27 | @Published var isReloading = false 28 | @Published var movie: Movie? 29 | } 30 | 31 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 32 | let output = Output() 33 | let activityTracker = ActivityTracker() 34 | let reloadActivityTracker = ActivityTracker() 35 | let errorTracker = ErrorTracker() 36 | 37 | input.loadTrigger 38 | .map { isReload in 39 | self.movieUseCase.getMovieDetail(id: id) 40 | .trackError(errorTracker) 41 | .trackActivity(isReload ? reloadActivityTracker : activityTracker) 42 | .asDriver() 43 | } 44 | .switchToLatest() 45 | .assign(to: \.movie, on: output) 46 | .cancel(with: cancelBag) 47 | 48 | activityTracker.isLoading 49 | .receive(on: RunLoop.main) 50 | .assign(to: \.isLoading, on: output) 51 | .cancel(with: cancelBag) 52 | 53 | reloadActivityTracker.isLoading 54 | .receive(on: RunLoop.main) 55 | .assign(to: \.isReloading, on: output) 56 | .cancel(with: cancelBag) 57 | 58 | errorTracker 59 | .receive(on: RunLoop.main) 60 | .unwrap() 61 | .sink(receiveValue: { error in 62 | navigator.showError(message: error.localizedDescription) 63 | }) 64 | .cancel(with: cancelBag) 65 | 66 | return output 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Movie/TopMovies/TopMoviesNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopMoviesNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol TopMoviesNavigatorType { 12 | func showError(message: String) 13 | func toMovieDetail(id: Int) 14 | } 15 | 16 | struct TopMoviesNavigator: TopMoviesNavigatorType { 17 | let navigation: HomeRootNavigatorType 18 | 19 | func showError(message: String) { 20 | let action = ActionButton(title: "OK", style: .cancel) 21 | let alert = Alert(message: message, buttons: [action], flagType: .error) 22 | navigation.alert(model: alert) 23 | } 24 | 25 | func toMovieDetail(id: Int) { 26 | let item = LinkItem(routePath: .movieDetail, items: MoviesDetailParams(id: id)) 27 | navigation.next(linkItem: item, isAnimated: true) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Movie/TopMovies/TopMoviesScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopMoviesScreen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TopMoviesScreen: View { 11 | private var input: TopMoviesViewModel.Input 12 | @ObservedObject private var output: TopMoviesViewModel.Output 13 | 14 | private let cancelBag = CancelBag() 15 | private let toDetailTrigger = PublishRelay() 16 | private let loadTrigger = PublishRelay() 17 | 18 | @ViewBuilder 19 | func upcomingMovies() -> some View { 20 | Section(title: R.string.localizable.movieUpcoming()) { 21 | ScrollView(.horizontal, showsIndicators: false) { 22 | LazyHStack(spacing: 16) { 23 | ForEach(output.data.upcoming) { movie in 24 | Button { 25 | toDetailTrigger.send(movie.id) 26 | } label: { 27 | HorizontalMovieCard(movie: movie) 28 | } 29 | .tint(.black) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | @ViewBuilder 37 | func topRatedMovies() -> some View { 38 | Section(title: R.string.localizable.movieTopRated()) { 39 | ForEach(output.data.topRated) { movie in 40 | Button { 41 | toDetailTrigger.send(movie.id) 42 | } label: { 43 | VerticalMovieCard(movie: movie) 44 | } 45 | .tint(.black) 46 | } 47 | } 48 | } 49 | 50 | var body: some View { 51 | Screen(isLoading: $output.isLoading, title: R.string.localizable.movieWatchTitle()) { 52 | ScrollView(showsIndicators: false) { 53 | VStack(alignment: .leading, spacing: 16) { 54 | if !output.data.upcoming.isEmpty { 55 | upcomingMovies() 56 | } 57 | if !output.data.topRated.isEmpty { 58 | topRatedMovies() 59 | } 60 | } 61 | } 62 | .padding(Spacing.normal.value) 63 | .refreshable { 64 | loadTrigger.send(true) 65 | } 66 | } 67 | .onAppear { 68 | loadTrigger.send(false) 69 | } 70 | } 71 | 72 | init(viewModel: TopMoviesViewModel) { 73 | let input = TopMoviesViewModel.Input(loadTrigger: loadTrigger.asDriver(), 74 | toDetailTrigger: toDetailTrigger.asDriver()) 75 | output = viewModel.transform(input, cancelBag: cancelBag) 76 | self.input = input 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Movie/TopMovies/TopMoviesViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TopMoviesViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | struct TopMoviesViewModel { 13 | let navigator: TopMoviesNavigatorType 14 | @Injected(\.movieUseCase) private var movieUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension TopMoviesViewModel: ViewModel { 19 | struct MoviesData { 20 | var topRated: [Movie] = [] 21 | var upcoming: [Movie] = [] 22 | } 23 | 24 | struct Input { 25 | let loadTrigger: Driver 26 | let toDetailTrigger: Driver 27 | } 28 | 29 | final class Output: ObservableObject { 30 | @Published var isLoading = false 31 | @Published var isReloading = false 32 | @Published var data = MoviesData() 33 | } 34 | 35 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 36 | let errorTracker = ErrorTracker() 37 | let activityTracker = ActivityTracker() 38 | let reloadActivityTracker = ActivityTracker() 39 | let output = Output() 40 | 41 | let upcoming = input.loadTrigger 42 | .map { isReload in 43 | self.movieUseCase.getUpcomingMovies(page: 1) 44 | .trackError(errorTracker) 45 | .trackActivity(isReload ? reloadActivityTracker : activityTracker) 46 | .asDriver() 47 | } 48 | .switchToLatest() 49 | 50 | let topRated = input.loadTrigger 51 | .map { isReload in 52 | self.movieUseCase.getTopRatedMovies(page: 1) 53 | .trackError(errorTracker) 54 | .trackActivity(isReload ? reloadActivityTracker : activityTracker) 55 | .asDriver() 56 | } 57 | .switchToLatest() 58 | 59 | Publishers.Zip(upcoming, topRated) 60 | .map { 61 | MoviesData(topRated: $0.0, upcoming: $0.1) 62 | } 63 | .assign(to: \.data, on: output) 64 | .cancel(with: cancelBag) 65 | 66 | activityTracker.isLoading 67 | .receive(on: RunLoop.main) 68 | .assign(to: \.isLoading, on: output) 69 | .cancel(with: cancelBag) 70 | 71 | reloadActivityTracker.isLoading 72 | .receive(on: RunLoop.main) 73 | .assign(to: \.isReloading, on: output) 74 | .cancel(with: cancelBag) 75 | 76 | errorTracker 77 | .receive(on: RunLoop.main) 78 | .unwrap() 79 | .sink(receiveValue: { error in 80 | navigator.showError(message: error.localizedDescription) 81 | }) 82 | .cancel(with: cancelBag) 83 | 84 | input.toDetailTrigger 85 | .sink(receiveValue: navigator.toMovieDetail(id:)) 86 | .cancel(with: cancelBag) 87 | 88 | return output 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Settings/SettingsNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SettingsNavigatorType {} 11 | 12 | struct SettingsNavigator: SettingsNavigatorType { 13 | let navigation: HomeRootNavigatorType 14 | } 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Settings/SettingsScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsScreen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsScreen: View { 11 | private var input: SettingsViewModel.Input 12 | @ObservedObject private var output: SettingsViewModel.Output 13 | 14 | private let cancelBag = CancelBag() 15 | private let logoutTrigger = PublishRelay() 16 | private let toggleDarkModeTrigger = PublishRelay() 17 | private let toggleLanguageTrigger = PublishRelay() 18 | 19 | var body: some View { 20 | Screen { 21 | ScrollView { 22 | VStack(spacing: Spacing.normal.value) { 23 | SettingItem(title: R.string.localizable.settingsDarkmode(), 24 | value: output.isDarkMode) { 25 | toggleDarkModeTrigger.send(()) 26 | } 27 | 28 | SettingItem(title: R.string.localizable.languageJapanese(), 29 | value: output.isJapanese) { 30 | toggleLanguageTrigger.send(()) 31 | } 32 | 33 | SettingItem(title: R.string.localizable.settingsLogout()) { 34 | logoutTrigger.send(()) 35 | } 36 | } 37 | .padding(Spacing.normal.value) 38 | } 39 | } 40 | } 41 | 42 | init(viewModel: SettingsViewModel) { 43 | let input = SettingsViewModel.Input(loadTrigger: Driver.just(()), 44 | logoutTrigger: logoutTrigger.asDriver(), 45 | toggleDarkModeTrigger: toggleDarkModeTrigger.asDriver(), 46 | toggleLanguageTrigger: toggleLanguageTrigger.asDriver()) 47 | output = viewModel.transform(input, cancelBag: cancelBag) 48 | self.input = input 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Settings/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | import Factory 10 | import Combine 11 | 12 | struct SettingsViewModel { 13 | let navigator: SettingsNavigatorType 14 | @Injected(\.settingsUseCase) var settingsUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension SettingsViewModel: ViewModel { 19 | struct Input { 20 | let loadTrigger: Driver 21 | let logoutTrigger: Driver 22 | let toggleDarkModeTrigger: Driver 23 | let toggleLanguageTrigger: Driver 24 | } 25 | 26 | final class Output: ObservableObject { 27 | @Published var isJapanese = false 28 | @Published var isDarkMode = false 29 | } 30 | 31 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 32 | let output = Output() 33 | 34 | input.loadTrigger 35 | .map { 36 | self.settingsUseCase.getCurrentLanguage() == SupportedLanguage.japanese.code 37 | } 38 | .assign(to: \.isJapanese, on: output) 39 | .cancel(with: cancelBag) 40 | 41 | input.loadTrigger 42 | .map { 43 | self.settingsUseCase.getDarkModeStatus() 44 | } 45 | .assign(to: \.isDarkMode, on: output) 46 | .cancel(with: cancelBag) 47 | 48 | input.toggleDarkModeTrigger 49 | .map { 50 | self.settingsUseCase.toggleDarkMode() 51 | } 52 | .map { 53 | self.settingsUseCase.getDarkModeStatus() 54 | } 55 | .assign(to: \.isDarkMode, on: output) 56 | .cancel(with: cancelBag) 57 | 58 | input.toggleLanguageTrigger 59 | .map { 60 | let current = self.settingsUseCase.getCurrentLanguage() 61 | let newLanguage = current == SupportedLanguage.japanese.code ? SupportedLanguage.english.code : SupportedLanguage.japanese.code 62 | self.settingsUseCase.setLanguage(newLanguage) 63 | } 64 | .map { 65 | self.settingsUseCase.getCurrentLanguage() == SupportedLanguage.japanese.code 66 | } 67 | .assign(to: \.isJapanese, on: output) 68 | .cancel(with: cancelBag) 69 | 70 | input.logoutTrigger 71 | .map { 72 | self.settingsUseCase.logout() 73 | } 74 | .sink(receiveValue: {}) 75 | .cancel(with: cancelBag) 76 | 77 | return output 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/ListTodo/ListTodoNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListTodoNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 13/05/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol ListTodoNavigatorType {} 12 | 13 | struct ListTodoNavigator: ListTodoNavigatorType {} 14 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/ListTodo/ListTodoScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListTodoScreen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 13/05/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ListTodoScreen: View { 11 | @ObservedObject private var output: ListTodoViewModel.Output 12 | private var input: ListTodoViewModel.Input 13 | 14 | private let cancelBag = CancelBag() 15 | private let updateCompleted = PublishRelay() 16 | private let deleteItemTrigger = PublishRelay() 17 | 18 | var body: some View { 19 | Screen(title: output.title) { 20 | ScrollView(showsIndicators: false) { 21 | LazyVStack(spacing: 16) { 22 | ForEach(output.todoItems) { item in 23 | todoItem(item: item) 24 | .onTapGesture { 25 | updateCompleted.send(item) 26 | } 27 | } 28 | } 29 | } 30 | .padding() 31 | } 32 | } 33 | 34 | @ViewBuilder 35 | func todoItem(item: TodoItem) -> some View { 36 | HStack { 37 | VStack(alignment: .leading) { 38 | VStack(alignment: .leading) { 39 | Text(formatDate(item.date)) 40 | .foregroundColor(Color(R.color.primary)) 41 | .strikethrough(item.isCompleted) 42 | 43 | Text(item.name) 44 | .fontWeight(.bold) 45 | .foregroundColor(Color(R.color.labelPrimary)) 46 | .strikethrough(item.isCompleted) 47 | 48 | Text(item.note) 49 | .foregroundColor(Color(R.color.labelPrimary)) 50 | .strikethrough(item.isCompleted) 51 | } 52 | } 53 | Spacer() 54 | VStack { 55 | Image(systemName: item.isCompleted ? "checkmark.square.fill" : "square") 56 | .foregroundColor(item.isCompleted ? Color(R.color.primary) : .gray) 57 | .font(.system(size: 25)) 58 | 59 | Spacer() 60 | .frame(height: Spacing.small.value) 61 | 62 | Button(R.string.localizable.commonDelete()) { 63 | deleteItemTrigger.send(item) 64 | } 65 | .tint(Color(R.color.primary)) 66 | } 67 | } 68 | .padding() 69 | .background(Color(R.color.todoCardBackground)) 70 | .cornerRadius(10) 71 | } 72 | 73 | init(viewModel: ListTodoViewModel) { 74 | let input = ListTodoViewModel.Input( 75 | loadTrigger: Driver.just(()), 76 | updateCompleted: updateCompleted.asDriver(), 77 | deleteItem: deleteItemTrigger.asDriver() 78 | ) 79 | output = viewModel.transform(input, cancelBag: cancelBag) 80 | self.input = input 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/ListTodo/ListTodoViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListTodoViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 13/05/2024. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | 12 | struct ListTodoViewModel { 13 | let navigator: ListTodoNavigatorType 14 | let category: TodoCategory 15 | @Injected(\.todoUseCase) var todoUseCase 16 | } 17 | 18 | // MARK: - ViewModelType 19 | extension ListTodoViewModel: ViewModel { 20 | struct Input { 21 | let loadTrigger: Driver 22 | let updateCompleted: Driver 23 | let deleteItem: Driver 24 | } 25 | 26 | final class Output: ObservableObject { 27 | @Published var title = "" 28 | @Published var todoItems: [TodoItem] = [] 29 | } 30 | 31 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 32 | let output = Output() 33 | 34 | Driver.just(category.name) 35 | .assign(to: \.title, on: output) 36 | .cancel(with: cancelBag) 37 | 38 | input.loadTrigger 39 | .map { 40 | self.todoUseCase.getTodos(category: category) 41 | .asDriver() 42 | } 43 | .switchToLatest() 44 | .assign(to: \.todoItems, on: output) 45 | .cancel(with: cancelBag) 46 | 47 | input.updateCompleted 48 | .sink(receiveValue: todoUseCase.updateCompleted(item:)) 49 | .cancel(with: cancelBag) 50 | 51 | input.deleteItem 52 | .map { 53 | self.todoUseCase.deleteTodo(item: $0) 54 | .asDriver() 55 | } 56 | .map { _ in 57 | self.todoUseCase.getTodos(category: category) 58 | .asDriver() 59 | } 60 | .switchToLatest() 61 | .assign(to: \.todoItems, on: output) 62 | .cancel(with: cancelBag) 63 | 64 | return output 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/NewTodo/NewTodoNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewTodoNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol NewTodoNavigatorType { 12 | func close() 13 | func showError(message: String) 14 | } 15 | 16 | struct NewTodoNavigator: NewTodoNavigatorType { 17 | let navigation: HomeRootNavigatorType 18 | 19 | func close() { 20 | navigation.close(isAnimated: true, completeAction: {}) 21 | } 22 | 23 | func showError(message: String) { 24 | let action = ActionButton(title: "OK", style: .cancel) 25 | let alert = Alert(message: message, buttons: [action], flagType: .error) 26 | navigation.alert(model: alert) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/NewTodo/NewTodoViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewTodoViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | 12 | struct NewTodoViewModel { 13 | let navigator: NewTodoNavigatorType 14 | @Injected(\.todoUseCase) var todoUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension NewTodoViewModel: ViewModel { 19 | final class Input: ObservableObject { 20 | @Published var note = "" 21 | @Published var selectedDate = Date() 22 | @Published var name = "" 23 | @Published var category = TodoCategory.all 24 | 25 | let loadTrigger: Driver 26 | let addTrigger: Driver 27 | let closeTrigger: Driver 28 | 29 | init(loadTrigger: Driver, 30 | addTrigger: Driver, 31 | closeTrigger: Driver) { 32 | self.loadTrigger = loadTrigger 33 | self.addTrigger = addTrigger 34 | self.closeTrigger = closeTrigger 35 | } 36 | } 37 | 38 | final class Output: ObservableObject {} 39 | 40 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 41 | input.closeTrigger 42 | .sink(receiveValue: navigator.close) 43 | .cancel(with: cancelBag) 44 | 45 | var isValidInput: Bool { 46 | return !input.name.isEmpty && !input.note.isEmpty 47 | } 48 | 49 | input.addTrigger 50 | .filter { !isValidInput } 51 | .sink(receiveValue: { 52 | navigator.showError(message: R.string.localizable.validationTodoItemInvalid()) 53 | }) 54 | .cancel(with: cancelBag) 55 | 56 | input.addTrigger 57 | .filter { isValidInput } 58 | .map { 59 | self.todoUseCase.addTodo(name: input.name, 60 | date: input.selectedDate, 61 | note: input.note, 62 | category: input.category) 63 | .asDriver() 64 | } 65 | .switchToLatest() 66 | .sink(receiveValue: navigator.close) 67 | .cancel(with: cancelBag) 68 | 69 | return Output() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/Todos/TodosNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosNavigator.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | import LinkNavigator 10 | 11 | protocol TodosNavigatorType { 12 | func toAddNew() 13 | func toTodoItems(category: TodoCategory) 14 | } 15 | 16 | struct TodosNavigator: TodosNavigatorType { 17 | let navigation: HomeRootNavigatorType 18 | 19 | func toAddNew() { 20 | let item = LinkItem(routePath: .newTodo) 21 | navigation.fullSheet(linkItem: item, isAnimated: true, prefersLargeTitles: false) 22 | } 23 | 24 | func toTodoItems(category: TodoCategory) { 25 | let item = LinkItem(routePath: .todoList, items: ListTodoParams(category: category)) 26 | navigation.next(linkItem: item, isAnimated: true) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/Todos/TodosScreen.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosScreen.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import Combine 11 | 12 | struct TodosScreen: View { 13 | private var input: TodosViewModel.Input 14 | @ObservedObject var output: TodosViewModel.Output 15 | 16 | private let cancelBag = CancelBag() 17 | private let loadTrigger = PublishRelay() 18 | private let toAddNew = PublishRelay() 19 | private let toTodoItems = PublishRelay() 20 | 21 | var body: some View { 22 | Screen(title: R.string.localizable.todoTitle()) { 23 | ZStack { 24 | if output.todoLists.isEmpty { 25 | emptyView() 26 | } else { 27 | todoListView() 28 | } 29 | addNewButton() 30 | } 31 | .frame(maxWidth: .infinity, maxHeight: .infinity) 32 | } 33 | .onAppear { 34 | loadTrigger.send(()) 35 | } 36 | } 37 | 38 | @ViewBuilder 39 | func todoListView() -> some View { 40 | ScrollView { 41 | VStack { 42 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 150, maximum: 200), 43 | spacing: Spacing.normal.value)], 44 | content: { 45 | ForEach(output.todoLists, id: \.category.id) { todo in 46 | TodoListItem(todoList: todo) 47 | .onTapGesture { 48 | print("onTapGesture \(todo.category.id)") 49 | toTodoItems.send(todo.category) 50 | } 51 | } 52 | }) 53 | .id(Defaults[.language]) 54 | Spacer() 55 | } 56 | .padding() 57 | } 58 | } 59 | 60 | @ViewBuilder 61 | func emptyView() -> some View { 62 | Image(R.image.todos_empty) 63 | .resizable() 64 | .frame(width: 200, height: 200, alignment: .center) 65 | .padding(.bottom, 40) 66 | } 67 | 68 | @ViewBuilder 69 | func addNewButton() -> some View { 70 | VStack { 71 | Spacer() 72 | 73 | HStack { 74 | Spacer() 75 | 76 | Button(action: { 77 | toAddNew.send(()) 78 | }, label: { 79 | Image(systemName: "plus.circle.fill") 80 | .resizable() 81 | .frame(width: 40, height: 40) 82 | .padding() 83 | .tint(Color(R.color.primary)) 84 | }) 85 | } 86 | } 87 | } 88 | 89 | init(viewModel: TodosViewModel) { 90 | let input = TodosViewModel.Input( 91 | loadTrigger: Publishers.Merge( 92 | Driver.just(()), 93 | loadTrigger.asDriver() 94 | ) 95 | .asDriver(), 96 | toAddNew: toAddNew.asDriver(), 97 | toTodoItems: toTodoItems.asDriver() 98 | ) 99 | output = viewModel.transform(input, cancelBag: cancelBag) 100 | self.input = input 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Scene/Home/Todo/Todos/TodosViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TodosViewModel.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 09/05/2024. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Factory 11 | 12 | struct TodosViewModel { 13 | let navigator: TodosNavigatorType 14 | @Injected(\.todoUseCase) var todoUseCase 15 | } 16 | 17 | // MARK: - ViewModelType 18 | extension TodosViewModel: ViewModel { 19 | struct Input { 20 | let loadTrigger: Driver 21 | let toAddNew: Driver 22 | let toTodoItems: Driver 23 | } 24 | 25 | final class Output: ObservableObject { 26 | @Published var todoLists: [TodoList] = [] 27 | } 28 | 29 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 30 | let output = Output() 31 | 32 | input.toAddNew 33 | .sink(receiveValue: navigator.toAddNew) 34 | .cancel(with: cancelBag) 35 | 36 | input.toTodoItems 37 | .sink(receiveValue: navigator.toTodoItems(category:)) 38 | .cancel(with: cancelBag) 39 | 40 | input.loadTrigger 41 | .map { 42 | self.todoUseCase.getTodoLists() 43 | .asDriver() 44 | } 45 | .switchToLatest() 46 | .assign(to: \.todoLists, on: output) 47 | .cancel(with: cancelBag) 48 | 49 | return output 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Type/Language.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Language.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | enum SupportedLanguage { 11 | case japanese 12 | case english 13 | 14 | var code: String { 15 | switch self { 16 | case .english: 17 | return "en" 18 | case .japanese: 19 | return "ja" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Type/Onboarding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Onboarding.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum OnboardingPage: CaseIterable { 11 | case page1 12 | case page2 13 | case page3 14 | 15 | var image: Image { 16 | switch self { 17 | case .page1: 18 | return Image(R.image.onboarding_page1) 19 | case .page2: 20 | return Image(R.image.onboarding_page2) 21 | case .page3: 22 | return Image(R.image.onboarding_page3) 23 | } 24 | } 25 | 26 | var title: String { 27 | switch self { 28 | case .page1: 29 | return R.string.localizable.onboardingPage1Title() 30 | case .page2: 31 | return R.string.localizable.onboardingPage2Title() 32 | case .page3: 33 | return R.string.localizable.onboardingPage3Title() 34 | } 35 | } 36 | 37 | var description: String { 38 | switch self { 39 | case .page1: 40 | return R.string.localizable.onboardingPage1Description() 41 | case .page2: 42 | return R.string.localizable.onboardingPage2Description() 43 | case .page3: 44 | return R.string.localizable.onboardingPage3Description() 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /BaseSwiftUI/Presentation/Type/TabbarItemType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabbarItemType.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | enum TabBarItemType: Int { 12 | case movies = 0 13 | case todos 14 | case settings 15 | 16 | static let allValues: [TabBarItemType] = [.movies, .todos, .settings] 17 | } 18 | 19 | extension TabBarItemType { 20 | var image: UIImage? { 21 | switch self { 22 | case .movies: 23 | return UIImage(systemName: "house") 24 | case .todos: 25 | return UIImage(systemName: "folder") 26 | case .settings: 27 | return UIImage(systemName: "person") 28 | } 29 | } 30 | 31 | var selectedImage: UIImage? { 32 | switch self { 33 | case .movies: 34 | return UIImage(systemName: "house.fill") 35 | case .todos: 36 | return UIImage(systemName: "folder.fill") 37 | case .settings: 38 | return UIImage(systemName: "person.fill") 39 | } 40 | } 41 | 42 | var title: String? { 43 | switch self { 44 | case .movies: 45 | return R.string.localizable.tabbarMovies() 46 | case .todos: 47 | return R.string.localizable.tabbarTodos() 48 | case .settings: 49 | return R.string.localizable.tabbarSettings() 50 | } 51 | } 52 | 53 | var tabbarItem: UITabBarItem { 54 | return UITabBarItem(title: title, image: image, selectedImage: selectedImage) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Architecture/ActivityTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityTracker.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Combine 9 | 10 | final class ActivityTracker: ObservableObject { 11 | @Published private var loadingCount: Int = 0 12 | 13 | var isLoading: AnyPublisher { 14 | $loadingCount 15 | .map { $0 > 0 } 16 | .removeDuplicates() 17 | .eraseToAnyPublisher() 18 | } 19 | 20 | private func increment() { 21 | loadingCount += 1 22 | } 23 | 24 | private func decrement() { 25 | loadingCount -= 1 26 | } 27 | 28 | func trackActivity(_ source: P) -> AnyPublisher { 29 | source 30 | .handleEvents(receiveSubscription: { [weak self] _ in self?.increment() }, 31 | receiveCompletion: { [weak self] _ in self?.decrement() }, 32 | receiveCancel: { [weak self] in self?.decrement() }) 33 | .eraseToAnyPublisher() 34 | } 35 | } 36 | 37 | extension Publisher { 38 | func trackActivity(_ activityIndicator: ActivityTracker) -> AnyPublisher { 39 | activityIndicator.trackActivity(self) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Architecture/ErrorTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorTracker.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Combine 9 | 10 | public typealias ErrorTracker = PassthroughSubject 11 | 12 | extension Publisher where Failure: Error { 13 | public func trackError(_ errorTracker: ErrorTracker) -> AnyPublisher { 14 | return handleEvents(receiveCompletion: { completion in 15 | if case let .failure(error) = completion { 16 | errorTracker.send(error) 17 | } 18 | }) 19 | .eraseToAnyPublisher() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Architecture/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityTracker.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 19/04/2024. 6 | // 7 | 8 | import Combine 9 | import Then 10 | 11 | public protocol ViewModel: Then { 12 | associatedtype Input 13 | associatedtype Output 14 | 15 | func transform(_ input: Input, cancelBag: CancelBag) -> Output 16 | } 17 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Extension/CancelBag.swift: -------------------------------------------------------------------------------- 1 | // https://github.com/devxoul/CancelBag 2 | 3 | import Combine 4 | import class Foundation.NSLock 5 | 6 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 7 | public final class CancelBag: Cancellable { 8 | private let lock = NSLock() 9 | private var cancellables: [Cancellable] = [] 10 | 11 | public init() {} 12 | 13 | internal func add(_ cancellable: Cancellable) { 14 | lock.lock() 15 | defer { self.lock.unlock() } 16 | cancellables.append(cancellable) 17 | } 18 | 19 | public func cancel() { 20 | lock.lock() 21 | let cancellables = cancellables 22 | self.cancellables.removeAll() 23 | lock.unlock() 24 | 25 | for cancellable in cancellables { 26 | cancellable.cancel() 27 | } 28 | } 29 | 30 | deinit { 31 | self.cancel() 32 | } 33 | } 34 | 35 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 36 | public extension Cancellable { 37 | func cancel(with cancellable: CancelBag) { 38 | cancellable.add(self) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Extension/Combine+Rx.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Combine+Rx.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 21/04/2024. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | public typealias Driver = AnyPublisher 12 | public typealias Observable = AnyPublisher 13 | public typealias PublishRelay = PassthroughSubject 14 | public typealias BehaviorRelay = CurrentValueSubject 15 | 16 | // MARK: - Driver 17 | extension Publisher { 18 | public func asDriver() -> Driver { 19 | return self.catch { _ in Empty() } 20 | .receive(on: RunLoop.main) 21 | .eraseToAnyPublisher() 22 | } 23 | 24 | public static func just(_ output: Output) -> Driver { 25 | return Just(output).eraseToAnyPublisher() 26 | } 27 | 28 | public static func empty() -> Driver { 29 | return Empty().eraseToAnyPublisher() 30 | } 31 | } 32 | 33 | // MARK: - Observable 34 | extension Publisher { 35 | public func asObservable() -> Observable { 36 | mapError { $0 } 37 | .eraseToAnyPublisher() 38 | } 39 | 40 | public static func just(_ output: Output) -> Observable { 41 | Just(output) 42 | .setFailureType(to: Error.self) 43 | .eraseToAnyPublisher() 44 | } 45 | 46 | public static func empty() -> Observable { 47 | return Empty().eraseToAnyPublisher() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Extension/Language+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RSwift+.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | import RswiftResources 9 | import Defaults 10 | 11 | extension StringResource { 12 | public func callAsFunction() -> String { 13 | String(resource: self, preferredLanguages: [Defaults[.language]]) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Extension/Publishers+Unwrap.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Publishers+Unwrap.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 21/04/2024. 6 | // 7 | 8 | import Combine 9 | 10 | public protocol OptionalType { 11 | associatedtype Wrapped 12 | var value: Wrapped? { get } 13 | } 14 | 15 | extension Optional: OptionalType { 16 | public var value: Wrapped? { 17 | return self 18 | } 19 | } 20 | 21 | struct Unwrapped: Publisher where Upstream: Publisher, Upstream.Output: OptionalType { 22 | typealias Output = Upstream.Output.Wrapped 23 | typealias Failure = Upstream.Failure 24 | 25 | let upstream: Upstream 26 | 27 | func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { 28 | upstream 29 | .flatMap { optional -> AnyPublisher in 30 | guard let unwrapped = optional.value else { 31 | return Empty().eraseToAnyPublisher() 32 | } 33 | return Just(unwrapped).setFailureType(to: Failure.self).eraseToAnyPublisher() 34 | } 35 | .receive(subscriber: subscriber) 36 | } 37 | } 38 | 39 | extension Publisher where Output: OptionalType { 40 | func unwrap() -> Unwrapped { 41 | return Unwrapped(upstream: self) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Extension/String+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | static func / (lhs: String, rhs: String) -> String { 12 | return lhs + "/" + rhs 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | BaseSwiftUI 4 | 5 | Created by phan.duong.ngoc on 19/04/2024. 6 | 7 | */ 8 | 9 | "onboarding.get.started" = "Get started"; 10 | "onboarding.page1.title" = "Add mymories"; 11 | "onboarding.page1.description" = "Capture mymories on the go and never miss important information."; 12 | "onboarding.page2.title" = "Easily organise"; 13 | "onboarding.page2.description" = "Organise mymories into similar groups under missions, goals or just storage."; 14 | "onboarding.page3.title" = "Simply ask"; 15 | "onboarding.page3.description" = "Ask anything relating to your mymories and get answers upfront. "; 16 | 17 | "common.register" = "Reigster"; 18 | "common.login" = "Login"; 19 | "common.username" = "Username"; 20 | "common.password" = "Password"; 21 | "common.confirm.password" = "Confirm Password"; 22 | "common.dont.have.account" = "Don’t have an account?"; 23 | "common.already.have.account" = "Already have an account?"; 24 | "common.see.more" = "See More >"; 25 | "common.tasks" = "Tasks"; 26 | "common.new.todo" = "New Task"; 27 | "common.delete" = "Delete"; 28 | 29 | "tabbar.movies" = "Movies"; 30 | "tabbar.todos" = "Todos"; 31 | "tabbar.settings" = "Settings"; 32 | 33 | "movie.upcoming" = "Upcoming"; 34 | "movie.top.rated" = "Top Rated"; 35 | "movie.release" = "Release"; 36 | "movie.watch.title" = "What do you want to watch?"; 37 | 38 | "settings.darkmode" = "Dark mode"; 39 | "settings.logout" = "Logout"; 40 | 41 | "language.english" = "English"; 42 | "language.japanese" = "Japanes"; 43 | 44 | "validation.email.invalid" = "Please enter a valid email address."; 45 | "validation.email.empty" = "Please enter an email address."; 46 | "validation.email.maxlength" = "Email address should be less than 128 characters."; 47 | "validation.password.invalid" = "Please enter a valid password."; 48 | "validation.password.empty" = "Please enter a password."; 49 | "validation.password.maxlength" = "Password should be less than 255 characters."; 50 | "validation.confirm.password.empty" = "Please enter a confirm password."; 51 | "validation.confirm.password.invalid" = "Password does not match"; 52 | "validation.todo.item.invalid" = "Please enter your note and name."; 53 | 54 | "todo.title" = "To-do List"; 55 | "todo.add" = "Add"; 56 | "todo.name" = "Name"; 57 | "todo.planning" = "What are your planning?"; 58 | "todo.select.date" = "Select date"; 59 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Resources/ja.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | BaseSwiftUI 4 | 5 | Created by phan.duong.ngoc on 19/04/2024. 6 | 7 | */ 8 | 9 | "onboarding.get.started" = "はじめる"; 10 | "onboarding.page1.title" = "マイムーリーズを追加"; 11 | "onboarding.page1.description" = "いつでも大切な情報をキャプチャして、マイムーリーズを手軽に保存しましょう。"; 12 | "onboarding.page2.title" = "簡単に整理"; 13 | "onboarding.page2.description" = "ミッション、目標、または単に保存場所に、似たようなグループにマイムーリーズを整理しましょう。"; 14 | "onboarding.page3.title" = "簡単に質問"; 15 | "onboarding.page3.description" = "マイムーリーズに関連する何かを尋ねて、すぐに答えを得ましょう。"; 16 | 17 | "common.register" = "登録する"; 18 | "common.login" = "ログイン"; 19 | "common.username" = "ユーザー名"; 20 | "common.password" = "パスワード"; 21 | "common.confirm.password" = "パスワードを確認"; 22 | "common.dont.have.account" = "アカウントをお持ちでないですか?"; 23 | "common.already.have.account" = "すでにアカウントをお持ちですか?"; 24 | "common.see.more" = "もっと見る >"; 25 | "common.tasks" = "タスク"; 26 | "common.new.todo" = "新しいタスク"; 27 | "common.delete" = "消去"; 28 | 29 | "tabbar.movies" = "映画"; 30 | "tabbar.todos" = "すべて"; 31 | "tabbar.settings" = "設定"; 32 | 33 | "movie.upcoming" = "今後の予定"; 34 | "movie.top.rated" = "トップ評価"; 35 | "movie.release" = "リリース"; 36 | "movie.watch.title" = "何を見たいですか?"; 37 | 38 | "settings.darkmode" = "ダークモード"; 39 | "settings.logout" = "ログアウト"; 40 | 41 | "language.english" = "英語"; 42 | "language.japanese" = "日本語"; 43 | 44 | "validation.email.invalid" = "有効なメールアドレスを入力してください。"; 45 | "validation.email.empty" = "メールアドレスを入力してください。"; 46 | "validation.email.maxlength" = "メールアドレスは128文字以下である必要があります。"; 47 | "validation.password.invalid" = "有効なパスワードを入力してください。"; 48 | "validation.password.empty" = "パスワードを入力してください。"; 49 | "validation.password.maxlength" = "パスワードは255文字以下である必要があります。"; 50 | "validation.confirm.password.empty" = "確認用のパスワードを入力してください。"; 51 | "validation.confirm.password.invalid" = "パスワードが一致しません。"; 52 | "validation.todo.item.invalid" = "メモと名前を入力してください"; 53 | 54 | "todo.title" = "やることリスト"; 55 | "todo.add" = "追加"; 56 | "todo.name" = "名前"; 57 | "todo.planning" = "計画は何ですか?"; 58 | "todo.select.date" = "日付を選択"; 59 | -------------------------------------------------------------------------------- /BaseSwiftUI/Utils/Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Utils.swift 3 | // BaseSwiftUI 4 | // 5 | // Created by phan.duong.ngoc on 22/04/2024. 6 | // 7 | 8 | import Foundation 9 | 10 | func infoForKey(_ key: String) -> String { 11 | return (Bundle.main.infoDictionary?[key] as? String)? 12 | .replacingOccurrences(of: "\\", with: "") ?? "" 13 | } 14 | 15 | func formatDate(_ inputDate: String) -> String { 16 | let inputFormatter = DateFormatter() 17 | inputFormatter.dateFormat = "yyyy-MM-dd" 18 | 19 | guard let date = inputFormatter.date(from: inputDate) else { 20 | return "" 21 | } 22 | 23 | let outputFormatter = DateFormatter() 24 | outputFormatter.dateFormat = "MMM dd, yyyy" 25 | 26 | return outputFormatter.string(from: date) 27 | } 28 | 29 | func formatDate(_ inputDate: Date) -> String { 30 | let dateFormatter = DateFormatter() 31 | dateFormatter.dateFormat = "EEEE(MM-dd) HH:mm" 32 | return dateFormatter.string(from: inputDate) 33 | } 34 | -------------------------------------------------------------------------------- /BaseSwiftUITests/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 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Mock/DI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DI.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | import Factory 10 | 11 | extension Container { 12 | var authUseCase: Factory { 13 | Factory(self) { AuthUseCaseMock() } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Mock/UseCase/AuthUseCaseMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthUseCaseMock.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | import Foundation 10 | import Combine 11 | 12 | final class AuthUseCaseMock: AuthUseCaseType { 13 | var updateOnboardingStatusCalled = false 14 | var isDoneOnboarding = false 15 | 16 | func updateOnboardingStatus(isDone: Bool) { 17 | updateOnboardingStatusCalled = true 18 | isDoneOnboarding = isDone 19 | } 20 | 21 | func validateEmail(email: String) -> ValidationResult { 22 | Validator.validateEmail(email).mapToVoid() 23 | } 24 | 25 | func validatePassword(password: String) -> ValidationResult { 26 | Validator.validatePassword(password).mapToVoid() 27 | } 28 | 29 | func validateConfirmPassword(password: String, confirmPassword: String) -> ValidationResult { 30 | Validator.validateConfirmPassword(password, confirmPassword: confirmPassword).mapToVoid() 31 | } 32 | 33 | var loginCalled = false 34 | var loginReturnValue: Result = .success(()) 35 | 36 | func login(email _: String, password _: String) -> Observable { 37 | loginCalled = true 38 | return loginReturnValue.publisher.eraseToAnyPublisher() 39 | } 40 | 41 | var isLoggedIn = false 42 | 43 | func setIsLoggedIn(_ value: Bool) { 44 | isLoggedIn = value 45 | } 46 | 47 | var registerCalled = false 48 | var registerReturnValue: Result = .success(()) 49 | 50 | func register(email _: String, password _: String) -> Observable { 51 | registerCalled = true 52 | return registerReturnValue.publisher.eraseToAnyPublisher() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Mock/UseCase/SettingsUseCaseMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsUseCaseMock.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 06/05/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | import Foundation 10 | import Combine 11 | 12 | final class SettingsUseCaseMock: SettingsUseCaseType { 13 | var getCurrentLanguageReturnValue = SupportedLanguage.english.code 14 | 15 | func getCurrentLanguage() -> String { 16 | return getCurrentLanguageReturnValue 17 | } 18 | 19 | var getDarkModeStatusReturnValue = false 20 | 21 | func getDarkModeStatus() -> Bool { 22 | return getDarkModeStatusReturnValue 23 | } 24 | 25 | var setLanguageCalled = false 26 | 27 | func setLanguage(_ language: String) { 28 | getCurrentLanguageReturnValue = language 29 | setLanguageCalled = true 30 | } 31 | 32 | var toggleDarkModeCalled = false 33 | 34 | func toggleDarkMode() { 35 | getDarkModeStatusReturnValue.toggle() 36 | toggleDarkModeCalled = true 37 | } 38 | 39 | var logoutCalled = false 40 | 41 | func logout() { 42 | logoutCalled = true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Scene/Login/LoginNavigatorMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginNavigatorMock.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | 10 | final class LoginNavigatorMock: LoginNavigatorType { 11 | var toRegisterCalled = false 12 | 13 | func toRegister() { 14 | toRegisterCalled = true 15 | } 16 | 17 | var showErrorCalled = false 18 | var errorMessage: String? 19 | 20 | func showError(message: String) { 21 | showErrorCalled = true 22 | errorMessage = message 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Scene/Login/LoginViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModelTests.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 24/04/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | import XCTest 10 | import Combine 11 | import Then 12 | 13 | final class LoginViewModelTests: XCTestCase { 14 | private var viewModel: LoginViewModel! 15 | private var navigator: LoginNavigatorMock! 16 | private var useCase: AuthUseCaseMock! 17 | 18 | private var input: LoginViewModel.Input! 19 | private var output: LoginViewModel.Output! 20 | private var cancelBag: CancelBag! 21 | 22 | private let loginTrigger = PublishRelay() 23 | private let toRegisterTrigger = PublishRelay() 24 | 25 | override func setUp() { 26 | super.setUp() 27 | navigator = LoginNavigatorMock() 28 | useCase = AuthUseCaseMock() 29 | viewModel = LoginViewModel(navigator: navigator).with { 30 | $0.authUseCase = useCase 31 | } 32 | 33 | input = LoginViewModel.Input(loginTrigger: loginTrigger.asDriver(), 34 | toRegisterTrigger: toRegisterTrigger.asDriver()) 35 | cancelBag = CancelBag() 36 | output = viewModel.transform(input, cancelBag: cancelBag) 37 | } 38 | 39 | func test_toRegisterTrigger_toRegister() { 40 | // act 41 | toRegisterTrigger.send(()) 42 | 43 | // assert 44 | wait { 45 | XCTAssertTrue(self.navigator.toRegisterCalled) 46 | } 47 | } 48 | 49 | func test_loginTrigger_withEmptyEmailEmptyPassword_showError_disableLoginButton() { 50 | // act 51 | input.username = "" 52 | input.password = "" 53 | loginTrigger.send(()) 54 | 55 | // assert 56 | wait { 57 | XCTAssertEqual(self.output.usernameValidationMessage, R.string.localizable.validationEmailEmpty()) 58 | XCTAssertEqual(self.output.passwordValidationMessage, R.string.localizable.validationPasswordEmpty()) 59 | XCTAssertFalse(self.output.isLoginEnabled) 60 | } 61 | } 62 | 63 | func test_loginTrigger_withInvalidEmail_showError_disableLoginButton() { 64 | // act 65 | input.username = "@gmail.com" 66 | input.password = "Aa@123456" 67 | loginTrigger.send(()) 68 | 69 | // assert 70 | wait { 71 | XCTAssertEqual(self.output.usernameValidationMessage, R.string.localizable.validationEmailInvalid()) 72 | XCTAssertFalse(self.output.isLoginEnabled) 73 | } 74 | } 75 | 76 | func test_loginTrigger_withInvalidPassword_showError_disableLoginButton() { 77 | // act 78 | input.username = "foo@gmail.com" 79 | input.password = "Aa@" 80 | loginTrigger.send(()) 81 | 82 | // assert 83 | wait { 84 | XCTAssertEqual(self.output.passwordValidationMessage, R.string.localizable.validationPasswordInvalid()) 85 | XCTAssertFalse(self.output.isLoginEnabled) 86 | } 87 | } 88 | 89 | func test_loginTrigger_withValidEmailPassword_loginSuccess() { 90 | // act 91 | input.username = "foo@gmail.com" 92 | input.password = "Aa@123456" 93 | loginTrigger.send(()) 94 | 95 | // assert 96 | wait { 97 | XCTAssertEqual(self.output.usernameValidationMessage, "") 98 | XCTAssertEqual(self.output.passwordValidationMessage, "") 99 | XCTAssertTrue(self.output.isLoginEnabled) 100 | XCTAssertTrue(self.useCase.isLoggedIn) 101 | } 102 | } 103 | 104 | func test_loginTrigger_withValidEmailPassword_accountBannedError_showError() { 105 | // arrange 106 | useCase.loginReturnValue = .failure(AccountBannedError()) 107 | 108 | // act 109 | input.username = "foo@gmail.com" 110 | input.password = "Aa@123456" 111 | loginTrigger.send(()) 112 | 113 | // assert 114 | wait { 115 | XCTAssertEqual(self.output.usernameValidationMessage, "") 116 | XCTAssertEqual(self.output.passwordValidationMessage, "") 117 | XCTAssertTrue(self.output.isLoginEnabled) 118 | XCTAssertFalse(self.useCase.isLoggedIn) 119 | XCTAssertEqual(self.navigator.errorMessage, AccountBannedError.message) 120 | XCTAssertTrue(self.navigator.showErrorCalled) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Scene/Register/RegisterNavigatorMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegisterNavigatorMock.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 06/05/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | 10 | final class RegisterNavigatorMock: RegisterNavigatorType { 11 | var toLoginCalled = false 12 | 13 | func toLogin() { 14 | toLoginCalled = true 15 | } 16 | 17 | var showErrorCalled = false 18 | var errorMessage: String? 19 | 20 | func showError(message: String) { 21 | errorMessage = message 22 | showErrorCalled = true 23 | } 24 | 25 | var showRegistrationSuccessCalled = false 26 | 27 | func showRegistrationSuccess() { 28 | showRegistrationSuccessCalled = true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Scene/Settings/SettingsNavigatorMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsNavigatorMock.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 06/05/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | 10 | final class SettingsNavigatorMock: SettingsNavigatorType {} 11 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Scene/Settings/SettingsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsViewModelTests.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 06/05/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | import XCTest 10 | import Combine 11 | 12 | final class SettingsViewModelTests: XCTestCase { 13 | private var viewModel: SettingsViewModel! 14 | private var navigator: SettingsNavigatorMock! 15 | private var useCase: SettingsUseCaseMock! 16 | 17 | private var input: SettingsViewModel.Input! 18 | private var output: SettingsViewModel.Output! 19 | private var cancelBag: CancelBag! 20 | 21 | private let loadTrigger = PublishRelay() 22 | private let logoutTrigger = PublishRelay() 23 | private let toggleDarkModeTrigger = PublishRelay() 24 | private let toggleLanguageTrigger = PublishRelay() 25 | 26 | override func setUp() { 27 | super.setUp() 28 | navigator = SettingsNavigatorMock() 29 | useCase = SettingsUseCaseMock() 30 | viewModel = SettingsViewModel(navigator: navigator).with { 31 | $0.settingsUseCase = useCase 32 | } 33 | 34 | input = SettingsViewModel.Input(loadTrigger: loadTrigger.asDriver(), 35 | logoutTrigger: logoutTrigger.asDriver(), 36 | toggleDarkModeTrigger: toggleDarkModeTrigger.asDriver(), 37 | toggleLanguageTrigger: toggleLanguageTrigger.asDriver()) 38 | cancelBag = CancelBag() 39 | output = viewModel.transform(input, cancelBag: cancelBag) 40 | } 41 | 42 | func test_logoutTrigger_logout() { 43 | // act 44 | logoutTrigger.send(()) 45 | 46 | // assert 47 | wait { 48 | XCTAssertTrue(self.useCase.logoutCalled) 49 | } 50 | } 51 | 52 | func test_loadTrigger_getDarkModeStatusAndCurrentLanguage() { 53 | // act 54 | loadTrigger.send(()) 55 | 56 | // assert 57 | wait { 58 | XCTAssertEqual(self.output.isJapanese, false) 59 | XCTAssertEqual(self.output.isDarkMode, false) 60 | } 61 | } 62 | 63 | func test_toggleDarkModeTrigger_toggleDarkModeStatus() { 64 | // act 65 | useCase.getDarkModeStatusReturnValue = false 66 | toggleDarkModeTrigger.send(()) 67 | 68 | // assert 69 | wait { 70 | XCTAssertEqual(self.output.isDarkMode, true) 71 | } 72 | } 73 | 74 | func test_toggleLanguageTrigger_toggleLanguage() { 75 | // act 76 | useCase.getCurrentLanguageReturnValue = SupportedLanguage.english.code 77 | toggleLanguageTrigger.send(()) 78 | 79 | // assert 80 | wait { 81 | XCTAssertEqual(self.output.isJapanese, true) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Utils/Extension/XCTestCase+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | import XCTest 9 | 10 | extension XCTestCase { 11 | func wait(interval: TimeInterval = 0.3, completion: @escaping (() -> Void)) { 12 | let expectation = expectation(description: "") 13 | 14 | DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 15 | completion() 16 | expectation.fulfill() 17 | } 18 | 19 | waitForExpectations(timeout: interval + 0.1) // add 0.1 for sure asyn after called 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /BaseSwiftUITests/Utils/TestError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestError.swift 3 | // BaseSwiftUITests 4 | // 5 | // Created by phan.duong.ngoc on 25/04/2024. 6 | // 7 | 8 | @testable import BaseSwiftUI 9 | import Foundation 10 | 11 | struct TestError: APIError { 12 | static let message = "Test error message" 13 | 14 | var errorDescription: String? { 15 | return NSLocalizedString(TestError.message, 16 | value: "", 17 | comment: "") 18 | } 19 | } 20 | 21 | struct AccountBannedError: APIError { 22 | static let message = "Your account has been banned." 23 | 24 | var errorDescription: String? { 25 | return NSLocalizedString(AccountBannedError.message, 26 | value: "", 27 | comment: "") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clean Architecture using SwiftUI + Combine 2 | 3 | The example app uses the Clean Architecture approach with SwiftUI and Combine. 4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

15 | 16 | ## Features 17 | - **Xcodegen**: Automated project configuration. 18 | - **Clean Architecture**: Separation of concerns with clear boundaries between layers. 19 | - **SwiftUI**: Declarative UI framework for building modern iOS apps. 20 | - **Combine**: Reactive framework for handling asynchronous events and data streams. 21 | - **Dependency Injection**: Easy management of dependencies and testability. 22 | - **Unit Tests**: Includes unit tests for business logic and use cases. 23 | - **Dark Mode**: Programmatically supports Dark Mode switch. 24 | - **SwiftData**: SwiftData support. 25 | 26 | ## Requirements 27 | 28 | - Xcode 15.0+ 29 | - Swift 5.7+ 30 | - [Homebrew](https://brew.sh/) installed 31 | 32 | ## Installation 33 | 34 | 1. **Install Homebrew Dependencies**: 35 | 36 | ```bash 37 | brew install xcodegen swiftlint swiftformat 38 | ``` 39 | 40 | 2. **Clone the Repository**: 41 | 42 | ```bash 43 | git clone git@github.com:ngocpd-1250/clean_swiftui_combine.git 44 | cd BaseSwiftUI 45 | ``` 46 | 47 | 3. **Generate Xcode Project**: 48 | 49 | Use `xcodegen` to generate the Xcode project: 50 | 51 | ```bash 52 | xcodegen 53 | ``` 54 | 55 | 4. **Open Xcode**: 56 | 57 | Open the generated Xcode project: 58 | 59 | ```bash 60 | open BaseSwiftUI.xcodeproj 61 | ``` 62 | 63 | 5. **Build and Run**: 64 | 65 | Build and run the project in Xcode. 66 | 67 | ## Project Structure 68 | 69 | The project follows the Clean Architecture principles with the following layers: 70 | 71 | - **Presentation**: SwiftUI views, navigators and view models. 72 | - **Domain**: Business logic and use cases. 73 | - **Data**: Data sources and repositories. 74 | 75 | ## Linting 76 | 77 | 1. **Linting**: [SwiftLint](https://github.com/realm/SwiftLint) 78 | 2. **Formatting**: [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) 79 | 80 | ## Packages 81 | 82 | 1. [LinkNavigator](https://github.com/interactord/LinkNavigator.git): for navigation management 83 | 2. [Factory](https://github.com/hmlongco/Factory.git): for dependency injection 84 | 3. [Rswift](https://github.com/mac-cain13/R.swift.git): for resource management 85 | 4. [Kingfisher](https://github.com/onevcat/Kingfisher.git): for loading and caching remote images 86 | 5. [Alamofire](https://github.com/Alamofire/Alamofire.git): for API requests 87 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | # app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app 2 | # apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username 3 | 4 | 5 | # For more information about the Appfile, see: 6 | # https://docs.fastlane.tools/advanced/#appfile 7 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | 2 | default_platform(:ios) 3 | 4 | platform :ios do 5 | desc "Run Unit Test" 6 | lane :test_dev do 7 | run_tests(scheme: "BaseSwiftUI Dev", 8 | xcargs: "-skipPackagePluginValidation") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios test_dev 19 | 20 | ```sh 21 | [bundle exec] fastlane ios test_dev 22 | ``` 23 | 24 | Run Unit Test 25 | 26 | ---- 27 | 28 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 29 | 30 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 31 | 32 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 33 | -------------------------------------------------------------------------------- /fastlane/report.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /fastlane/test_output/report.junit: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /screenshots/add_todo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/add_todo.png -------------------------------------------------------------------------------- /screenshots/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/login.png -------------------------------------------------------------------------------- /screenshots/movie_detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/movie_detail.png -------------------------------------------------------------------------------- /screenshots/onboarding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/onboarding.png -------------------------------------------------------------------------------- /screenshots/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/register.png -------------------------------------------------------------------------------- /screenshots/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/settings.png -------------------------------------------------------------------------------- /screenshots/todo_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/todo_list.png -------------------------------------------------------------------------------- /screenshots/top_movie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ngocpd-1250/clean_swiftui_combine/23884c6f09a506d20130d7c3bbfc0a6a42b63be1/screenshots/top_movie.png --------------------------------------------------------------------------------