From 78141b4f08f714a55503afb1a514a351d82d3936 Mon Sep 17 00:00:00 2001 From: Olivier Pineau Date: Fri, 24 Nov 2023 14:58:45 -0500 Subject: [PATCH] Mirego-base-setup 5 of X: Navigation (#42) * Base nav * navigation viewmodels * open details * add close icon * iOS nav * For the font * override navigaiton type * fix stuff * Add navigation and UI on Android * Mirego-base-setup 7 of X: Killswitch (#39) * Analytics stuff * Analytics tracking * killswitch integration * pr comment * fixes * fix mock item in test * killswitch qa env * Revert qa killswitch url * add possibility to use maven local dependencies * Dummy firebase initialization * update killswitch version * Mirego-base-setup 8 of X: CI tasks + lint (#40) * fix * Test fix * Format * disable enum rule * fix permissions * add task to ci * Add checkCommon * android ktlint * oups * Mirego-base-setup 9 of X: App Center (#47) * setup appcenter * add init on Android * base setup for fastlane (#48) --------- Co-authored-by: Christophe Tremblay <61481239+ChristopheTremblay@users.noreply.github.com> --------- Co-authored-by: Olivier Pineau Co-authored-by: Olivier Pineau * Mirego-base-setup 6 of X: Analytics (#41) * Analytics stuff * Analytics tracking * Dummy firebase initialization * fix * oups * remove comment --------- Co-authored-by: Steven de Tilly --------- Co-authored-by: Francis Pepin Co-authored-by: Christophe Tremblay <61481239+ChristopheTremblay@users.noreply.github.com> Co-authored-by: Steven de Tilly --- .editorconfig | 28 +++ .github/workflows/ci.yaml | 7 +- androidApp/Gemfile | 3 + androidApp/Gemfile.lock | 217 ++++++++++++++++++ androidApp/build.gradle.kts | 8 +- androidApp/fastlane/Fastfile | 112 +++++++++ androidApp/fastlane/app | 2 + androidApp/fastlane/app.ci | 4 + androidApp/fastlane/app.store | 2 + androidApp/src/main/AndroidManifest.xml | 1 + .../kmp/boilerplate/app/AndroidApplication.kt | 18 +- .../kmp/boilerplate/app/MainActivity.kt | 22 ++ .../AndroidSharedAnalyticsService.kt | 30 +++ .../app/bootstrap/AndroidBootstrap.kt | 8 + .../app/resources/AndroidImageProvider.kt | 6 +- .../kmp/boilerplate/app/ui/common/Const.kt | 2 +- .../boilerplate/app/ui/common/ErrorView.kt | 2 +- .../kmp/boilerplate/app/ui/common/Utils.kt | 5 +- .../app/ui/navigation/BoilerplateNavHost.kt | 46 ++++ .../app/ui/navigation/NavigationView.kt | 27 +++ .../app/ui/navigation/VMDNavigableContent.kt | 26 +++ .../app/ui/navigation/VMDNavigationView.kt | 108 +++++++++ .../app/ui/preview/PreviewProvider.kt | 2 +- .../ProjectDetailsContentView.kt | 148 ++++++++++++ .../ui/projectdetails/ProjectDetailsView.kt | 97 ++++++++ .../app/ui/projects/ProjectsContentView.kt | 11 + .../app/ui/projects/ProjectsView.kt | 22 +- .../kmp/boilerplate/app/ui/root/RootView.kt | 2 +- .../kmp/boilerplate/app/ui/theme/TextStyle.kt | 2 +- .../kmp/boilerplate/app/ui/theme/Theme.kt | 4 - .../main/res/drawable/baseline_close_24.xml | 5 + androidApp/src/main/res/values/config.xml | 4 + gradle.properties | 2 + gradle/libs.versions.toml | 17 +- ios/Gemfile | 3 + ios/Podfile | 7 +- ios/Podfile.lock | 100 +++++++- ios/fastlane/Fastfile | 212 +++++++++++++++++ ios/fastlane/app | 6 + ios/fastlane/app.ci | 8 + ios/fastlane/app.store | 15 ++ ios/iosApp.xcodeproj/project.pbxproj | 52 +++++ ios/iosApp/AppDelegate.swift | 7 +- ios/iosApp/AppInitializer.swift | 62 ++++- ios/iosApp/Domain/AnalyticsServiceImpl.swift | 20 ++ ios/iosApp/Domain/ImageProvider.swift | 2 + .../Extensions/VMDColor+Extensions.swift | 14 ++ ios/iosApp/GoogleService-Info.plist | 32 +++ ios/iosApp/ToReplace-GoogleService-Info.plist | 32 +++ ios/iosApp/UI/Common/NavigationView.swift | 18 ++ ios/iosApp/UI/Common/TextStyle.swift | 10 +- .../UI/Navigation/Binding+Extensions.swift | 26 +++ .../UI/Navigation/Navigation+Extensions.swift | 92 ++++++++ .../UI/Navigation/NavigationModifier.swift | 58 +++++ .../ProjectDetailsContentView.swift | 84 +++++++ .../ProjectDetails/ProjectDetailsView.swift | 58 +++++ .../UI/Projects/ProjectsContentView.swift | 11 +- ios/iosApp/UI/Projects/ProjectsView.swift | 11 + ios/iosApp/UI/Root/RootView.swift | 1 + settings.gradle.kts | 2 + shared/build.gradle.kts | 17 +- .../localization/KWordTranslation.kt | 6 +- .../kmp/boilerplate/StateDataExtensions.kt | 1 - .../kmp/boilerplate/analytics/Analytics.kt | 39 ++++ .../analytics/EmptySharedAnalyticsService.kt | 12 + .../analytics/SharedAnalyticsConfiguration.kt | 5 + .../analytics/SharedAnalyticsService.kt | 9 + .../boilerplate/bootstrap/AppEnvironment.kt | 29 ++- .../kmp/boilerplate/bootstrap/Bootstrapper.kt | 6 +- .../kmp/boilerplate/bootstrap/Module.kt | 8 +- .../boilerplate/datasource/DataSourceImpl.kt | 10 + .../apollo/ApolloGraphQLDiskDataSource.kt | 6 +- .../datasource/generic/GenericDataSource.kt | 2 +- .../graphql/query/ProjectDetails.graphql | 22 ++ .../graphql/query/ProjectsQuery.graphql | 2 + .../ProjectDetailsRepository.kt | 9 + .../ProjectDetailsRepositoryImpl.kt | 42 ++++ .../projects/ProjectsRepositoryImpl.kt | 40 ++-- .../preview/ProjectDetailsUseCasePreview.kt | 34 +++ .../usecase/preview/ProjectsUseCasePreview.kt | 8 +- .../usecase/preview/UseCaseFactoryPreview.kt | 1 + .../projectdetails/ProjectDetailsUseCase.kt | 19 ++ .../ProjectDetailsUseCaseImpl.kt | 46 ++++ .../usecase/projects/ProjectsUseCase.kt | 5 +- .../usecase/projects/ProjectsUseCaseImpl.kt | 6 +- .../viewmodel/common/SharedImageResource.kt | 1 + .../viewmodel/factory/ViewModelFactory.kt | 3 + .../viewmodel/factory/ViewModelFactoryImpl.kt | 6 + .../factory/ViewModelFactoryPreview.kt | 17 +- .../navigation/MainNavigationDelegate.kt | 12 + .../viewmodel/navigation/NavigationRoute.kt | 17 ++ .../navigation/NavigationViewModel.kt | 5 + .../navigation/NavigationViewModelImpl.kt | 31 +++ .../ProjectDetailsNavigationData.kt | 9 + .../projectdetails/ProjectDetailsViewModel.kt | 37 +++ .../ProjectDetailsViewModelImpl.kt | 96 ++++++++ .../viewmodel/projects/ProjectsViewModel.kt | 7 +- .../projects/ProjectsViewModelImpl.kt | 21 +- .../viewmodel/root/RootViewModelImpl.kt | 1 - .../navigation/VMDBaseScreenViewModel.kt | 6 + .../navigation/VMDBaseScreenViewModelImpl.kt | 15 ++ .../navigation/VMDNavigationRoute.kt | 10 + .../navigation/VMDNavigationViewModel.kt | 7 + .../navigation/VMDNavigationViewModelImpl.kt | 50 ++++ .../translations/translation.en.json | 4 +- .../translations/translation.fr.json | 4 +- .../usecase/ProjectsUseCaseTestImpl.kt | 8 +- .../viewmodel/ProjectsViewModelImplTest.kt | 12 +- 108 files changed, 2602 insertions(+), 104 deletions(-) create mode 100644 .editorconfig create mode 100644 androidApp/Gemfile create mode 100644 androidApp/Gemfile.lock create mode 100644 androidApp/fastlane/Fastfile create mode 100644 androidApp/fastlane/app create mode 100644 androidApp/fastlane/app.ci create mode 100644 androidApp/fastlane/app.store create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/analytics/AndroidSharedAnalyticsService.kt create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/BoilerplateNavHost.kt create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/NavigationView.kt create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigableContent.kt create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigationView.kt create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsContentView.kt create mode 100644 androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsView.kt create mode 100644 androidApp/src/main/res/drawable/baseline_close_24.xml create mode 100644 androidApp/src/main/res/values/config.xml create mode 100644 ios/fastlane/Fastfile create mode 100644 ios/fastlane/app create mode 100644 ios/fastlane/app.ci create mode 100644 ios/fastlane/app.store create mode 100644 ios/iosApp/Domain/AnalyticsServiceImpl.swift create mode 100644 ios/iosApp/Extensions/VMDColor+Extensions.swift create mode 100644 ios/iosApp/GoogleService-Info.plist create mode 100644 ios/iosApp/ToReplace-GoogleService-Info.plist create mode 100644 ios/iosApp/UI/Common/NavigationView.swift create mode 100644 ios/iosApp/UI/Navigation/Binding+Extensions.swift create mode 100644 ios/iosApp/UI/Navigation/Navigation+Extensions.swift create mode 100644 ios/iosApp/UI/Navigation/NavigationModifier.swift create mode 100644 ios/iosApp/UI/ProjectDetails/ProjectDetailsContentView.swift create mode 100644 ios/iosApp/UI/ProjectDetails/ProjectDetailsView.swift create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/Analytics.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/EmptySharedAnalyticsService.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsConfiguration.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsService.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectDetails.graphql create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectDetailsUseCasePreview.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCaseImpl.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/MainNavigationDelegate.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationRoute.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModelImpl.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsNavigationData.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModelImpl.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModelImpl.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationRoute.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModelImpl.kt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7900b0b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.json] +indent_size = 2 + +[{*.yml,*.yaml}] +indent_size = 2 + +[*.{kt,kts}] +max_line_length = 180 +ij_kotlin_code_style_defaults = kotlin_official +ij_kotlin_keep_blank_lines_in_declarations = 1 +ij_kotlin_keep_blank_lines_in_code = 1 +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_align_multiline_parameters = false +ij_kotlin_import_nested_classes = false +ij_kotlin_packages_to_use_import_on_demand = unset +ij_kotlin_imports_layout = * +ktlint_disabled_rules = enum-entry-name-case + +[*.md] +max_line_length = 300 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01a2a8d..91fd937 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,5 +36,8 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew check + - name: Run common tests and lint + run: ./gradlew clean shared:checkCommon --parallel --stacktrace -Pskip_gitversion --info + + - name: Run Android lint + run: ./gradlew androidApp:ktlintCheck --stacktrace -Pskip_gitversion diff --git a/androidApp/Gemfile b/androidApp/Gemfile new file mode 100644 index 0000000..7a118b4 --- /dev/null +++ b/androidApp/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gem "fastlane" diff --git a/androidApp/Gemfile.lock b/androidApp/Gemfile.lock new file mode 100644 index 0000000..54ad08f --- /dev/null +++ b/androidApp/Gemfile.lock @@ -0,0 +1,217 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + addressable (2.8.5) + public_suffix (>= 2.0.2, < 6.0) + artifactory (3.0.15) + atomos (0.1.3) + aws-eventstream (1.2.0) + aws-partitions (1.846.0) + aws-sdk-core (3.186.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.72.0) + aws-sdk-core (~> 3, >= 3.184.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.136.0) + aws-sdk-core (~> 3, >= 3.181.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.6.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.8.1) + emoji_regex (3.2.3) + excon (0.104.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.7) + fastlane (2.216.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.51.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.2) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.29.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.6.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.1) + google-cloud-storage (1.45.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.29.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) + domain_name (~> 0.5) + httpclient (2.8.3) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.1) + mini_magick (4.12.0) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.3.0) + nanaimo (0.3.0) + naturally (2.2.1) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) + public_suffix (5.0.3) + rake (13.1.0) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rexml (3.2.6) + rouge (2.0.7) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + security (0.1.3) + signet (0.18.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.2) + unicode-display_width (2.5.0) + webrick (1.8.1) + word_wrap (1.0.0) + xcodeproj (1.23.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-22 + +DEPENDENCIES + fastlane + +BUNDLED WITH + 2.4.10 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 65ced66..cd2f2e4 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.ktlint) + alias(libs.plugins.crashlyticsPlugin) } kotlin { @@ -77,9 +79,11 @@ dependencies { implementation(project(":shared")) implementation(libs.android.splash) + implementation(libs.android.firebase.analytics) + implementation(libs.android.firebase.crashlytics) + implementation(platform(libs.android.firebase.bom)) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) implementation(libs.accompanist.systemuicontroller) implementation(libs.accompanist.placeholder.material) @@ -91,4 +95,6 @@ dependencies { implementation(libs.koin.androidx.compose.navigation) implementation(libs.trikot.viewmodels.databinding) implementation(libs.trikot.vmd.compose) + "ciImplementation"(libs.appcenter) + "storeImplementation"(libs.appcenter.play) } diff --git a/androidApp/fastlane/Fastfile b/androidApp/fastlane/Fastfile new file mode 100644 index 0000000..bdd5a1c --- /dev/null +++ b/androidApp/fastlane/Fastfile @@ -0,0 +1,112 @@ +require_relative '../../fastlane/utils.rb' +default_platform(:android) + +platform :android do + lane :build_app do |options| + env = options[:env] + load_environment_variables(env) + + # replace repoName with the name of your repository + remove_unused_resources("/home/runner/work/repoName/repoName") + if is_store(env) + build_store() + else + build_appcenter() + end + end + + lane :build_app_ci do |options| + env = options[:env] + load_environment_variables(env) + ensure_env_vars( + env_vars: ['FLAVOR'] + ) + build_apk() + end + + private_lane :build_appcenter do + ensure_env_vars( + env_vars: [ + 'APP_CENTER_ORGANIZATION', + 'APP_CENTER_APP_NAME', + 'APP_CENTER_APP_SECRET', + 'APP_CENTER_API_TOKEN', + 'APP_CENTER_DISTRIBUTION_GROUPS', + 'FLAVOR' + ] + ) + + appcenter_secret = ENV["APP_CENTER_APP_SECRET"] + unless appcenter_secret.nil? || appcenter_secret.empty? + xml_editor( + path_to_xml_file: 'src/main/res/values/config.xml', + xml_path: '//resources//string[@name=\'appcenter_app_secret\']', + new_value: appcenter_secret + ) + end + + build_apk() + + appcenter_upload( + api_token: ENV["APP_CENTER_API_TOKEN"], + owner_type: "organization", + owner_name: ENV["APP_CENTER_ORGANIZATION"], + app_name: ENV["APP_CENTER_APP_NAME"], + destinations: ENV['APP_CENTER_DISTRIBUTION_GROUPS'], + destination_type: "group" + ) + end + + private_lane :build_store do + ensure_env_vars( + env_vars: [ + 'PACKAGE_NAME', + 'FLAVOR', + 'GOOGLE_PLAY_STORE_JSON_KEY' + ] + ) + + validate_play_store_json_key( + json_key_data: ENV["GOOGLE_PLAY_STORE_JSON_KEY"] + ) + + build_aab() + + upload_to_play_store( + root_url: "https://androidpublisher.googleapis.com/", + aab: Actions.lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH], + package_name: ENV["PACKAGE_NAME"], + track: 'internal', + release_status: 'draft', + json_key_data: ENV["GOOGLE_PLAY_STORE_JSON_KEY"] + ) + end + + private_lane :build_apk do + gradle_path = "../gradlew" + gradle( + task: "clean", + gradle_path: gradle_path + ) + gradle( + task: 'assemble', + flavor: ENV["FLAVOR"], + build_type: 'Release', + gradle_path: gradle_path + ) + end + + private_lane :build_aab do + gradle_path = "../gradlew" + gradle( + task: "clean", + gradle_path: gradle_path + ) + gradle( + task: 'bundle', + flavor: ENV["FLAVOR"], + build_type: 'Upload', + gradle_path: gradle_path + ) + end +end diff --git a/androidApp/fastlane/app b/androidApp/fastlane/app new file mode 100644 index 0000000..e207fed --- /dev/null +++ b/androidApp/fastlane/app @@ -0,0 +1,2 @@ +APP_CENTER_ORGANIZATION= +APP_CENTER_DISTRIBUTION_GROUPS= diff --git a/androidApp/fastlane/app.ci b/androidApp/fastlane/app.ci new file mode 100644 index 0000000..e6969cc --- /dev/null +++ b/androidApp/fastlane/app.ci @@ -0,0 +1,4 @@ +FLAVOR=CI +APP_CENTER_APP_NAME= +APP_CENTER_APP_SECRET= +ICON_BANNER=CI diff --git a/androidApp/fastlane/app.store b/androidApp/fastlane/app.store new file mode 100644 index 0000000..23d41bd --- /dev/null +++ b/androidApp/fastlane/app.store @@ -0,0 +1,2 @@ +FLAVOR=Playstore +PACKAGE_NAME=com.mirego.kmp.boilerplate diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 9bd6ca6..1b8a347 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + when(resource) { + override fun resourceIdForResource(resource: VMDImageResource, context: Context) = when (resource) { + is SharedImageResource -> when (resource) { SharedImageResource.emptyPageIcon -> R.drawable.baseline_question_mark_24 SharedImageResource.errorPageIcon -> R.drawable.baseline_warning_24 SharedImageResource.imagePlaceholder -> R.drawable.baseline_image_24 + SharedImageResource.closeIcon -> R.drawable.baseline_close_24 } + else -> null } } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Const.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Const.kt index b74297a..c511db3 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Const.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Const.kt @@ -4,4 +4,4 @@ import androidx.compose.ui.unit.dp object Const { val padding = 16.dp -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/ErrorView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/ErrorView.kt index 63cb5af..00ae4af 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/ErrorView.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/ErrorView.kt @@ -76,7 +76,7 @@ fun ErrorView(errorViewModel: ErrorViewModel) { .clip(RoundedCornerShape(percent = 50)) .background(Color.Red) .padding(vertical = 12.dp), - viewModel = viewModel.retryButton, + viewModel = viewModel.retryButton ) { content -> Text( modifier = Modifier, diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Utils.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Utils.kt index 9dfa37e..f505258 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Utils.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Utils.kt @@ -7,6 +7,7 @@ import com.google.accompanist.placeholder.placeholder import com.google.accompanist.placeholder.shimmer import com.mirego.kmp.boilerplate.app.ui.theme.ShimmerBackground import com.mirego.kmp.boilerplate.app.ui.theme.ShimmerHighlight +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor fun Modifier.loading(isLoading: Boolean) = this.then( placeholder( @@ -14,4 +15,6 @@ fun Modifier.loading(isLoading: Boolean) = this.then( highlight = PlaceholderHighlight.shimmer(highlightColor = Color.ShimmerHighlight), color = Color.ShimmerBackground ) -) \ No newline at end of file +) + +fun VMDColor.toColor() = Color(red, green, blue) diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/BoilerplateNavHost.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/BoilerplateNavHost.kt new file mode 100644 index 0000000..b2611b3 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/BoilerplateNavHost.kt @@ -0,0 +1,46 @@ +package com.mirego.kmp.boilerplate.app.ui.navigation + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import com.mirego.kmp.boilerplate.app.ui.projectdetails.ProjectDetailsView +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationRoute +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModel + +@Composable +fun BoilerplateNavHost( + navController: NavHostController, + startDestination: String, + navigationViewModel: NavigationViewModel, + content: @Composable () -> Unit +) = NavHost( + navController = navController, + startDestination = startDestination, + enterTransition = { fadeIn(tween(500)) }, + exitTransition = { fadeOut(tween(500)) } +) { + composable(startDestination) { + content() + } + composable(NavigationRoute.ProjectDetails.NAME) { + NavigableContent(navigationViewModel) + } +} + +@Composable +private fun NavigableContent(navigationViewModel: NavigationViewModel) { + Box(modifier = Modifier.fillMaxSize()) { + VMDNavigableContent(navigationViewModel = navigationViewModel) { navigationRoute -> + when (navigationRoute) { + is NavigationRoute.ProjectDetails -> ProjectDetailsView(projectDetailsViewModel = navigationRoute.viewModel) + } + } + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/NavigationView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/NavigationView.kt new file mode 100644 index 0000000..4406b9b --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/NavigationView.kt @@ -0,0 +1,27 @@ +package com.mirego.kmp.boilerplate.app.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.navigation.compose.rememberNavController +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModel + +@Composable +fun NavigationView(navigationViewModel: NavigationViewModel, content: @Composable () -> Unit) { + if (LocalInspectionMode.current) { + content() + return + } + + val navController = rememberNavController() + VMDNavigationView( + navigationViewModel = navigationViewModel, + navController = navController + ) { startDestination -> + BoilerplateNavHost( + navController = navController, + startDestination = startDestination, + navigationViewModel = navigationViewModel, + content = content + ) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigableContent.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigableContent.kt new file mode 100644 index 0000000..38eb05c --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigableContent.kt @@ -0,0 +1,26 @@ +package com.mirego.kmp.boilerplate.app.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationRoute +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationViewModel + +/** + * When using VMDNavigationView, wrap the different navigation destinations in a VMDNavigableContent + * + * @param navigationViewModel The VMDNavigationViewModel + * @param content The wrapped navigation content. The block will receive the navigationRoute, as well as a flag to indicate if the composition is done in the context of an exit transition. + */ +@Composable +fun VMDNavigableContent( + navigationViewModel: VMDNavigationViewModel, + content: @Composable (navigationRoute: T) -> Unit +) { + val route: T? by remember(key1 = navigationViewModel) { + mutableStateOf(navigationViewModel.navigationRoute) + } + val navigationRoute = route ?: return + content(navigationRoute) +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigationView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigationView.kt new file mode 100644 index 0000000..6541326 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/navigation/VMDNavigationView.kt @@ -0,0 +1,108 @@ +package com.mirego.kmp.boilerplate.app.ui.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavDestination +import androidx.navigation.NavHostController +import androidx.navigation.NavOptionsBuilder +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModel +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationRoute +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState +import kotlinx.coroutines.flow.MutableStateFlow + +private const val ROOT_ROUTE = "ROOT" + +/** + * For views backed by a VMDNavigationViewModel, wrap your content with VMDNavigationView to handle navigation. + * + * @param navigationViewModel The VMDNavigationViewModel + * @param navController The NavHostController that controls the navigation. Note that it is possible to use custom implementations (for instance if a navigation library is used). + * @param navOptionsBuilder The options to use for navigation + * @param navHost The navHost containing the different navigation destinations. Note that it is possible to use custom implementations (for instance if a navigation library is used). + */ +@Composable +fun VMDNavigationView( + navigationViewModel: VMDNavigationViewModel, + navController: NavHostController, + navOptionsBuilder: NavOptionsBuilder.() -> Unit = { + launchSingleTop = true + }, + navHost: @Composable (startDestination: String) -> Unit +) { + navHost(startDestination = ROOT_ROUTE) + + NavigationHandler( + navigationViewModel = navigationViewModel, + navController = navController, + navOptionsBuilder = navOptionsBuilder + ) +} + +@Composable +private fun NavigationHandler( + navigationViewModel: VMDNavigationViewModel, + navController: NavHostController, + navOptionsBuilder: NavOptionsBuilder.() -> Unit +) { + val navigationRoute by navigationViewModel.observeAsState(navigationViewModel::navigationRoute, navigationViewModel.navigationRoute) + val currentRoute = navController.currentDestination + + when (val navigation = getNavigation(navigationRoute, currentRoute)) { + is VMDNavigation.POP -> navController.popBackStack() + is VMDNavigation.LAUNCH -> navController.navigate( + route = navigation.destinationRoute, + builder = navOptionsBuilder + ) + is VMDNavigation.NONE -> Unit + } + val lifecycleEventState = MutableStateFlow(Lifecycle.Event.ON_START) + val observer = LifecycleEventObserver { _, event -> + lifecycleEventState.value = event + } + val lifecycleOwner = LocalLifecycleOwner.current + + lifecycleOwner.lifecycle.addObserver(observer) + + DisposableEffect(lifecycleOwner) { + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + val state = lifecycleEventState.collectAsState() + + key(state.value) { + BackHandler( + enabled = navigationRoute != null && (navigationRoute?.viewModel as? NavigationViewModel)?.navigationRoute == null + ) { + navigationRoute?.resetBlock?.invoke() + } + } +} + +private fun getNavigation( + navigationRoute: VMDNavigationRoute?, + currentNavDestination: NavDestination? +): VMDNavigation = when { + navigationRoute == null && !currentNavDestination.isRoot -> VMDNavigation.POP + navigationRoute != null && currentNavDestination.isRoot -> VMDNavigation.LAUNCH(navigationRoute.name) + else -> VMDNavigation.NONE +} + +private val NavDestination?.isRoot: Boolean + get() = this == null || this.route == null || this.route == ROOT_ROUTE + +private sealed class VMDNavigation { + object NONE : VMDNavigation() + object POP : VMDNavigation() + data class LAUNCH( + val destinationRoute: String + ) : VMDNavigation() +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt index f7e0faf..7a1925c 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/preview/PreviewProvider.kt @@ -13,6 +13,6 @@ fun PreviewProvider(content: @Composable (ViewModelFactoryPreview) -> Unit) { val viewModelFactoryPreview = ViewModelFactoryPreview( i18N = PreviewI18N(BuildConfig.KWORD_TRANSLATION_FILE_PATH) ) - + content(viewModelFactoryPreview) } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsContentView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsContentView.kt new file mode 100644 index 0000000..b52e949 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsContentView.kt @@ -0,0 +1,148 @@ +package com.mirego.kmp.boilerplate.app.ui.projectdetails + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import com.mirego.kmp.boilerplate.app.ui.common.Const.padding +import com.mirego.kmp.boilerplate.app.ui.common.loading +import com.mirego.kmp.boilerplate.app.ui.common.toColor +import com.mirego.kmp.boilerplate.app.ui.theme.TextSize +import com.mirego.kmp.boilerplate.app.ui.theme.TextWeight +import com.mirego.kmp.boilerplate.app.ui.theme.style +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsRoot +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.LocalImage +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.PlaceholderState +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDImage +import com.mirego.trikot.viewmodels.declarative.properties.VMDImageResource + +@Composable +fun ProjectDetailsContentView(content: ProjectDetailsRoot.Content) { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val textColor = content.textColor.toColor() + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + VMDImage( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = screenWidth * 1.25f) + .align(Alignment.TopCenter), + viewModel = content.image, + contentScale = ContentScale.FillWidth, + placeholder = { placeholderImageResource: VMDImageResource, _: PlaceholderState -> + ImagePlaceholder( + placeholderImageResource = placeholderImageResource, + color = textColor + ) + } + ) + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = padding) + .padding(bottom = padding) + ) { + Text( + modifier = Modifier.loading(content.isLoading), + text = content.title, + style = style(TextSize.LARGE_TITLE, TextWeight.BOLD), + color = textColor + ) + + Text( + modifier = Modifier + .padding(top = 24.dp) + .loading(content.isLoading), + text = content.subtitle, + style = style(TextSize.TITLE1, TextWeight.SEMI_BOLD), + color = textColor + ) + + Box( + modifier = Modifier + .padding(top = 24.dp) + .height(1.dp) + .fillMaxWidth() + .background(textColor) + ) + + Column( + modifier = Modifier.padding(top = 32.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.loading(content.isLoading), + text = content.projectType.first, + style = style(TextSize.SUB_HEADLINE, TextWeight.SEMI_BOLD), + color = textColor + ) + + Text( + modifier = Modifier.loading(content.isLoading), + text = content.projectType.second, + style = style(TextSize.SUB_HEADLINE, TextWeight.REGULAR), + color = textColor, + maxLines = 2 + ) + } + + Column( + modifier = Modifier.padding(top = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.loading(content.isLoading), + text = content.releaseYear.first, + style = style(TextSize.SUB_HEADLINE, TextWeight.SEMI_BOLD), + color = textColor + ) + + Text( + modifier = Modifier.loading(content.isLoading), + text = content.releaseYear.second, + style = style(TextSize.SUB_HEADLINE, TextWeight.REGULAR), + color = textColor, + maxLines = 2 + ) + } + } + } +} + +@Composable +private fun ImagePlaceholder(placeholderImageResource: VMDImageResource, color: Color) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), + contentAlignment = Alignment.Center + ) { + LocalImage( + modifier = Modifier.size(96.dp), + imageResource = placeholderImageResource, + contentScale = ContentScale.FillWidth, + colorFilter = ColorFilter.tint(color) + ) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsView.kt new file mode 100644 index 0000000..b97ddeb --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projectdetails/ProjectDetailsView.kt @@ -0,0 +1,97 @@ +package com.mirego.kmp.boilerplate.app.ui.projectdetails + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.systemuicontroller.SystemUiController +import com.mirego.kmp.boilerplate.app.ui.common.ErrorView +import com.mirego.kmp.boilerplate.app.ui.common.toColor +import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.usecase.preview.PreviewState +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsRoot +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.LocalImage +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDButton + +@Composable +fun ProjectDetailsView(projectDetailsViewModel: ProjectDetailsViewModel, systemUiController: SystemUiController? = null) { + val viewModel: ProjectDetailsViewModel by projectDetailsViewModel.observeAsState() + systemUiController?.setNavigationBarColor(color = viewModel.backgroundColor.toColor(), darkIcons = false) + Box( + modifier = Modifier + .fillMaxSize() + .background(viewModel.backgroundColor.toColor()) + ) { + ContentView(viewModel = viewModel) + + VMDButton( + modifier = Modifier + .statusBarsPadding() + .padding(8.dp) + .clip(CircleShape) + .size(40.dp) + .background(viewModel.textColor.toColor().copy(alpha = 0.1f)), + viewModel = viewModel.closeButton + ) { content -> + LocalImage( + modifier = Modifier.size(32.dp), + imageResource = content.image, + colorFilter = ColorFilter.tint(viewModel.textColor.toColor()) + ) + } + } +} + +@Composable +private fun ContentView(viewModel: ProjectDetailsViewModel) { + viewModel.rootContent?.let { content -> + when (content) { + is ProjectDetailsRoot.Content -> ProjectDetailsContentView(content = content) + is ProjectDetailsRoot.Error -> ErrorView(errorViewModel = content.errorViewModel) + } + } +} + +@Preview +@Composable +fun PreviewProjectsDetailsContentView() { + PreviewProvider { + ProjectDetailsView(projectDetailsViewModel = it.createProjectDetails(previewState = PreviewState.Data.Content)) + } +} + +@Preview +@Composable +fun PreviewProjectsDetailsEmptyView() { + PreviewProvider { + ProjectDetailsView(projectDetailsViewModel = it.createProjectDetails(previewState = PreviewState.Data.Empty)) + } +} + +@Preview +@Composable +fun PreviewProjectsDetailsLoadingView() { + PreviewProvider { + ProjectDetailsView(projectDetailsViewModel = it.createProjectDetails(previewState = PreviewState.Loading)) + } +} + +@Preview +@Composable +fun PreviewProjectsDetailsErrorView() { + PreviewProvider { + ProjectDetailsView(projectDetailsViewModel = it.createProjectDetails(previewState = PreviewState.Error)) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsContentView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsContentView.kt index 2950421..e0b0853 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsContentView.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsContentView.kt @@ -1,6 +1,8 @@ package com.mirego.kmp.boilerplate.app.ui.projects import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,8 +17,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -103,6 +107,13 @@ private fun ProjectsListView(viewModel: VMDListViewModel) { @Composable private fun ItemView(item: ProjectItem) { Column( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable( + onClick = { item.tapAction.invoke() }, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple() + ), verticalArrangement = Arrangement.spacedBy(padding) ) { VMDImage( diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsView.kt index 4b0902c..656eb24 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsView.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsView.kt @@ -4,13 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import com.mirego.kmp.boilerplate.app.ui.common.ErrorView +import com.mirego.kmp.boilerplate.app.ui.navigation.NavigationView import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider import com.mirego.kmp.boilerplate.app.ui.theme.PrimaryBlack import com.mirego.kmp.boilerplate.usecase.preview.PreviewState @@ -21,20 +21,22 @@ import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsStat @Composable fun ProjectsView(projectsViewModel: ProjectsViewModel) { val viewModel: ProjectsViewModel by projectsViewModel.observeAsState() - Box( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding() - .background(Color.PrimaryBlack), - ) { - ContentView(viewModel = viewModel) + NavigationView(navigationViewModel = viewModel) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .background(Color.PrimaryBlack) + ) { + ContentView(viewModel = viewModel) + } } } @Composable private fun ContentView(viewModel: ProjectsViewModel) { viewModel.rootContent?.let { content -> - when(content) { + when (content) { is ProjectsRoot.Content -> ProjectsContentView(listViewModel = content.sections) is ProjectsRoot.Error -> ErrorView(errorViewModel = content.errorViewModel) } @@ -71,4 +73,4 @@ fun PreviewProjectsErrorView() { PreviewProvider { ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Error)) } -} \ No newline at end of file +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt index c7906ce..b30fc6e 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/root/RootView.kt @@ -25,7 +25,7 @@ fun RootView(rootViewModel: RootViewModel) { Box( modifier = Modifier .fillMaxSize() - .background(Color.White), + .background(Color.White) ) { ProjectsView(projectsViewModel = viewModel.projectsViewModel) } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/TextStyle.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/TextStyle.kt index 97322a8..c8e6c95 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/TextStyle.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/TextStyle.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.unit.sp fun style(size: TextSize, weight: TextWeight) = TextStyle( fontSize = size.fontSize(), fontStyle = FontStyle.Normal, - fontWeight = weight.fontName(), + fontWeight = weight.fontName() ) enum class TextSize { diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Theme.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Theme.kt index 9839074..23f31f8 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Theme.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Theme.kt @@ -3,11 +3,7 @@ package com.mirego.kmp.boilerplate.app.ui.theme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import com.google.accompanist.placeholder.PlaceholderHighlight -import com.google.accompanist.placeholder.placeholder -import com.google.accompanist.placeholder.shimmer @Composable fun Theme( diff --git a/androidApp/src/main/res/drawable/baseline_close_24.xml b/androidApp/src/main/res/drawable/baseline_close_24.xml new file mode 100644 index 0000000..70db409 --- /dev/null +++ b/androidApp/src/main/res/drawable/baseline_close_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/values/config.xml b/androidApp/src/main/res/values/config.xml new file mode 100644 index 0000000..4060034 --- /dev/null +++ b/androidApp/src/main/res/values/config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/gradle.properties b/gradle.properties index 7f319a0..0b259a2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,3 +9,5 @@ android.useAndroidX=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.enableCInteropCommonization=true kotlin.mpp.stability.nowarn=true +#iOS +kotlin.native.binary.objcExportSuspendFunctionLaunchThreadRestriction=none diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5460cbd..c23922e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,10 +4,15 @@ accompanist = "0.32.0" androidComposeCompiler = "1.5.3" androidGradlePlugin = "8.1.2" android-splash = "1.0.1" +android-playServices = "20.7.0" +play-services-measurement-api = "21.5.0" androidxActivityCompose = "1.8.0" androidxAppcompat = "1.6.1" androidxComposeBom = "2023.10.01" +appcenter = "5.0.0" apollo = "3.7.4" +crashlytics-plugin = "2.9.9" +killswitch = "1.0.4" koin = "3.5.0" koin-android = "3.5.0" koin-androidx-compose = "3.5.0" @@ -18,6 +23,7 @@ kotlinxCoroutines = "1.7.3" kotlinxSerialization = "1.6.0" kword-plugin = "4.0.0" ktlint = "11.6.1" +firebase = "32.5.0" mockk = "1.12.5" okio = "3.6.0" owasp = "8.4.2" @@ -34,7 +40,14 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-material = { group = "androidx.compose.material", name = "material" } +#android-play-services-measurement-api = { group = "com.google.android.gms", name = "play-services-measurement-api", version.ref = "play-services-measurement-api" } +android-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase" } +android-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" } +android-firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" } +appcenter = { module = "com.microsoft.appcenter:appcenter-distribute", version.ref = "appcenter"} +appcenter-play = { module = "com.microsoft.appcenter:appcenter-distribute-play", version.ref = "appcenter"} apollo-runtime = { group = "com.apollographql.apollo3", name = "apollo-runtime" } +killswitch = { module = "com.mirego.killswitch-mobile:killswitch", version.ref = "killswitch" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin_ksp" } ksp-koinCompiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref = "koin_ksp" } @@ -53,6 +66,8 @@ skie = { module = "co.touchlab.skie:configuration-annotations", version.ref = "s mockk-common = { module = "io.mockk:mockk-common", version.ref = "mockk" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +trikot-analytics = { module = "com.mirego.trikot:analytics", version.ref = "trikot" } +trikot-analytics-firebase = { module = "com.mirego.trikot.analytics:firebase-ktx", version.ref = "trikot" } trikot-vmd = { module = "com.mirego.trikot:viewmodels-declarative-flow", version.ref = "trikot" } trikot-kword = { module = "com.mirego.trikot:kword", version.ref = "trikot" } trikot-datasources = { module = "com.mirego.trikot:datasources-flow", version.ref = "trikot" } @@ -70,10 +85,10 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref kotlin-native-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } kspPlugin = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +crashlyticsPlugin = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics-plugin" } mirego-kwordPlugin = { id = "mirego.kword", version.ref = "kword-plugin" } owasp-dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "owasp" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } skie-plugin = { id = "co.touchlab.skie", version.ref = "skie" } [bundles] - diff --git a/ios/Gemfile b/ios/Gemfile index 0927832..40328e3 100644 --- a/ios/Gemfile +++ b/ios/Gemfile @@ -1,3 +1,6 @@ source 'https://rubygems.org' gem 'cocoapods', '~> 1.13' + + +gem "fastlane" \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index 1bfb53e..56b377a 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -6,7 +6,12 @@ inhibit_all_warnings! target 'iosApp' do use_frameworks! - platform :ios, $deploymentTarget + platform :ios, $deploymentTarget + # Third-party + pod 'AppCenter/Distribute' + pod 'FirebaseCore' + pod 'FirebaseAnalytics' + # Multiplatform pod 'Shared', :path => '../shared' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8f1a2f9..3b1440a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,82 @@ PODS: + - AppCenter/Core (5.0.4) + - AppCenter/Distribute (5.0.4): + - AppCenter/Core + - FirebaseAnalytics (10.16.0): + - FirebaseAnalytics/AdIdSupport (= 10.16.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.16.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.16.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseCore (10.16.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreInternal (10.16.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.16.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - GoogleAppMeasurement (10.16.0): + - GoogleAppMeasurement/AdIdSupport (= 10.16.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.16.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.16.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.16.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleUtilities/AppDelegateSwizzler (7.11.5): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.11.5): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.11.5): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (7.11.5): + - GoogleUtilities/Logger + - GoogleUtilities/Network (7.11.5): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.11.5)" + - GoogleUtilities/Reachability (7.11.5): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (7.11.5): + - GoogleUtilities/Logger - Kingfisher (7.8.1) + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) + - PromisesObjC (2.3.1) - Shared (0.1) - SwiftLint (0.53.0) - SwiftUIIntrospect (1.1.0) @@ -14,6 +91,9 @@ PODS: - Trikot/viewmodels.declarative.flow DEPENDENCIES: + - AppCenter/Distribute + - FirebaseAnalytics + - FirebaseCore - Shared (from `../shared`) - SwiftLint - "Trikot/kword (from `git@github.com:mirego/trikot.git`, tag `5.2.0`)" @@ -21,7 +101,16 @@ DEPENDENCIES: SPEC REPOS: trunk: + - AppCenter + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - GoogleAppMeasurement + - GoogleUtilities - Kingfisher + - nanopb + - PromisesObjC - SwiftLint - SwiftUIIntrospect @@ -38,12 +127,21 @@ CHECKOUT OPTIONS: :tag: 5.2.0 SPEC CHECKSUMS: + AppCenter: 85c92db0759d2792a65eb61d6842d2e86611a49a + FirebaseAnalytics: 7b41efc4eba5ff841cc94d5994b5f339361258f4 + FirebaseCore: 65a801af84cca84361ef9eac3fd868656968a53b + FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a + FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee + GoogleAppMeasurement: 079d7632810e9d9704a99932547ba1554acc4342 + GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 Kingfisher: 63f677311d36a3473f6b978584f8a3845d023dc5 + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 Shared: 185e58fd174d1d4b955d7581bc1bacb9810272fa SwiftLint: 5ce4d6a8ff83f1b5fd5ad5dbf30965d35af65e44 SwiftUIIntrospect: 53b6a16734c822804ff82c35d9073d8d8edfb085 Trikot: ca201c8ae67ff21f35a60a0bda6c600fbe51b282 -PODFILE CHECKSUM: a8e15659553c290a2147c108f4746a4431cf7594 +PODFILE CHECKSUM: 85b292e2fef97a1359500ae62489551542a67efb COCOAPODS: 1.13.0 diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 0000000..d544ccc --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,212 @@ +import_from_git(url: 'git@github.com:mirego/fastlane-toolkit.git', branch: 'feature/export-options-thinning') +require_relative '../../fastlane/utils.rb' +default_platform(:ios) + +ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "600" + +platform :ios do + + lane :build_app do |options| + env = options[:env] + load_environment_variables(env) + if is_store(env) + build_store() + else + build_appcenter() + end + end + + private_lane :build_appcenter do + ensure_env_vars( + env_vars: [ + 'APP_CENTER_ORGANIZATION', + 'APP_CENTER_APP_NAME', + 'APP_CENTER_APP_SECRET', + 'APP_CENTER_API_TOKEN', + 'APP_CENTER_DISTRIBUTION_GROUPS', + 'BUNDLE_IDENTIFIER', + 'BUILD_CONFIGURATION', + 'TARGET', + 'INFO_PLIST_PATH', + 'PROVISIONING_PROFILE_PATH', + 'APP_GROUP_ID', + 'ENVIRONMENT' + ] + ) + + enterprise_config = enterprise_configuration() + + provisioning_profile = Model::ProvisioningProfile.new( + path: ENV["PROVISIONING_PROFILE_PATH"] + ) + + build_configuration = Model::Configuration.new( + certificate: enterprise_config.certificate, + provisioningProfile: provisioning_profile, + buildConfiguration: enterprise_config, + exportMethod: enterprise_config.exportMethod + ) + + build_configuration.bundleIdentifierOverride = ENV["BUNDLE_IDENTIFIER"] + build_configuration.buildConfiguration = ENV["BUILD_CONFIGURATION"] + + do_build_app(build_configuration: build_configuration) + + appcenter_upload( + api_token: ENV["APP_CENTER_API_TOKEN"], + owner_type: "organization", + owner_name: ENV["APP_CENTER_ORGANIZATION"], + app_name: ENV["APP_CENTER_APP_NAME"], + ipa: "../ios/#{ENV["TARGET"]}.ipa", + destinations: ENV['APP_CENTER_DISTRIBUTION_GROUPS'], + destination_type: "group" + ) + + upload_symbols() + end + + private_lane :build_store do + ensure_env_vars( + env_vars: [ + 'APP_STORE_CERTIFICATE_PATH', + 'APP_STORE_CERTIFICATE_PASSWORD', + 'APP_STORE_PROVISIONING_PROFILE_NAME', + 'APP_STORE_CONNECT_API_KEY_ID', + 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', + 'APP_STORE_CONNECT_API_KEY_FILE_PATH', + 'PROVISIONING_PROFILE_PATH', + 'PROVISIONING_PROFILE_WIDGET_PATH', + 'APP_GROUP_ID', + 'BUNDLE_IDENTIFIER', + 'BUNDLE_IDENTIFIER_WIDGET', + 'BUILD_CONFIGURATION', + 'TARGET', + 'INFO_PLIST_PATH', + 'ENVIRONMENT', + 'GOOGLE_SIGN_IN_URL_SCHEME', + 'GOOGLE_SIGN_IN_CLIENT_ID' + ] + ) + + app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], + key_filepath: File.expand_path(ENV["APP_STORE_CONNECT_API_KEY_FILE_PATH"]), + in_house: false + ) + + appStoreProvisioningProfile = Model::ProvisioningProfile.new( + path: ENV["PROVISIONING_PROFILE_PATH"] + ) + + appStoreCertificate = Model::Certificate.new( + path: ENV["APP_STORE_CERTIFICATE_PATH"], + name: ENV["APP_STORE_PROVISIONING_PROFILE_NAME"], + password: ENV["APP_STORE_CERTIFICATE_PASSWORD"] + ) + + appStoreConfiguration = Model::Configuration.new( + certificate: appStoreCertificate, + provisioningProfile: appStoreProvisioningProfile, + buildConfiguration: ENV["BUILD_CONFIGURATION"], + exportMethod: "app-store" + ) + + do_build_app(build_configuration: appStoreConfiguration) + + upload_to_testflight( + skip_submission: true, + skip_waiting_for_build_processing: true, + notify_external_testers: false + ) + + upload_symbols() + end + + private_lane :do_build_app do |options| + build_configuration = options[:build_configuration] + + info_plist_path = ENV["INFO_PLIST_PATH"] + target = ENV["TARGET"] + bundle_identifier = ENV["BUNDLE_IDENTIFIER"] + + project = Model::Project.new( + workspacePath: "iosApp.xcworkspace", + projectPath: "iosApp.xcodeproj", + infoPlistPath: info_plist_path, + scheme: target, + target: target, + bundleIdentifier: bundle_identifier + ) + + setup_app_extensions(build_configuration: build_configuration, project: project) + + app_center_app_secret = ENV["APP_CENTER_APP_SECRET"] + unless app_center_app_secret.nil? || app_center_app_secret.empty? + update_info_plist( + xcodeproj: project.projectPath, + plist_path: project.infoPlistPath, + block: proc do |plist| + plist["APP_CENTER_APP_SECRET"] = app_center_app_secret + urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "AppCenterURL"} + urlScheme[:CFBundleURLSchemes] = ["appcenter-#{app_center_app_secret}"] + end + ) + end + + google_sign_in_client_id = ENV["GOOGLE_SIGN_IN_CLIENT_ID"] + google_sign_in_url_scheme = ENV["GOOGLE_SIGN_IN_URL_SCHEME"] + unless google_sign_in_client_id.nil? || google_sign_in_url_scheme.nil? + update_info_plist( + xcodeproj: project.projectPath, + plist_path: project.infoPlistPath, + block: proc do |plist| + plist["GIDClientID"] = google_sign_in_client_id + urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "GoogleSignIn"} + urlScheme[:CFBundleURLSchemes] = ["#{google_sign_in_url_scheme}"] + end + ) + end + + replace_app_icons() + + set_info_plist_value(path: info_plist_path, key: "Environment", value: ENV["ENVIRONMENT"]) + + gradle( + task: "updatePList", + flags: "-PpListPath=#{info_plist_path}", + gradle_path: "../gradlew" + ) + + buildNumber = get_info_plist_value(path: project.infoPlistPath, key: "CFBundleVersion") + increment_build_number(build_number: buildNumber, xcodeproj: "./iosApp.xcodeproj") + + cocoapods( + use_bundle_exec: true, + try_repo_update_on_error: true, + verbose: false + ) + + build_ios_app_with_toolkit( + project: project, + configuration: build_configuration, + include_bitcode: false + ) + end + + private_lane :upload_symbols do + upload_symbols_to_crashlytics( + dsym_paths: [ + "./#{ENV["TARGET"]}.app.dSYM.zip", + '../common/build/cocoapods/framework/Common.framework.dSYM' + ], + gsp_path: "./#{ENV["TARGET"]}/SupportingFiles/GoogleService-Info.plist" + ) + end + + private_lane :replace_app_icons do + app_icons_path = File.expand_path(File.join("../", "#{ENV["TARGET"]}", "Resources", "Assets.xcassets", "Icons")) + icons_overwrite_path = File.expand_path(File.join("overwrites", "#{ENV["OVERWRITE_NAME"]}.overwrite", "AppIcon.appiconset")) + FileUtils.cp_r(icons_overwrite_path, app_icons_path) + end +end diff --git a/ios/fastlane/app b/ios/fastlane/app new file mode 100644 index 0000000..dddd563 --- /dev/null +++ b/ios/fastlane/app @@ -0,0 +1,6 @@ +APP_CENTER_ORGANIZATION= +BUILD_CONFIGURATION= +TARGET= +TARGET_WIDGET_EXTENSION= +INFO_PLIST_PATH= +INFO_PLIST_WIDGET_PATH= diff --git a/ios/fastlane/app.ci b/ios/fastlane/app.ci new file mode 100644 index 0000000..83f0503 --- /dev/null +++ b/ios/fastlane/app.ci @@ -0,0 +1,8 @@ +APP_CENTER_DISTRIBUTION_GROUPS= +APP_CENTER_APP_NAME= +APP_CENTER_APP_SECRET= +PROVISIONING_PROFILE_PATH= +BUNDLE_IDENTIFIER= +APP_GROUP_ID= +ENVIRONMENT=ci +OVERWRITE_NAME=ci diff --git a/ios/fastlane/app.store b/ios/fastlane/app.store new file mode 100644 index 0000000..fdf8791 --- /dev/null +++ b/ios/fastlane/app.store @@ -0,0 +1,15 @@ +BUNDLE_IDENTIFIER= +ENVIRONMENT= +OVERWRITE_NAME= + +GOOGLE_SIGN_IN_URL_SCHEME= +GOOGLE_SIGN_IN_CLIENT_ID= + +PROVISIONING_PROFILE_PATH= +APP_STORE_PROVISIONING_PROFILE_NAME= +APP_STORE_CERTIFICATE_PATH= +APP_GROUP_ID= + +#AppStore Connect +APP_STORE_CONNECT_API_KEY_ID= +APP_STORE_CONNECT_API_KEY_ISSUER_ID= diff --git a/ios/iosApp.xcodeproj/project.pbxproj b/ios/iosApp.xcodeproj/project.pbxproj index ad01976..61bf733 100644 --- a/ios/iosApp.xcodeproj/project.pbxproj +++ b/ios/iosApp.xcodeproj/project.pbxproj @@ -27,6 +27,15 @@ 895BED072AF099BC005B1212 /* ProjectsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED062AF099BC005B1212 /* ProjectsContentView.swift */; }; 895BED092AF0A4C3005B1212 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED082AF0A4C3005B1212 /* ErrorView.swift */; }; 895BED0B2AF0A7ED005B1212 /* EmptyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED0A2AF0A7ED005B1212 /* EmptyContentView.swift */; }; + 895BED0E2AF3E66C005B1212 /* ProjectDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED0D2AF3E66C005B1212 /* ProjectDetailsView.swift */; }; + 895BED112AF3FD32005B1212 /* NavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED102AF3FD32005B1212 /* NavigationModifier.swift */; }; + 895BED132AF3FD76005B1212 /* Navigation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED122AF3FD76005B1212 /* Navigation+Extensions.swift */; }; + 895BED152AF3FDD0005B1212 /* Binding+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED142AF3FDD0005B1212 /* Binding+Extensions.swift */; }; + 895BED172AF416F5005B1212 /* VMDColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED162AF416F5005B1212 /* VMDColor+Extensions.swift */; }; + 895BED192AF42C2B005B1212 /* ProjectDetailsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED182AF42C2B005B1212 /* ProjectDetailsContentView.swift */; }; + 895BED1B2AF4469C005B1212 /* NavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED1A2AF4469C005B1212 /* NavigationView.swift */; }; + 895BED1D2AF58517005B1212 /* AnalyticsServiceImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED1C2AF58517005B1212 /* AnalyticsServiceImpl.swift */; }; + 895BED1F2AF9780C005B1212 /* ToReplace-GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 895BED1E2AF9780C005B1212 /* ToReplace-GoogleService-Info.plist */; }; 9B8ACFDB4E332DFCA8B97CBB /* Pods_iosApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E4E1328B104D05A50A097EE /* Pods_iosApp.framework */; }; /* End PBXBuildFile section */ @@ -55,6 +64,15 @@ 895BED062AF099BC005B1212 /* ProjectsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectsContentView.swift; sourceTree = ""; }; 895BED082AF0A4C3005B1212 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; 895BED0A2AF0A7ED005B1212 /* EmptyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyContentView.swift; sourceTree = ""; }; + 895BED0D2AF3E66C005B1212 /* ProjectDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDetailsView.swift; sourceTree = ""; }; + 895BED102AF3FD32005B1212 /* NavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModifier.swift; sourceTree = ""; }; + 895BED122AF3FD76005B1212 /* Navigation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Navigation+Extensions.swift"; sourceTree = ""; }; + 895BED142AF3FDD0005B1212 /* Binding+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+Extensions.swift"; sourceTree = ""; }; + 895BED162AF416F5005B1212 /* VMDColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VMDColor+Extensions.swift"; sourceTree = ""; }; + 895BED182AF42C2B005B1212 /* ProjectDetailsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectDetailsContentView.swift; sourceTree = ""; }; + 895BED1A2AF4469C005B1212 /* NavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationView.swift; sourceTree = ""; }; + 895BED1C2AF58517005B1212 /* AnalyticsServiceImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsServiceImpl.swift; sourceTree = ""; }; + 895BED1E2AF9780C005B1212 /* ToReplace-GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "ToReplace-GoogleService-Info.plist"; sourceTree = ""; }; E232C917135C2C1E3BC8748A /* Pods-iosApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iosApp.release.xcconfig"; path = "Target Support Files/Pods-iosApp/Pods-iosApp.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -109,6 +127,7 @@ 058557BA273AAA24004C7B11 /* Assets.xcassets */, 895BED042AEFFEA2005B1212 /* Colors.xcassets */, 058557D7273AAEEB004C7B11 /* Preview Content */, + 895BED1E2AF9780C005B1212 /* ToReplace-GoogleService-Info.plist */, ); path = iosApp; sourceTree = ""; @@ -120,6 +139,7 @@ 89011D4C2AE9A4FC0073544B /* AppEnvironment+iOS.swift */, 89011D4A2AE9A4CD0073544B /* BootstrapImpl.swift */, 89011D502AE9A7750073544B /* ImageProvider.swift */, + 895BED1C2AF58517005B1212 /* AnalyticsServiceImpl.swift */, ); path = Domain; sourceTree = ""; @@ -129,7 +149,9 @@ children = ( 89011D582AE9B00F0073544B /* Application */, 895BECFF2AEFF0FB005B1212 /* Common */, + 895BED0F2AF3FD0E005B1212 /* Navigation */, 895BECFC2AEFF06C005B1212 /* Projects */, + 895BED0C2AF3E650005B1212 /* ProjectDetails */, 895BECF92AEAB3D1005B1212 /* Root */, 89011D592AE9B0160073544B /* Previews */, ); @@ -156,6 +178,7 @@ isa = PBXGroup; children = ( 89011D522AE9A8150073544B /* LocaleExtensions.swift */, + 895BED162AF416F5005B1212 /* VMDColor+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -184,10 +207,30 @@ 895BED022AEFF281005B1212 /* TextStyle.swift */, 895BED082AF0A4C3005B1212 /* ErrorView.swift */, 895BED0A2AF0A7ED005B1212 /* EmptyContentView.swift */, + 895BED1A2AF4469C005B1212 /* NavigationView.swift */, ); path = Common; sourceTree = ""; }; + 895BED0C2AF3E650005B1212 /* ProjectDetails */ = { + isa = PBXGroup; + children = ( + 895BED0D2AF3E66C005B1212 /* ProjectDetailsView.swift */, + 895BED182AF42C2B005B1212 /* ProjectDetailsContentView.swift */, + ); + path = ProjectDetails; + sourceTree = ""; + }; + 895BED0F2AF3FD0E005B1212 /* Navigation */ = { + isa = PBXGroup; + children = ( + 895BED102AF3FD32005B1212 /* NavigationModifier.swift */, + 895BED122AF3FD76005B1212 /* Navigation+Extensions.swift */, + 895BED142AF3FDD0005B1212 /* Binding+Extensions.swift */, + ); + path = Navigation; + sourceTree = ""; + }; C8C629BFDC2144230B71E3BC /* Frameworks */ = { isa = PBXGroup; children = ( @@ -268,6 +311,7 @@ buildActionMask = 2147483647; files = ( 895BED052AEFFEA2005B1212 /* Colors.xcassets in Resources */, + 895BED1F2AF9780C005B1212 /* ToReplace-GoogleService-Info.plist in Resources */, 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, ); @@ -359,16 +403,24 @@ buildActionMask = 2147483647; files = ( 89011D492AE9A3690073544B /* AppDelegate.swift in Sources */, + 895BED172AF416F5005B1212 /* VMDColor+Extensions.swift in Sources */, 89011D4D2AE9A4FC0073544B /* AppEnvironment+iOS.swift in Sources */, 895BED012AEFF111005B1212 /* StatusBarConfigurator.swift in Sources */, + 895BED152AF3FDD0005B1212 /* Binding+Extensions.swift in Sources */, 89011D5B2AE9B0310073544B /* PreviewHelpers.swift in Sources */, 895BED072AF099BC005B1212 /* ProjectsContentView.swift in Sources */, + 895BED132AF3FD76005B1212 /* Navigation+Extensions.swift in Sources */, + 895BED0E2AF3E66C005B1212 /* ProjectDetailsView.swift in Sources */, 89011D4F2AE9A7340073544B /* AppInitializer.swift in Sources */, + 895BED1B2AF4469C005B1212 /* NavigationView.swift in Sources */, 89011D542AE9A8150073544B /* LocaleExtensions.swift in Sources */, 895BED092AF0A4C3005B1212 /* ErrorView.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, 895BECFE2AEFF078005B1212 /* ProjectsView.swift in Sources */, + 895BED192AF42C2B005B1212 /* ProjectDetailsContentView.swift in Sources */, + 895BED112AF3FD32005B1212 /* NavigationModifier.swift in Sources */, 89011D512AE9A7750073544B /* ImageProvider.swift in Sources */, + 895BED1D2AF58517005B1212 /* AnalyticsServiceImpl.swift in Sources */, 89011D4B2AE9A4CD0073544B /* BootstrapImpl.swift in Sources */, 895BED032AEFF281005B1212 /* TextStyle.swift in Sources */, 895BECFB2AEAB3DC005B1212 /* RootView.swift in Sources */, diff --git a/ios/iosApp/AppDelegate.swift b/ios/iosApp/AppDelegate.swift index 0430115..b23f016 100644 --- a/ios/iosApp/AppDelegate.swift +++ b/ios/iosApp/AppDelegate.swift @@ -2,7 +2,7 @@ import Shared import UIKit class AppDelegate: NSObject, UIApplicationDelegate { - let bootstrapper = Bootstrapper() + private lazy var bootstrapper = Bootstrapper(bootstrap: BootstrapImpl()) lazy var applicationViewModel: ApplicationViewModel = { bootstrapper.applicationViewModel() @@ -12,9 +12,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - let bootstrap = BootstrapImpl() - AppInitializer.initializeComponents(environment: bootstrap.environment) - bootstrapper.doInitDependencies(bootstrap: bootstrap) + AppInitializer.initializeComponents(environment: bootstrapper.bootstrap.environment) + bootstrapper.doInitDependencies() return true } diff --git a/ios/iosApp/AppInitializer.swift b/ios/iosApp/AppInitializer.swift index 79768a2..be45230 100644 --- a/ios/iosApp/AppInitializer.swift +++ b/ios/iosApp/AppInitializer.swift @@ -1,12 +1,56 @@ +import AppCenter +import AppCenterDistribute +import FirebaseCore import Foundation import Kingfisher import Shared import Trikot enum AppInitializer { - static func initializeComponents(environment: AppEnvironment) { + static func initializeComponents(environment _: AppEnvironment) { + initializeFirebase() + initializeAppCenter() initializeCommon() initializeKingfisher() + inititalizeKillSwitch() + } + + private static func initializeFirebase() { + guard + let filePath = Bundle.main.path(forResource: "ToReplace-GoogleService-Info", ofType: "plist"), + let firebaseOptions = FirebaseOptions(contentsOfFile: filePath) else { + return + } + + FirebaseApp.configure(options: firebaseOptions) + + let firebaseAnalyticsService = AnalyticsServiceImpl() + #if DEBUG + firebaseAnalyticsService.isEnabled = false + #else + firebaseAnalyticsService.isEnabled = true + #endif + + SharedAnalyticsConfiguration().analyticsManager = firebaseAnalyticsService + } + + private static func initializeAppCenter() { + guard let appCenterSecret = Bundle.main.object(forInfoDictionaryKey: "APP_CENTER_APP_SECRET") as? String, !appCenterSecret.isEmpty else { return } + Distribute.updateTrack = .private + AppCenter.start(withAppSecret: appCenterSecret, services: [Distribute.self]) + } + + private static func initializeFirebase() { + FirebaseApp.configure() + + let firebaseAnalyticsService = AnalyticsServiceImpl() + #if DEBUG + firebaseAnalyticsService.isEnabled = false + #else + firebaseAnalyticsService.isEnabled = true + #endif + + SharedAnalyticsConfiguration().analyticsManager = firebaseAnalyticsService } private static func initializeCommon() { @@ -20,4 +64,20 @@ enum AppInitializer { private static func initializeKingfisher() { ImageCache.default.diskStorage.config.sizeLimit = 500 * 1_024 * 1_024 // 500 MB } + + private static func inititalizeKillSwitch() { + Task { + do { + let viewData = try await IOSKillswitch().engage( + key: AppEnvironment.current.iOSSpecific.killSwitchAPIKey, + url: AppEnvironment.current.iOSSpecific.killSwitchUrl + ) + DispatchQueue.main.async { + IOSKillswitch().showDialog(viewData: viewData) + } + } catch { + print("Killswitch error: \(error)") + } + } + } } diff --git a/ios/iosApp/Domain/AnalyticsServiceImpl.swift b/ios/iosApp/Domain/AnalyticsServiceImpl.swift new file mode 100644 index 0000000..d4e8abd --- /dev/null +++ b/ios/iosApp/Domain/AnalyticsServiceImpl.swift @@ -0,0 +1,20 @@ +import FirebaseAnalytics +import Foundation +import Shared + +public class AnalyticsServiceImpl: SharedAnalyticsService { + + public init(enableAnalytics: Bool = true) { + isEnabled = enableAnalytics + } + + public var isEnabled: Bool { + didSet { + Analytics.setAnalyticsCollectionEnabled(isEnabled) + } + } + + public func trackEvent(event: AnalyticsEvent, properties: [String: Any]) { + Analytics.logEvent(event.name, parameters: properties) + } +} diff --git a/ios/iosApp/Domain/ImageProvider.swift b/ios/iosApp/Domain/ImageProvider.swift index b0616f6..31aaee9 100644 --- a/ios/iosApp/Domain/ImageProvider.swift +++ b/ios/iosApp/Domain/ImageProvider.swift @@ -12,6 +12,8 @@ final class ImageProvider: VMDImageProvider { return Image(systemName: "exclamationmark.triangle.fill") case .imageplaceholder: return Image(systemName: "photo") + case .closeicon: + return Image(systemName: "xmark.circle.fill") } } } diff --git a/ios/iosApp/Extensions/VMDColor+Extensions.swift b/ios/iosApp/Extensions/VMDColor+Extensions.swift new file mode 100644 index 0000000..052d9f6 --- /dev/null +++ b/ios/iosApp/Extensions/VMDColor+Extensions.swift @@ -0,0 +1,14 @@ +import Shared +import SwiftUI + +extension VMDColor { + var color: Color { + Color( + Color.RGBColorSpace.sRGB, + red: Double(Double(red) / 255.0), + green: Double(Double(green) / 255.0), + blue: Double(Double(blue) / 255.0), + opacity: Double(alpha) + ) + } +} diff --git a/ios/iosApp/GoogleService-Info.plist b/ios/iosApp/GoogleService-Info.plist new file mode 100644 index 0000000..58af956 --- /dev/null +++ b/ios/iosApp/GoogleService-Info.plist @@ -0,0 +1,32 @@ + + + + + ANDROID_CLIENT_ID + 123456789012-0cnvm52tlj4cn7rkfujdkuq3iq79v5ig.apps.googleusercontent.com + API_KEY + AIzaSyC3_0lgMdiPr41DjqzSWP2MqGIqIakley0 + GCM_SENDER_ID + 123456789012 + PLIST_VERSION + 1 + BUNDLE_ID + com.mirego.kmp.boilerplate + PROJECT_ID + boilerplate-1234567890123 + STORAGE_BUCKET + boilerplate-1234567890123.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:123456789012:ios:c930c9194f493843b44d0a + + diff --git a/ios/iosApp/ToReplace-GoogleService-Info.plist b/ios/iosApp/ToReplace-GoogleService-Info.plist new file mode 100644 index 0000000..58af956 --- /dev/null +++ b/ios/iosApp/ToReplace-GoogleService-Info.plist @@ -0,0 +1,32 @@ + + + + + ANDROID_CLIENT_ID + 123456789012-0cnvm52tlj4cn7rkfujdkuq3iq79v5ig.apps.googleusercontent.com + API_KEY + AIzaSyC3_0lgMdiPr41DjqzSWP2MqGIqIakley0 + GCM_SENDER_ID + 123456789012 + PLIST_VERSION + 1 + BUNDLE_ID + com.mirego.kmp.boilerplate + PROJECT_ID + boilerplate-1234567890123 + STORAGE_BUCKET + boilerplate-1234567890123.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:123456789012:ios:c930c9194f493843b44d0a + + diff --git a/ios/iosApp/UI/Common/NavigationView.swift b/ios/iosApp/UI/Common/NavigationView.swift new file mode 100644 index 0000000..267a887 --- /dev/null +++ b/ios/iosApp/UI/Common/NavigationView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +private struct EmbedInNavigationViewModifier: ViewModifier { + func body(content: Content) -> some View { + NavigationView { + content + .navigationBarBackButtonHidden(true) + .navigationBarTitleDisplayMode(.inline) + } + .navigationViewStyle(.stack) + } +} + +extension View { + func embedInNavigationView() -> some View { + modifier(EmbedInNavigationViewModifier()) + } +} diff --git a/ios/iosApp/UI/Common/TextStyle.swift b/ios/iosApp/UI/Common/TextStyle.swift index 9a75a3a..54ea619 100644 --- a/ios/iosApp/UI/Common/TextStyle.swift +++ b/ios/iosApp/UI/Common/TextStyle.swift @@ -67,11 +67,11 @@ enum TextStyle: String { var fontName: String { switch self { - case .light: return "SFPro-Light" - case .regular: return "SFPro-Regular" - case .medium: return "SFPro-Medium" - case .semiBold: return "SFPro-Semibold" - case .bold: return "SFPro-Bold" + case .light: return "HelveticaNeue-Light" + case .regular: return "HelveticaNeue" + case .medium: return "HelveticaNeue-Medium" + case .semiBold: return "HelveticaNeue-Bold" + case .bold: return "HelveticaNeue-CondensedBold" } } } diff --git a/ios/iosApp/UI/Navigation/Binding+Extensions.swift b/ios/iosApp/UI/Navigation/Binding+Extensions.swift new file mode 100644 index 0000000..a927b56 --- /dev/null +++ b/ios/iosApp/UI/Navigation/Binding+Extensions.swift @@ -0,0 +1,26 @@ +import Shared +import SwiftUI + +extension Binding { + init(value: Value, didSet: ((Value) -> Void)? = nil) { + self.init(get: { value }, set: { didSet?($0) }) + } + + func toIdentifiable(didSet: ((IdentifiableWrapper?) -> Void)? = nil) -> Binding?> where Value == Wrapped? { + .init( + get: { + if let wrappedValue = wrappedValue { + return IdentifiableWrapper(value: wrappedValue) + } else { + return nil as IdentifiableWrapper? + } + }, + set: { didSet?($0) } + ) + } +} + +struct IdentifiableWrapper: Identifiable { + let value: T + var id: String { value.name } +} diff --git a/ios/iosApp/UI/Navigation/Navigation+Extensions.swift b/ios/iosApp/UI/Navigation/Navigation+Extensions.swift new file mode 100644 index 0000000..630928b --- /dev/null +++ b/ios/iosApp/UI/Navigation/Navigation+Extensions.swift @@ -0,0 +1,92 @@ +import Shared +import SwiftUI +import Trikot + +extension View { + func sheet( + route: Route?, + @ViewBuilder content: @escaping (Route) -> Content + ) -> some View { + sheet( + item: Binding(value: route) + .toIdentifiable( + didSet: { newValue in + if newValue == nil { + route?.resetBlock() + } + } + ), + content: { item in + content(item.value) + // The scenePhase is not working when presenting a sheet or a fullScreenCover. With that modifier we pass it from the parent + .modifier(ScenePhaseModifier()) + } + ) + } + + func fullScreen( + route: Route?, + @ViewBuilder content: @escaping (Route) -> Content + ) -> some View { + fullScreenCover( + item: Binding(value: route) + .toIdentifiable( + didSet: { newValue in + if newValue == nil { + route?.resetBlock() + } + } + ), + content: { item in + content(item.value) + // The scenePhase is not working when presenting a sheet or a fullScreenCover. With that modifier we pass it from the parent + .modifier(ScenePhaseModifier()) + } + ) + } + + func push( + route: Route?, + @ViewBuilder content: @escaping (Route) -> Content + ) -> some View { + background( + NavigationLink( + isActive: Binding(get: { + route != nil + }, set: { newValue in + guard !newValue else { + return + } + route?.resetBlock() + }), + destination: { + if let route = route { + content(route) + } + }, + label: { + EmptyView() + } + ) + .hidden() + .accessibilityHidden(true) + ) + } +} + +struct ScenePhaseModifier: ViewModifier { + @Environment(\.scenePhase) private var scenePhase + + func body(content: Content) -> some View { + content + .environment(\.scenePhase, scenePhase) + } +} + +struct NavigationRouteWrapper: Equatable { + static func == (lhs: NavigationRouteWrapper, rhs: NavigationRouteWrapper) -> Bool { + lhs.route?.name == rhs.route?.name + } + + let route: Route? +} diff --git a/ios/iosApp/UI/Navigation/NavigationModifier.swift b/ios/iosApp/UI/Navigation/NavigationModifier.swift new file mode 100644 index 0000000..6116a7f --- /dev/null +++ b/ios/iosApp/UI/Navigation/NavigationModifier.swift @@ -0,0 +1,58 @@ +import Shared +import SwiftUI +import Trikot + +struct NavigationModifier: ViewModifier { + let viewModel: VMDNavigationViewModel + let route: VMDNavigationRoute? + let navigationTypeOverride: ((VMDNavigationRoute) -> NavigationType?)? + + func body(content: Content) -> some View { + content + .sheet(route: navigationType(for: route) == .sheet ? route : nil) { route in + buildView(for: route) + } + .fullScreen(route: navigationType(for: route) == .fullScreen ? route : nil) { route in + buildView(for: route) + } + .push(route: navigationType(for: route) == .push ? route : nil) { route in + buildView(for: route) + } + } + + @ViewBuilder private func buildView(for route: VMDNavigationRoute) -> some View { + if let route = route as? NavigationRouteProjectDetails { + ProjectDetailsView(viewModel: route.viewModel) + } + } + + private func navigationType(for route: VMDNavigationRoute?) -> NavigationType? { + if let route, let overridenNavigationType = navigationTypeOverride?(route) { + return overridenNavigationType + } + + if route is NavigationRouteProjectDetails { + return .push + } + + return nil + } +} + +extension View { + func handleNavigation(_ viewModel: VMDNavigationViewModel, route: VMDNavigationRoute?, navigationTypeOverride: ((VMDNavigationRoute) -> NavigationType?)? = nil) -> some View { + modifier( + NavigationModifier( + viewModel: viewModel, + route: route, + navigationTypeOverride: navigationTypeOverride + ) + ) + } +} + +enum NavigationType { + case sheet + case fullScreen + case push +} diff --git a/ios/iosApp/UI/ProjectDetails/ProjectDetailsContentView.swift b/ios/iosApp/UI/ProjectDetails/ProjectDetailsContentView.swift new file mode 100644 index 0000000..d664969 --- /dev/null +++ b/ios/iosApp/UI/ProjectDetails/ProjectDetailsContentView.swift @@ -0,0 +1,84 @@ +import Shared +import SwiftUI +import Trikot + +struct ProjectDetailsContentView: View { + let viewModel: ProjectDetailsRootContent + private var textColor: Color { + viewModel.textColor.color + } + + var body: some View { + GeometryReader { proxy in + contentView + .redacted(reason: viewModel.isLoading ? .placeholder : []) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .background( + backgroundImageView + .frame(width: proxy.size.width, height: proxy.size.width * 1.25, alignment: .top), + alignment: .top + ) + } + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 0) + Text(viewModel.title) + .textStyle(.largeTitle, .bold, textColor) + + Text(viewModel.subtitle) + .textStyle(.title1, .semiBold, textColor) + .padding(.top, 24) + + textColor + .frame(height: 1) + .frame(maxWidth: .infinity) + .padding(.top, 24) + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.projectType.first) + .textStyle(.subHeadline, .semiBold, textColor) + .fixedSize(horizontal: false, vertical: true) + + Text(viewModel.projectType.second) + .textStyle(.subHeadline, .regular, textColor) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 32) + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.releaseYear.first) + .textStyle(.subHeadline, .semiBold, textColor) + .fixedSize(horizontal: false, vertical: true) + + Text(viewModel.releaseYear.second) + .textStyle(.subHeadline, .regular, textColor) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 16) + } + .padding(.horizontal, 16) + } + + private var backgroundImageView: some View { + VMDImage(viewModel.image) + .placeholder { imagePlaceHolder in + ZStack { + Rectangle() + .foregroundColor(viewModel.backgroundColor.color) + imagePlaceHolder? + .resizable() + .scaledToFit() + .foregroundStyle(Color.black) + .frame(width: 100, height: 100) + } + } + .resizable() + .scaledToFit() + } +} diff --git a/ios/iosApp/UI/ProjectDetails/ProjectDetailsView.swift b/ios/iosApp/UI/ProjectDetails/ProjectDetailsView.swift new file mode 100644 index 0000000..9828092 --- /dev/null +++ b/ios/iosApp/UI/ProjectDetails/ProjectDetailsView.swift @@ -0,0 +1,58 @@ +import Shared +import SwiftUI +import Trikot + +struct ProjectDetailsView: View { + @ObservedObject private var observableViewModel: ObservableViewModelAdapter + + init(viewModel: ProjectDetailsViewModel) { + observableViewModel = viewModel.asObservable() + } + + var viewModel: ProjectDetailsViewModel { + observableViewModel.viewModel + } + + var body: some View { + ZStack(alignment: .topLeading) { + contentView + .ignoresSafeArea(edges: .top) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + viewModel.backgroundColor.color + .ignoresSafeArea() + ) + .navigationBarBackButtonHidden() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + VMDButton(viewModel.closeButton) { + $0.image.image? + .renderingMode(.template) + .resizable() + .frame(width: 35, height: 35) + .foregroundStyle(Color.white) + .contentShape(Rectangle()) + } + } + } + .handleNavigation(viewModel, route: viewModel.navigationRoute) + } + + @ViewBuilder private var contentView: some View { + if let root = viewModel.rootContent { + switch onEnum(of: root) { + case let .content(content): + ProjectDetailsContentView(viewModel: content) + case let .error(error): + ErrorView(viewModel: error.errorViewModel) + } + } + } +} + +#Preview { + ProjectDetailsView( + viewModel: factoryPreview().createProjectDetails(previewState: PreviewStateDataContent()) + ) +} diff --git a/ios/iosApp/UI/Projects/ProjectsContentView.swift b/ios/iosApp/UI/Projects/ProjectsContentView.swift index 7dc08ba..4ad133b 100644 --- a/ios/iosApp/UI/Projects/ProjectsContentView.swift +++ b/ios/iosApp/UI/Projects/ProjectsContentView.swift @@ -49,8 +49,12 @@ struct ProjectsContentView: View { private func projectListView(viewModel: VMDListViewModel, itemSize: CGFloat) -> some View { VStack(spacing: 16) { ForEach(viewModel.elements, id: \.identifier) { item in - itemView(viewModel: item) - .frame(width: itemSize) + Button { + item.tapAction() + } label: { + itemView(viewModel: item) + .frame(width: itemSize) + } } } } @@ -66,6 +70,7 @@ struct ProjectsContentView: View { .resizable() .scaledToFit() .frame(width: 100, height: 100) + .foregroundStyle(Color.black) } } .resizable() @@ -82,12 +87,14 @@ struct ProjectsContentView: View { Text(viewModel.subtitle) .textStyle(.title1, .regular, .white) .lineLimit(2) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .padding(.top, 4) Text(viewModel.description_) .textStyle(.caption1, .regular, Color(.accentOrange)) .lineLimit(2) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) .padding(.top, padding) } diff --git a/ios/iosApp/UI/Projects/ProjectsView.swift b/ios/iosApp/UI/Projects/ProjectsView.swift index fa541da..5a7b302 100644 --- a/ios/iosApp/UI/Projects/ProjectsView.swift +++ b/ios/iosApp/UI/Projects/ProjectsView.swift @@ -20,6 +20,7 @@ struct ProjectsView: View { Color(.primaryBlack) .ignoresSafeArea() ) + .handleNavigation(viewModel, route: viewModel.navigationRoute, navigationTypeOverride: navigationTypeOverride) } @ViewBuilder private var contentView: some View { @@ -34,6 +35,16 @@ struct ProjectsView: View { } } +extension ProjectsView { + func navigationTypeOverride(route: VMDNavigationRoute) -> NavigationType? { + if route is NavigationRouteProjectDetails { + return .push + } + + return nil + } +} + #Preview { ProjectsView( viewModel: factoryPreview().createProjects( diff --git a/ios/iosApp/UI/Root/RootView.swift b/ios/iosApp/UI/Root/RootView.swift index 4489ea3..ce24fff 100644 --- a/ios/iosApp/UI/Root/RootView.swift +++ b/ios/iosApp/UI/Root/RootView.swift @@ -15,6 +15,7 @@ struct RootView: View { var body: some View { ProjectsView(viewModel: viewModel.projectsViewModel) + .embedInNavigationView() } } diff --git a/settings.gradle.kts b/settings.gradle.kts index bb4b602..5cafb21 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ pluginManagement { repositories { google() gradlePluginPortal() + mavenLocal() mavenCentral() maven("https://s3.amazonaws.com/mirego-maven/public") resolutionStrategy { @@ -19,6 +20,7 @@ pluginManagement { dependencyResolutionManagement { repositories { google() + mavenLocal() mavenCentral() maven("https://s3.amazonaws.com/mirego-maven/public") } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index e992969..769f961 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -19,10 +19,12 @@ val TRIKOT_FRAMEWORK_NAME = "Shared" fun org.jetbrains.kotlin.gradle.plugin.mpp.Framework.configureFramework() { baseName = TRIKOT_FRAMEWORK_NAME isStatic = false + export(libs.trikot.analytics) export(libs.trikot.vmd) export(libs.trikot.kword) export(libs.trikot.datasources) export(libs.trikot.vmd.annotations) + export(libs.killswitch) binaryOption("bundleId", TRIKOT_FRAMEWORK_NAME) } @@ -102,10 +104,12 @@ kotlin { api(libs.koin.core) implementation(libs.okio) implementation(libs.skie) + api(libs.trikot.analytics) api(libs.trikot.vmd.annotations) api(libs.trikot.datasources) api(libs.trikot.kword) api(libs.trikot.vmd) + api(libs.killswitch) } kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") kotlin.srcDir(kword.generatedDir) @@ -127,7 +131,6 @@ kotlin { } } - val iosX64Main by getting val iosArm64Main by getting val iosSimulatorArm64Main by getting @@ -168,6 +171,8 @@ ktlint { enableExperimentalRules.set(true) filter { exclude { element -> element.file.path.contains("generated/") } + exclude { element -> element.file.path.contains("viewmodel/SharedImageResource") } + exclude { element -> element.file.path.contains("analytics/Analytics") } } } @@ -178,3 +183,13 @@ tasks.withType>().all { dependsOn(tasks.withType()) } } + +tasks["runKtlintFormatOverCommonMainSourceSet"].dependsOn("kspCommonMainKotlinMetadata") +tasks["runKtlintCheckOverCommonMainSourceSet"].dependsOn("kspCommonMainKotlinMetadata") + +val checkCommon: Task by tasks.creating { + group = "verification" + description = "Like check, but only with android target for common unit tests" + dependsOn("ktlintCheck") + dependsOn("testReleaseUnitTest") +} diff --git a/shared/src/commonMain/generated/com/mirego/kmp/boilerplate/localization/KWordTranslation.kt b/shared/src/commonMain/generated/com/mirego/kmp/boilerplate/localization/KWordTranslation.kt index 846d29b..65e6e26 100644 --- a/shared/src/commonMain/generated/com/mirego/kmp/boilerplate/localization/KWordTranslation.kt +++ b/shared/src/commonMain/generated/com/mirego/kmp/boilerplate/localization/KWordTranslation.kt @@ -6,6 +6,8 @@ import kotlin.String enum class KWordTranslation( override val translationKey: String ) : KWordKey { + PROJECT_DETAILS_RELEASE_YEAR("project_details_release_year"), + GENERIC_ERROR_MESSAGE("generic_error_message"), GENERIC_ERROR_TITLE("generic_error_title"), @@ -18,5 +20,7 @@ enum class KWordTranslation( GENERIC_EMPTY_CONTENT_TITLE("generic_empty_content_title"), - GENERIC_RETRY("generic_retry"); + GENERIC_RETRY("generic_retry"), + + PROJECT_DETAILS_PROJECT_TYPE("project_details_project_type"); } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/StateDataExtensions.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/StateDataExtensions.kt index f436e65..f8a5133 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/StateDataExtensions.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/StateDataExtensions.kt @@ -1,2 +1 @@ package com.mirego.kmp.boilerplate - diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/Analytics.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/Analytics.kt new file mode 100644 index 0000000..c81082b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/Analytics.kt @@ -0,0 +1,39 @@ +@file:Suppress("EnumEntryName") + +package com.mirego.kmp.boilerplate.analytics + +import com.mirego.trikot.analytics.AnalyticsConfiguration +import com.mirego.trikot.analytics.AnalyticsEvent + +object Analytics { + private const val PARAM_SCREEN_TITLE = "screen_title" + private const val PARAM_PROJECT_ID = "project_id" + + fun trackScreenView(screen: ScreenName) { + AnalyticsConfiguration.analyticsManager.trackEvent( + event = AnalyticsEvents.screen_view, + properties = mapOf( + PARAM_SCREEN_TITLE to screen.name + ) + ) + } + + fun trackViewProject(projectId: String) { + AnalyticsConfiguration.analyticsManager.trackEvent( + event = AnalyticsEvents.view_project, + properties = mapOf( + PARAM_PROJECT_ID to projectId + ) + ) + } +} + +enum class AnalyticsEvents : AnalyticsEvent { + screen_view, + view_project +} + +enum class ScreenName { + projects, + project_details +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/EmptySharedAnalyticsService.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/EmptySharedAnalyticsService.kt new file mode 100644 index 0000000..1f89368 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/EmptySharedAnalyticsService.kt @@ -0,0 +1,12 @@ +package com.mirego.kmp.boilerplate.analytics + +import com.mirego.trikot.analytics.AnalyticsEvent +import com.mirego.trikot.analytics.AnalyticsPropertiesType + +class EmptySharedAnalyticsService : SharedAnalyticsService { + override var isEnabled: Boolean = false + + override fun trackEvent(event: AnalyticsEvent, properties: AnalyticsPropertiesType) { + // No-Op + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsConfiguration.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsConfiguration.kt new file mode 100644 index 0000000..d236cbb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsConfiguration.kt @@ -0,0 +1,5 @@ +package com.mirego.kmp.boilerplate.analytics + +object SharedAnalyticsConfiguration { + var analyticsManager: SharedAnalyticsService = EmptySharedAnalyticsService() +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsService.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsService.kt new file mode 100644 index 0000000..cd5d468 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/analytics/SharedAnalyticsService.kt @@ -0,0 +1,9 @@ +package com.mirego.kmp.boilerplate.analytics + +import com.mirego.trikot.analytics.AnalyticsEvent +import com.mirego.trikot.analytics.AnalyticsPropertiesType + +interface SharedAnalyticsService { + var isEnabled: Boolean + fun trackEvent(event: AnalyticsEvent, properties: AnalyticsPropertiesType) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt index 21e6902..a326786 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppEnvironment.kt @@ -2,14 +2,37 @@ package com.mirego.kmp.boilerplate.bootstrap enum class AppEnvironment( val key: String, - val graphQlApiUrl: String + val graphQlApiUrl: String, + val iOSSpecific: PlatformSpecific, + val androidSpecific: PlatformSpecific ) { DEV( key = "dev", - "https://api.mirego.com/graphql" + "https://api.mirego.com/graphql", + iOSSpecific = PlatformSpecific( + killSwitchAPIKey = "", // Replace with your own killSwitchAPIKey + killSwitchUrl = "https://killswitch.mirego.com/killswitch" + ), + androidSpecific = PlatformSpecific( + killSwitchAPIKey = "", // Replace with your own killSwitchAPIKey + killSwitchUrl = "https://killswitch.mirego.com/killswitch" + ) ), PRODUCTION( key = "production", - "https://api.mirego.com/graphql" + "https://api.mirego.com/graphql", + iOSSpecific = PlatformSpecific( + killSwitchAPIKey = "", // Replace with your own killSwitchAPIKey + killSwitchUrl = "https://killswitch.mirego.com/killswitch" + ), + androidSpecific = PlatformSpecific( + killSwitchAPIKey = "", // Replace with your own killSwitchAPIKey + killSwitchUrl = "https://killswitch.mirego.com/killswitch" + ) ) } + +data class PlatformSpecific( + val killSwitchAPIKey: String, + val killSwitchUrl: String +) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt index e9aecf9..d723760 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Bootstrapper.kt @@ -10,7 +10,9 @@ import org.koin.core.component.get import org.koin.core.context.startKoin import org.koin.core.parameter.parametersOf -class Bootstrapper : KoinComponent { +class Bootstrapper( + val bootstrap: Bootstrap +) : KoinComponent { private val exceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> println("Exception: $throwable") @@ -19,7 +21,7 @@ class Bootstrapper : KoinComponent { private val rootCoroutineScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob() + exceptionHandler) - fun initDependencies(bootstrap: Bootstrap) = startKoin { + fun initDependencies() = startKoin { configureKoin(bootstrap) } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt index 89c3e5d..daf692d 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/Module.kt @@ -37,11 +37,9 @@ fun generalModule(bootstrap: Bootstrap): Module { } } -private fun createApolloClientBuilder(bootstrap: Bootstrap): ApolloClient.Builder = - ApolloClient.Builder() - .serverUrl(bootstrap.environment.graphQlApiUrl) - .addHttpInterceptor(LocaleHeaderInterceptor()) - +private fun createApolloClientBuilder(bootstrap: Bootstrap): ApolloClient.Builder = ApolloClient.Builder() + .serverUrl(bootstrap.environment.graphQlApiUrl) + .addHttpInterceptor(LocaleHeaderInterceptor()) object ModuleQualifier { const val DISK_CACHE_PATH = "diskCachePath" diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceImpl.kt index 45cd686..708dc19 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceImpl.kt @@ -1,6 +1,7 @@ package com.mirego.kmp.boilerplate.datasource import com.apollographql.apollo3.ApolloClient +import com.mirego.kmp.boilerplate.ProjectDetailsQuery import com.mirego.kmp.boilerplate.ProjectsQuery import com.mirego.kmp.boilerplate.bootstrap.ModuleQualifier import com.mirego.kmp.boilerplate.datasource.apollo.ApolloGraphQLDataSource @@ -15,3 +16,12 @@ class ProjectsDataSource( apolloClient = apolloClient, diskCachePath = "$diskCachePath/projects" ) + +@Single +class ProjectDetailsDataSource( + apolloClient: ApolloClient, + @Named(ModuleQualifier.DISK_CACHE_PATH) private val diskCachePath: String +) : ApolloGraphQLDataSource( + apolloClient = apolloClient, + diskCachePath = "$diskCachePath/projectDetails" +) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDiskDataSource.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDiskDataSource.kt index 2b3e746..5e79b1a 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDiskDataSource.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDiskDataSource.kt @@ -50,10 +50,10 @@ class ApolloGraphQLDiskDataSource, T : Que override suspend fun save(request: R, data: FlowDataSourceExpiringValue?) { data?.value?.let { dataToSave -> withContext(Dispatchers.IO) { + val filePath = buildFilePath(request) try { NativeFileSystem.fileSystem.createDirectories(diskCachePath.toPath()) - val filePath = buildFilePath(request) - val content = NativeFileSystem.fileSystem.sink(filePath).buffer() + val content = NativeFileSystem.fileSystem.sink(file = filePath, mustCreate = true).buffer() val jsonWritter = BufferedSinkJsonWriter(content) jsonWritter.beginObject() request.serializeJsonMethod(jsonWritter, customScalarAdapters, dataToSave) @@ -61,7 +61,7 @@ class ApolloGraphQLDiskDataSource, T : Que content.flush() content.close() } catch (error: Throwable) { - println("Failed to save json in disk cache! Error: $error") + println("Failed to save json in disk cache, $filePath : $error") } } } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/generic/GenericDataSource.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/generic/GenericDataSource.kt index ce6cd41..c73572f 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/generic/GenericDataSource.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/generic/GenericDataSource.kt @@ -4,9 +4,9 @@ import com.mirego.kmp.boilerplate.datasource.DataSourceUtils import com.mirego.trikot.datasources.flow.BaseExpiringExecutableFlowDataSource import com.mirego.trikot.datasources.flow.ExpiringFlowDataSourceRequest import com.mirego.trikot.datasources.flow.FlowDataSourceRequest +import kotlin.time.Duration.Companion.minutes import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json -import kotlin.time.Duration.Companion.minutes open class GenericDataSource( json: Json, diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectDetails.graphql b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectDetails.graphql new file mode 100644 index 0000000..5ba8ec2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectDetails.graphql @@ -0,0 +1,22 @@ +query ProjectDetails($projectId: PageSlug!) { + page(slug: $projectId) { + ... on Page { + blocks { + blockType + ...on ProjectHeader { + entity { + mainImageUrl(width: 1200, height: 1200) + name + projectType + year + mainColor + textColor + client { + name + } + } + } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectsQuery.graphql b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectsQuery.graphql index 30ed6be..6ebb31a 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectsQuery.graphql +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/query/ProjectsQuery.graphql @@ -12,6 +12,8 @@ query Projects($projectsSlug: PageSlug!) { client { name } + mainColor + textColor } } } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepository.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepository.kt new file mode 100644 index 0000000..e9b2941 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepository.kt @@ -0,0 +1,9 @@ +package com.mirego.kmp.boilerplate.repository.projectdetails + +import com.mirego.kmp.boilerplate.ProjectDetailsQuery +import com.mirego.kmp.boilerplate.utils.StateData +import kotlinx.coroutines.flow.Flow + +interface ProjectDetailsRepository { + fun projectDetails(id: String): Flow> +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepositoryImpl.kt new file mode 100644 index 0000000..6d38db6 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projectdetails/ProjectDetailsRepositoryImpl.kt @@ -0,0 +1,42 @@ +package com.mirego.kmp.boilerplate.repository.projectdetails + +import com.mirego.kmp.boilerplate.ProjectDetailsQuery +import com.mirego.kmp.boilerplate.ProjectDetailsQuery.Data.Page.Companion.asPage +import com.mirego.kmp.boilerplate.ProjectDetailsQuery.Data.PagePage.Block.Companion.asProjectHeader +import com.mirego.kmp.boilerplate.adapter.ProjectDetailsQuery_ResponseAdapter +import com.mirego.kmp.boilerplate.datasource.ProjectDetailsDataSource +import com.mirego.kmp.boilerplate.datasource.apollo.ApolloGraphQLDataSourceRequest +import com.mirego.kmp.boilerplate.extension.mapToErrorIfValueNull +import com.mirego.kmp.boilerplate.utils.StateData +import com.mirego.trikot.datasources.flow.FlowDataSourceRequest +import com.mirego.trikot.datasources.flow.extensions.mapValue +import kotlinx.coroutines.flow.Flow +import org.koin.core.annotation.Single + +@Single +class ProjectDetailsRepositoryImpl( + private val dataSource: ProjectDetailsDataSource +) : ProjectDetailsRepository { + + override fun projectDetails(id: String): Flow> = dataSource.read( + ProjectDetailsQuery(id).request(forceRefresh = false) + ).mapValue { + it.value.page + ?.asPage() + ?.blocks?.firstNotNullOfOrNull { + it.asProjectHeader()?.entity + } + }.mapToErrorIfValueNull() +} + +private fun ProjectDetailsQuery.request(forceRefresh: Boolean = true) = ApolloGraphQLDataSourceRequest( + query = this, + serializeJsonMethod = ProjectDetailsQuery_ResponseAdapter.Data::toJson, + deSerializeJsonMethod = ProjectDetailsQuery_ResponseAdapter.Data::fromJson, + cacheableId = "${projectId.toString().replace("/", "-")}-${id()}", + requestType = if (forceRefresh) { + FlowDataSourceRequest.Type.REFRESH_CACHE + } else { + FlowDataSourceRequest.Type.USE_CACHE + } +) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projects/ProjectsRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projects/ProjectsRepositoryImpl.kt index 1a61069..dbef80a 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projects/ProjectsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/projects/ProjectsRepositoryImpl.kt @@ -22,20 +22,19 @@ class ProjectsRepositoryImpl( private val localeRepository: LocaleRepository, private val dataSource: ProjectsDataSource ) : ProjectsRepository { - override fun projects(): Flow>> = - localeRepository.locale() - .flatMapLatest { locale -> - dataSource.read( - buildQuery(language = locale.language) - .request(forceRefresh = false) - ).mapValue { - it.value.page - ?.asPage() - ?.blocks?.firstNotNullOfOrNull { - it.asProjectsList()?.projects?.entries - }.orEmpty() - } + override fun projects(): Flow>> = localeRepository.locale() + .flatMapLatest { locale -> + dataSource.read( + buildQuery(language = locale.language) + .request(forceRefresh = false) + ).mapValue { + it.value.page + ?.asPage() + ?.blocks?.firstNotNullOfOrNull { + it.asProjectsList()?.projects?.entries + }.orEmpty() } + } override suspend fun refreshProjects() { val locale = localeRepository.locale().first() @@ -53,11 +52,10 @@ class ProjectsRepositoryImpl( ) } -private fun ProjectsQuery.request(forceRefresh: Boolean = true) = - ApolloGraphQLDataSourceRequest( - query = this, - serializeJsonMethod = ProjectsQuery_ResponseAdapter.Data::toJson, - deSerializeJsonMethod = ProjectsQuery_ResponseAdapter.Data::fromJson, - cacheableId = id() + projectsSlug.toString(), - requestType = if (forceRefresh) FlowDataSourceRequest.Type.REFRESH_CACHE else FlowDataSourceRequest.Type.USE_CACHE - ) +private fun ProjectsQuery.request(forceRefresh: Boolean = true) = ApolloGraphQLDataSourceRequest( + query = this, + serializeJsonMethod = ProjectsQuery_ResponseAdapter.Data::toJson, + deSerializeJsonMethod = ProjectsQuery_ResponseAdapter.Data::fromJson, + cacheableId = id() + projectsSlug.toString(), + requestType = if (forceRefresh) FlowDataSourceRequest.Type.REFRESH_CACHE else FlowDataSourceRequest.Type.USE_CACHE +) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectDetailsUseCasePreview.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectDetailsUseCasePreview.kt new file mode 100644 index 0000000..6907dd5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectDetailsUseCasePreview.kt @@ -0,0 +1,34 @@ +package com.mirego.kmp.boilerplate.usecase.preview + +import com.mirego.kmp.boilerplate.usecase.projectdetails.ProjectDetailsUseCase +import com.mirego.kmp.boilerplate.usecase.projectdetails.ProjectDetailsViewData +import com.mirego.kmp.boilerplate.usecase.projectdetails.toVMDColor +import com.mirego.kmp.boilerplate.utils.StateData +import com.mirego.kmp.boilerplate.utils.stateDataData +import com.mirego.kmp.boilerplate.utils.stateDataError +import com.mirego.kmp.boilerplate.utils.stateDataPending +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class ProjectDetailsUseCasePreview(private val previewState: PreviewState) : ProjectDetailsUseCase { + companion object { + fun buildPreviewViewData() = ProjectDetailsViewData( + "", + "Mirego", + "We make cool stuff", + "KMP mobile apps", + "2023", + "FFFFFF".toVMDColor() ?: VMDColor.None, + "000000".toVMDColor() ?: VMDColor.None + ) + } + + override fun projectsDetails(id: String): Flow> = flowOf( + when (previewState) { + is PreviewState.Data -> stateDataData(buildPreviewViewData()) + PreviewState.Loading -> stateDataPending() + PreviewState.Error -> stateDataError(Throwable()) + } + ) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectsUseCasePreview.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectsUseCasePreview.kt index c5a6ecf..4fcaf9d 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectsUseCasePreview.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/ProjectsUseCasePreview.kt @@ -1,5 +1,6 @@ package com.mirego.kmp.boilerplate.usecase.preview +import com.mirego.kmp.boilerplate.usecase.projectdetails.toVMDColor import com.mirego.kmp.boilerplate.usecase.projects.ProjectItemViewData import com.mirego.kmp.boilerplate.usecase.projects.ProjectsUseCase import com.mirego.kmp.boilerplate.usecase.projects.ProjectsViewData @@ -7,11 +8,12 @@ import com.mirego.kmp.boilerplate.utils.StateData import com.mirego.kmp.boilerplate.utils.stateDataData import com.mirego.kmp.boilerplate.utils.stateDataError import com.mirego.kmp.boilerplate.utils.stateDataPending +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map -import kotlin.time.Duration.Companion.seconds class ProjectsUseCasePreview( previewState: PreviewState @@ -23,7 +25,9 @@ class ProjectsUseCasePreview( title = "Project #$it", subtitle = "A small project description #$it", description = "iOS & Android applications", - imageUrl = "" + imageUrl = "", + backgroundColor = "000000".toVMDColor() ?: VMDColor.None, + textColor = "FFFFFF".toVMDColor() ?: VMDColor.None ) } } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/UseCaseFactoryPreview.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/UseCaseFactoryPreview.kt index b0870ad..da90c75 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/UseCaseFactoryPreview.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/preview/UseCaseFactoryPreview.kt @@ -2,4 +2,5 @@ package com.mirego.kmp.boilerplate.usecase.preview class UseCaseFactoryPreview { fun projectsUseCase(previewState: PreviewState) = ProjectsUseCasePreview(previewState) + fun projectDetailsUseCase(previewState: PreviewState) = ProjectDetailsUseCasePreview(previewState) } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCase.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCase.kt new file mode 100644 index 0000000..ea0bd56 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCase.kt @@ -0,0 +1,19 @@ +package com.mirego.kmp.boilerplate.usecase.projectdetails + +import com.mirego.kmp.boilerplate.utils.StateData +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor +import kotlinx.coroutines.flow.Flow + +interface ProjectDetailsUseCase { + fun projectsDetails(id: String): Flow> +} + +data class ProjectDetailsViewData( + val imageUrl: String, + val title: String, + val subtitle: String, + val projectType: String, + val releaseYear: String, + val backgroundColor: VMDColor, + val textColor: VMDColor +) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCaseImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCaseImpl.kt new file mode 100644 index 0000000..2cd2989 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projectdetails/ProjectDetailsUseCaseImpl.kt @@ -0,0 +1,46 @@ +package com.mirego.kmp.boilerplate.usecase.projectdetails + +import com.mirego.kmp.boilerplate.repository.projectdetails.ProjectDetailsRepository +import com.mirego.kmp.boilerplate.utils.StateData +import com.mirego.trikot.datasources.flow.extensions.mapValue +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor +import kotlinx.coroutines.flow.Flow +import org.koin.core.annotation.Factory + +@Factory +class ProjectDetailsUseCaseImpl( + private val repository: ProjectDetailsRepository +) : ProjectDetailsUseCase { + companion object { + private val defaultBackgroundColor = VMDColor(255, 255, 255, 1f) + private val defaultTextColor = VMDColor(255, 255, 255, 1f) + } + + override fun projectsDetails(id: String): Flow> = repository.projectDetails(id = id) + .mapValue { entity -> + ProjectDetailsViewData( + imageUrl = entity.mainImageUrl.toString(), + title = entity.client.name, + subtitle = entity.name, + projectType = entity.projectType, + releaseYear = entity.year.toString(), + backgroundColor = entity.mainColor?.toVMDColor() ?: defaultBackgroundColor, + textColor = entity.textColor?.toVMDColor() ?: defaultTextColor + ) + } +} + +fun String.toVMDColor(): VMDColor? { + var hex = this + hex = hex.replace("#", "") + + if (hex.length != 6) { + return null + } + + return VMDColor( + hex.substring(0, 2).toInt(16), + hex.substring(2, 4).toInt(16), + hex.substring(4, 6).toInt(16) + ) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCase.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCase.kt index b8d2167..538cb76 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCase.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCase.kt @@ -1,6 +1,7 @@ package com.mirego.kmp.boilerplate.usecase.projects import com.mirego.kmp.boilerplate.utils.StateData +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor import kotlinx.coroutines.flow.Flow interface ProjectsUseCase { @@ -21,5 +22,7 @@ data class ProjectItemViewData( val title: String, val subtitle: String, val description: String, - val imageUrl: String + val imageUrl: String, + val backgroundColor: VMDColor, + val textColor: VMDColor ) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCaseImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCaseImpl.kt index e0e9113..7ab307b 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCaseImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/usecase/projects/ProjectsUseCaseImpl.kt @@ -1,8 +1,10 @@ package com.mirego.kmp.boilerplate.usecase.projects import com.mirego.kmp.boilerplate.repository.projects.ProjectsRepository +import com.mirego.kmp.boilerplate.usecase.projectdetails.toVMDColor import com.mirego.kmp.boilerplate.utils.StateData import com.mirego.trikot.datasources.flow.extensions.mapValue +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor import kotlinx.coroutines.flow.Flow import org.koin.core.annotation.Factory @@ -23,7 +25,9 @@ class ProjectsUseCaseImpl( title = project.client.name, subtitle = project.name, description = project.projectType, - imageUrl = project.listImageUrl.toString() + imageUrl = project.listImageUrl.toString(), + backgroundColor = project.mainColor?.toVMDColor() ?: VMDColor.None, + textColor = project.textColor?.toVMDColor() ?: VMDColor.None ) } ) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/SharedImageResource.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/SharedImageResource.kt index 246ed6d..6dbce1d 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/SharedImageResource.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/SharedImageResource.kt @@ -5,6 +5,7 @@ package com.mirego.kmp.boilerplate.viewmodel.common import com.mirego.trikot.viewmodels.declarative.properties.VMDImageResource enum class SharedImageResource : VMDImageResource { + closeIcon, emptyPageIcon, errorPageIcon, imagePlaceholder diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt index 2748fdf..c0fadb5 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactory.kt @@ -1,5 +1,7 @@ package com.mirego.kmp.boilerplate.viewmodel.factory +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsNavigationData +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsViewModel import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsViewModel import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel import kotlinx.coroutines.CoroutineScope @@ -7,4 +9,5 @@ import kotlinx.coroutines.CoroutineScope interface ViewModelFactory { fun createRoot(coroutineScope: CoroutineScope): RootViewModel fun createProjects(coroutineScope: CoroutineScope): ProjectsViewModel + fun createProjectDetails(navigationData: ProjectDetailsNavigationData, closeAction: () -> Unit, coroutineScope: CoroutineScope): ProjectDetailsViewModel } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt index bda99c4..ad348a4 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryImpl.kt @@ -1,5 +1,7 @@ package com.mirego.kmp.boilerplate.viewmodel.factory +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsNavigationData +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsViewModel import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsViewModel import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel import kotlinx.coroutines.CoroutineScope @@ -17,4 +19,8 @@ class ViewModelFactoryImpl : ViewModelFactory, KoinComponent { override fun createProjects(coroutineScope: CoroutineScope): ProjectsViewModel = get { parametersOf(coroutineScope) } + + override fun createProjectDetails(navigationData: ProjectDetailsNavigationData, closeAction: () -> Unit, coroutineScope: CoroutineScope): ProjectDetailsViewModel = get { + parametersOf(navigationData, closeAction, coroutineScope) + } } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt index 436eeff..a531971 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/factory/ViewModelFactoryPreview.kt @@ -2,10 +2,14 @@ package com.mirego.kmp.boilerplate.viewmodel.factory import com.mirego.kmp.boilerplate.usecase.preview.PreviewState import com.mirego.kmp.boilerplate.usecase.preview.UseCaseFactoryPreview +import com.mirego.kmp.boilerplate.usecase.projectdetails.toVMDColor import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModelImpl +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsNavigationData +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsViewModelImpl import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsViewModelImpl import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModelImpl import com.mirego.trikot.kword.I18N +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor import com.mirego.trikot.viewmodels.declarative.util.CoroutineScopeProvider import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -35,11 +39,22 @@ class ViewModelFactoryPreview( coroutineScope = createCoroutineScope() ) - override fun createProjects(coroutineScope: CoroutineScope) = createProjects(PreviewState.Data.Content) + override fun createProjects(coroutineScope: CoroutineScope) = createProjects() fun createProjects(previewState: PreviewState = PreviewState.Data.Content) = ProjectsViewModelImpl( projectsUseCase = useCaseFactoryPreview.projectsUseCase(previewState), i18N = i18N, viewModelFactory = this, coroutineScope = createCoroutineScope() ) + + override fun createProjectDetails(navigationData: ProjectDetailsNavigationData, closeAction: () -> Unit, coroutineScope: CoroutineScope) = createProjectDetails() + + fun createProjectDetails(previewState: PreviewState = PreviewState.Data.Content) = ProjectDetailsViewModelImpl( + navigationData = ProjectDetailsNavigationData("", "000000".toVMDColor() ?: VMDColor.None, "ffffff".toVMDColor() ?: VMDColor.None), + projectDetailsUseCase = useCaseFactoryPreview.projectDetailsUseCase(previewState), + i18N = i18N, + viewModelFactory = this, + closeAction = {}, + coroutineScope = createCoroutineScope() + ) } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/MainNavigationDelegate.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/MainNavigationDelegate.kt new file mode 100644 index 0000000..e75213c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/MainNavigationDelegate.kt @@ -0,0 +1,12 @@ +package com.mirego.kmp.boilerplate.viewmodel.navigation + +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsNavigationData + +interface MainNavigationDelegate { + fun navigateToProjectDetails(navigationData: ProjectDetailsNavigationData) +} + +enum class CloseActionType { + BACK, + COMPLETED +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationRoute.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationRoute.kt new file mode 100644 index 0000000..08cf872 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationRoute.kt @@ -0,0 +1,17 @@ +package com.mirego.kmp.boilerplate.viewmodel.navigation + +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsViewModel +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationRoute + +sealed interface NavigationRoute : VMDNavigationRoute { + data class ProjectDetails( + override val viewModel: ProjectDetailsViewModel, + override val resetBlock: () -> Unit + ) : NavigationRoute { + companion object { + const val NAME = "ProjectDetails" + } + + override val name: String = NAME + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModel.kt new file mode 100644 index 0000000..4ae2a81 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModel.kt @@ -0,0 +1,5 @@ +package com.mirego.kmp.boilerplate.viewmodel.navigation + +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationViewModel + +interface NavigationViewModel : VMDNavigationViewModel diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModelImpl.kt new file mode 100644 index 0000000..b96572e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/navigation/NavigationViewModelImpl.kt @@ -0,0 +1,31 @@ +package com.mirego.kmp.boilerplate.viewmodel.navigation + +import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsNavigationData +import com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation.VMDNavigationViewModelImpl +import kotlinx.coroutines.CoroutineScope + +open class NavigationViewModelImpl( + onTrackScreenView: () -> Unit, + private val viewModelFactory: ViewModelFactory, + coroutineScope: CoroutineScope +) : NavigationViewModel, + MainNavigationDelegate, + VMDNavigationViewModelImpl( + onTrackScreenView = onTrackScreenView, + coroutineScope = coroutineScope + ) { + + override fun navigateToProjectDetails(navigationData: ProjectDetailsNavigationData) { + updateRoute( + route = NavigationRoute.ProjectDetails( + viewModel = viewModelFactory.createProjectDetails( + navigationData, + ::resetRoute, + cancelAndCreateCoroutineScope() + ), + resetBlock = ::resetRoute + ) + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsNavigationData.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsNavigationData.kt new file mode 100644 index 0000000..e0c4fac --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsNavigationData.kt @@ -0,0 +1,9 @@ +package com.mirego.kmp.boilerplate.viewmodel.projectdetails + +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor + +data class ProjectDetailsNavigationData( + val id: String, + val backgroundColor: VMDColor, + val textColor: VMDColor +) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModel.kt new file mode 100644 index 0000000..0e96d9c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModel.kt @@ -0,0 +1,37 @@ +package com.mirego.kmp.boilerplate.viewmodel.projectdetails + +import com.mirego.kmp.boilerplate.viewmodel.common.ErrorViewModel +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModel +import com.mirego.trikot.viewmodels.declarative.Published +import com.mirego.trikot.viewmodels.declarative.components.VMDButtonViewModel +import com.mirego.trikot.viewmodels.declarative.components.VMDImageViewModel +import com.mirego.trikot.viewmodels.declarative.content.VMDImageContent +import com.mirego.trikot.viewmodels.declarative.content.VMDTextPairContent +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor + +interface ProjectDetailsViewModel : NavigationViewModel { + val closeButton: VMDButtonViewModel + + val backgroundColor: VMDColor + val textColor: VMDColor + + @Published + val rootContent: ProjectDetailsRoot? +} + +sealed interface ProjectDetailsRoot { + data class Content( + val image: VMDImageViewModel, + val title: String, + val subtitle: String, + val projectType: VMDTextPairContent, + val releaseYear: VMDTextPairContent, + val backgroundColor: VMDColor, + val textColor: VMDColor, + val isLoading: Boolean + ) : ProjectDetailsRoot + + data class Error( + val errorViewModel: ErrorViewModel + ) : ProjectDetailsRoot +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModelImpl.kt new file mode 100644 index 0000000..58c0800 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projectdetails/ProjectDetailsViewModelImpl.kt @@ -0,0 +1,96 @@ +package com.mirego.kmp.boilerplate.viewmodel.projectdetails + +import com.mirego.kmp.boilerplate.analytics.Analytics +import com.mirego.kmp.boilerplate.analytics.ScreenName +import com.mirego.kmp.boilerplate.localization.KWordTranslation +import com.mirego.kmp.boilerplate.usecase.preview.ProjectDetailsUseCasePreview +import com.mirego.kmp.boilerplate.usecase.projectdetails.ProjectDetailsUseCase +import com.mirego.kmp.boilerplate.usecase.projectdetails.ProjectDetailsViewData +import com.mirego.kmp.boilerplate.viewmodel.common.ErrorViewModelImpl +import com.mirego.kmp.boilerplate.viewmodel.common.SharedImageResource +import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModelImpl +import com.mirego.trikot.datasources.DataState +import com.mirego.trikot.kword.I18N +import com.mirego.trikot.viewmodels.declarative.PublishedSubClass +import com.mirego.trikot.viewmodels.declarative.content.VMDTextPairContent +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor +import com.mirego.trikot.viewmodels.declarative.viewmodel.buttonWithImage +import com.mirego.trikot.viewmodels.declarative.viewmodel.remoteImage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Factory + +@Factory +@PublishedSubClass(superClass = NavigationViewModelImpl::class) +class ProjectDetailsViewModelImpl( + private val navigationData: ProjectDetailsNavigationData, + projectDetailsUseCase: ProjectDetailsUseCase, + private val i18N: I18N, + viewModelFactory: ViewModelFactory, + closeAction: () -> Unit, + coroutineScope: CoroutineScope +) : ProjectDetailsViewModel, BaseProjectDetailsViewModelImpl( + onTrackScreenView = { + Analytics.trackScreenView(ScreenName.project_details) + }, + viewModelFactory = viewModelFactory, + coroutineScope = coroutineScope +) { + override val backgroundColor: VMDColor = navigationData.backgroundColor + override val textColor: VMDColor = navigationData.textColor + + init { + bindRootContent( + projectDetailsUseCase.projectsDetails(navigationData.id).map { stateData -> + when (stateData) { + is DataState.Data -> buildContent(stateData.value, false) + is DataState.Pending -> buildLoading() + is DataState.Error -> buildError() + } + } + ) + } + + private fun buildContent(viewData: ProjectDetailsViewData, isLoading: Boolean) = ProjectDetailsRoot.Content( + image = remoteImage( + imageUrl = viewData.imageUrl, + placeholderImageResource = SharedImageResource.imagePlaceholder + ), + title = viewData.title, + subtitle = viewData.subtitle, + projectType = VMDTextPairContent( + i18N[KWordTranslation.PROJECT_DETAILS_PROJECT_TYPE], + viewData.projectType + ), + releaseYear = VMDTextPairContent( + i18N[KWordTranslation.PROJECT_DETAILS_RELEASE_YEAR], + viewData.releaseYear + ), + backgroundColor = viewData.backgroundColor, + textColor = viewData.textColor, + isLoading + ) + + private fun buildLoading() = buildContent( + viewData = ProjectDetailsUseCasePreview.buildPreviewViewData() + .copy(backgroundColor = navigationData.backgroundColor, textColor = navigationData.textColor), + isLoading = true + ) + + private fun buildError() = ProjectDetailsRoot.Error( + errorViewModel = ErrorViewModelImpl.build( + i18N = i18N, + titleKey = KWordTranslation.GENERIC_ERROR_TITLE, + messageKey = KWordTranslation.GENERIC_ERROR_MESSAGE, + coroutineScope = coroutineScope, + retryAction = {} + ) + ) + + override val closeButton = buttonWithImage(image = SharedImageResource.closeIcon) { + setAction { + closeAction() + } + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModel.kt index ca5d62d..9f523a5 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModel.kt @@ -2,18 +2,16 @@ package com.mirego.kmp.boilerplate.viewmodel.projects import com.mirego.kmp.boilerplate.viewmodel.common.EmptyViewModel import com.mirego.kmp.boilerplate.viewmodel.common.ErrorViewModel +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModel import com.mirego.trikot.viewmodels.declarative.Published import com.mirego.trikot.viewmodels.declarative.components.VMDImageViewModel import com.mirego.trikot.viewmodels.declarative.components.VMDListViewModel import com.mirego.trikot.viewmodels.declarative.content.VMDIdentifiableContent -import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDLifecycleViewModel -import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel -interface ProjectsViewModel : VMDViewModel, VMDLifecycleViewModel { +interface ProjectsViewModel : NavigationViewModel { @Published val rootContent: ProjectsRoot? - } sealed interface ProjectsRoot { @@ -53,5 +51,6 @@ data class ProjectItem( val subtitle: String, val description: String, val image: VMDImageViewModel, + val tapAction: () -> Unit, val isLoading: Boolean ) : VMDIdentifiableContent diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModelImpl.kt index 4876f86..11dfcb3 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModelImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/projects/ProjectsViewModelImpl.kt @@ -1,5 +1,7 @@ package com.mirego.kmp.boilerplate.viewmodel.projects +import com.mirego.kmp.boilerplate.analytics.Analytics +import com.mirego.kmp.boilerplate.analytics.ScreenName import com.mirego.kmp.boilerplate.extension.prioritiseData import com.mirego.kmp.boilerplate.localization.KWordTranslation import com.mirego.kmp.boilerplate.usecase.preview.ProjectsUseCasePreview @@ -10,10 +12,11 @@ import com.mirego.kmp.boilerplate.viewmodel.common.EmptyViewModelImpl import com.mirego.kmp.boilerplate.viewmodel.common.ErrorViewModelImpl import com.mirego.kmp.boilerplate.viewmodel.common.SharedImageResource import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory +import com.mirego.kmp.boilerplate.viewmodel.navigation.NavigationViewModelImpl +import com.mirego.kmp.boilerplate.viewmodel.projectdetails.ProjectDetailsNavigationData import com.mirego.trikot.datasources.DataState import com.mirego.trikot.kword.I18N import com.mirego.trikot.viewmodels.declarative.PublishedSubClass -import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDLifecycleViewModelImpl import com.mirego.trikot.viewmodels.declarative.viewmodel.list import com.mirego.trikot.viewmodels.declarative.viewmodel.remoteImage import kotlinx.coroutines.CoroutineScope @@ -22,13 +25,17 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Factory @Factory -@PublishedSubClass(superClass = VMDLifecycleViewModelImpl::class) +@PublishedSubClass(superClass = NavigationViewModelImpl::class) class ProjectsViewModelImpl( private val projectsUseCase: ProjectsUseCase, private val i18N: I18N, viewModelFactory: ViewModelFactory, coroutineScope: CoroutineScope ) : ProjectsViewModel, BaseProjectsViewModelImpl( + onTrackScreenView = { + Analytics.trackScreenView(ScreenName.projects) + }, + viewModelFactory = viewModelFactory, coroutineScope = coroutineScope ) { @@ -88,6 +95,16 @@ class ProjectsViewModelImpl( imageUrl = imageUrl, placeholderImageResource = SharedImageResource.imagePlaceholder ), + tapAction = { + Analytics.trackViewProject(projectId = id) + navigateToProjectDetails( + ProjectDetailsNavigationData( + id = id, + backgroundColor = backgroundColor, + textColor = textColor + ) + ) + }, isLoading = isLoading ) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt index 4a1f2d6..4d2389b 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/root/RootViewModelImpl.kt @@ -3,7 +3,6 @@ package com.mirego.kmp.boilerplate.viewmodel.root import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsViewModel import com.mirego.trikot.kword.I18N -import com.mirego.trikot.viewmodels.declarative.PublishedSubClass import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModelImpl import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.Factory diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModel.kt new file mode 100644 index 0000000..7bc50e5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModel.kt @@ -0,0 +1,6 @@ +package com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation + +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDLifecycleViewModel +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel + +interface VMDBaseScreenViewModel : VMDViewModel, VMDLifecycleViewModel diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModelImpl.kt new file mode 100644 index 0000000..22317cb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDBaseScreenViewModelImpl.kt @@ -0,0 +1,15 @@ +package com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation + +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDLifecycleViewModelImpl +import kotlinx.coroutines.CoroutineScope + +open class VMDBaseScreenViewModelImpl( + private val onTrackScreenView: () -> Unit, + coroutineScope: CoroutineScope +) : VMDBaseScreenViewModel, VMDLifecycleViewModelImpl(coroutineScope) { + + override fun onAppearFirst(coroutineScope: CoroutineScope) { + super.onAppearFirst(coroutineScope) + onTrackScreenView() + } +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationRoute.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationRoute.kt new file mode 100644 index 0000000..529e0b0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationRoute.kt @@ -0,0 +1,10 @@ +package com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation + +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel + +interface VMDNavigationRoute { + val viewModel: VMDViewModel? + get() = null + val name: String + val resetBlock: () -> Unit +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModel.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModel.kt new file mode 100644 index 0000000..a0639a4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModel.kt @@ -0,0 +1,7 @@ +package com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation + +import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDViewModel + +interface VMDNavigationViewModel : VMDViewModel, VMDBaseScreenViewModel { + val navigationRoute: ROUTE? +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModelImpl.kt new file mode 100644 index 0000000..e792d55 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/mirego/trikot/viewmodels/declarative/navigation/VMDNavigationViewModelImpl.kt @@ -0,0 +1,50 @@ +package com.mirego.kmp.mirego.trikot.viewmodels.declarative.navigation + +import com.mirego.trikot.foundation.concurrent.AtomicReference +import com.mirego.trikot.viewmodels.declarative.util.CoroutineScopeProvider +import com.mirego.trikot.viewmodels.declarative.viewmodel.internal.VMDFlowProperty +import com.mirego.trikot.viewmodels.declarative.viewmodel.internal.emit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import org.koin.core.component.KoinComponent + +abstract class VMDNavigationViewModelImpl( + onTrackScreenView: () -> Unit, + coroutineScope: CoroutineScope +) : VMDNavigationViewModel, VMDBaseScreenViewModelImpl(onTrackScreenView = onTrackScreenView, coroutineScope = coroutineScope), KoinComponent { + + private val currentCoroutineScope = AtomicReference(null) + + private var isNavigationBound = false + + private val navigationRouteDelegate = emit(null as ROUTE?, this, coroutineScope) + override var navigationRoute: ROUTE? by navigationRouteDelegate + + fun cancelAndCreateCoroutineScope() = currentCoroutineScope.value.let { currentScope -> + val newScope = createCoroutineScope() + currentCoroutineScope.setOrThrow(currentScope, newScope) + + currentScope?.cancel() + + newScope + } + + private fun createCoroutineScope() = CoroutineScopeProvider.provideMainWithSuperviserJob() + + fun updateRoute(route: ROUTE) { + navigationRoute = route + } + + fun resetRoute() { + if (navigationRoute != null) { + navigationRoute = null + cancelAndCreateCoroutineScope() + } + } + + override val propertyMapping: Map> by lazy { + super.propertyMapping.toMutableMap().also { + it[::navigationRoute.name] = navigationRouteDelegate + } + } +} diff --git a/shared/src/commonMain/resources/translations/translation.en.json b/shared/src/commonMain/resources/translations/translation.en.json index 952c60a..2887321 100644 --- a/shared/src/commonMain/resources/translations/translation.en.json +++ b/shared/src/commonMain/resources/translations/translation.en.json @@ -5,5 +5,7 @@ "generic_retry": "Retry", "projects_header_title": "Our projects", "projects_header_description": "Over the years, we’ve built over 300 digital products used every day by millions of users on mobile phones, Web browsers, tablets, watches, TV screens, digital kiosks and home assistants. Here are a few of them.", - "projects_empty_content_message": "There are no projects to show" + "projects_empty_content_message": "There are no projects to show", + "project_details_project_type": "Type", + "project_details_release_year": "Launch year" } diff --git a/shared/src/commonMain/resources/translations/translation.fr.json b/shared/src/commonMain/resources/translations/translation.fr.json index eb2d33d..4d00577 100644 --- a/shared/src/commonMain/resources/translations/translation.fr.json +++ b/shared/src/commonMain/resources/translations/translation.fr.json @@ -5,5 +5,7 @@ "generic_retry": "Réessayer", "projects_header_title": "Nos projets", "projects_header_description": "Au fil des ans, nous avons bâti plus de 300 produits numériques utilisés chaque jour par des millions d'utilisateurs sur téléphones, navigateurs Web, tablettes, montres connectées, télévisions, bornes interactives et assistants vocaux. En voici quelques-uns.", - "projects_empty_content_message": "Il n'y a aucun projets de disponible" + "projects_empty_content_message": "Il n'y a aucun projets de disponible", + "project_details_project_type": "Type", + "project_details_release_year": "Année de lancement" } diff --git a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/usecase/ProjectsUseCaseTestImpl.kt b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/usecase/ProjectsUseCaseTestImpl.kt index cee7c4c..3f610de 100644 --- a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/usecase/ProjectsUseCaseTestImpl.kt +++ b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/usecase/ProjectsUseCaseTestImpl.kt @@ -9,11 +9,11 @@ import com.mirego.kmp.boilerplate.utils.stateDataData import com.mirego.trikot.datasources.extensions.value import io.mockk.every import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertTrue import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertTrue class ProjectsUseCaseTestImpl : BaseTest() { private val repository = mockk() @@ -74,6 +74,8 @@ class ProjectsUseCaseTestImpl : BaseTest() { listImageUrl = "https://miregologo.com", client = ProjectsQuery.Data.PagePage.ProjectsListBlock.Projects.Entry.Client( name = "Mirego" - ) + ), + "000000", + "FFFFFF" ) } diff --git a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/viewmodel/ProjectsViewModelImplTest.kt b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/viewmodel/ProjectsViewModelImplTest.kt index 408231e..be06bd6 100644 --- a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/viewmodel/ProjectsViewModelImplTest.kt +++ b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/viewmodel/ProjectsViewModelImplTest.kt @@ -1,6 +1,7 @@ package com.mirego.kmp.boilerplate.viewmodel import com.mirego.kmp.boilerplate.testutils.BaseTest +import com.mirego.kmp.boilerplate.usecase.projectdetails.toVMDColor import com.mirego.kmp.boilerplate.usecase.projects.ProjectItemViewData import com.mirego.kmp.boilerplate.usecase.projects.ProjectsUseCase import com.mirego.kmp.boilerplate.usecase.projects.ProjectsViewData @@ -9,14 +10,15 @@ import com.mirego.kmp.boilerplate.viewmodel.factory.ViewModelFactory import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsContentSection import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsRoot import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsViewModelImpl +import com.mirego.trikot.viewmodels.declarative.properties.VMDColor import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertTrue +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest -class ProjectsViewModelImplTestTest : BaseTest() { +class ProjectsViewModelImplTest : BaseTest() { private val useCase = mockk() private val viewModelFactory = mockk() @@ -48,7 +50,9 @@ class ProjectsViewModelImplTestTest : BaseTest() { title = "title", subtitle = "subtitle", description = "description", - imageUrl = "imageUrl" + imageUrl = "imageUrl", + backgroundColor = "000000".toVMDColor() ?: VMDColor.None, + textColor = "FFFFFF".toVMDColor() ?: VMDColor.None ) ) )