diff --git a/.editorconfig b/.editorconfig index da1185a..c60ee73 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +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 = off -ij_kotlin_allow_trailing_comma_on_call_site = false -ij_kotlin_allow_trailing_comma = false -ktlint_code_style = ktlint_official -ktlint_disabled_rules=no-enum-name-in-uppercase,enum-entry-name-case,filename +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,filename + +[*.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 ad00056..9e6c962 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.ktlint) + alias(libs.plugins.crashlyticsPlugin) } kotlin { @@ -42,6 +43,9 @@ android { buildFeatures { compose = true } + androidResources { + generateLocaleConfig = true + } composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidComposeCompiler.get() } @@ -79,10 +83,14 @@ 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) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.material) @@ -91,4 +99,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/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt index 3aa533b..13932ac 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/AndroidApplication.kt @@ -1,14 +1,30 @@ package com.mirego.kmp.boilerplate.app import android.app.Application +import android.content.res.Configuration +import com.microsoft.appcenter.AppCenter +import com.microsoft.appcenter.distribute.Distribute +import com.microsoft.appcenter.distribute.UpdateTrack +import com.mirego.kmp.boilerplate.R import com.mirego.kmp.boilerplate.app.bootstrap.AndroidBootstrap import com.mirego.kmp.boilerplate.bootstrap.Bootstrapper class AndroidApplication : Application() { - val bootstrapper = Bootstrapper() + lateinit var bootstrapper: Bootstrapper override fun onCreate() { super.onCreate() - bootstrapper.initDependencies(AndroidBootstrap(this)) + bootstrapper = Bootstrapper(AndroidBootstrap(this)) + bootstrapper.initDependencies() + + if (getString(R.string.appcenter_app_secret).isNotBlank()) { + Distribute.setUpdateTrack(UpdateTrack.PRIVATE) + AppCenter.start(this, getString(R.string.appcenter_app_secret), Distribute::class.java) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + (bootstrapper.bootstrap as? AndroidBootstrap)?.appInformation?.updateLocale() } } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt index 6f73876..0551512 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/MainActivity.kt @@ -1,14 +1,20 @@ package com.mirego.kmp.boilerplate.app +import android.content.ContentValues.TAG import android.os.Bundle +import android.util.Log import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import androidx.lifecycle.lifecycleScope +import com.mirego.killswitch.AndroidKillswitch +import com.mirego.killswitch.KillswitchException import com.mirego.kmp.boilerplate.app.ui.application.ApplicationView import com.mirego.kmp.boilerplate.bootstrap.Bootstrapper import com.mirego.kmp.boilerplate.trikot.viewmodels.declarative.compose.getInitialViewModel import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModel +import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { private val bootstrapper: Bootstrapper @@ -27,5 +33,21 @@ class MainActivity : AppCompatActivity() { setContent { ApplicationView(applicationViewModel = viewModel) } + + lifecycleScope.launch { + try { + AndroidKillswitch.showDialog( + viewData = AndroidKillswitch.engage( + key = bootstrapper.bootstrap.environment.androidSpecific.killSwitchAPIKey, + context = this@MainActivity, + url = bootstrapper.bootstrap.environment.androidSpecific.killSwitchAPIKey + ), + activity = this@MainActivity, + themeResId = android.R.style.Theme_DeviceDefault_Light_Dialog_Alert + ) + } catch (e: KillswitchException) { + Log.e(TAG, "Killswitch exception", e) + } + } } } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/analytics/AndroidSharedAnalyticsService.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/analytics/AndroidSharedAnalyticsService.kt new file mode 100644 index 0000000..3406dc4 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/analytics/AndroidSharedAnalyticsService.kt @@ -0,0 +1,30 @@ +package com.mirego.kmp.boilerplate.app.analytics + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import com.mirego.kmp.boilerplate.analytics.SharedAnalyticsService +import com.mirego.trikot.analytics.AnalyticsEvent +import com.mirego.trikot.analytics.AnalyticsPropertiesType + +class AndroidSharedAnalyticsService( + context: Context, + private var analyticsEnabled: Boolean = true +) : SharedAnalyticsService { + private var firebaseAnalytics = FirebaseAnalytics.getInstance(context) + + override var isEnabled: Boolean + get() = analyticsEnabled + set(value) { + analyticsEnabled = value + firebaseAnalytics.setAnalyticsCollectionEnabled(value) + } + + override fun trackEvent(event: AnalyticsEvent, properties: AnalyticsPropertiesType) { + val bundle = Bundle() + properties.forEach { + bundle.putString(it.key, it.value.toString()) + } + firebaseAnalytics.logEvent(event.name, bundle) + } +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt index 7fb87eb..341f254 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AndroidBootstrap.kt @@ -2,6 +2,8 @@ package com.mirego.kmp.boilerplate.app.bootstrap import android.content.Context import com.mirego.kmp.boilerplate.BuildConfig +import com.mirego.kmp.boilerplate.analytics.SharedAnalyticsConfiguration +import com.mirego.kmp.boilerplate.app.analytics.AndroidSharedAnalyticsService import com.mirego.kmp.boilerplate.app.resources.AndroidImageProvider import com.mirego.kmp.boilerplate.bootstrap.AppEnvironment import com.mirego.kmp.boilerplate.bootstrap.Bootstrap @@ -9,7 +11,6 @@ import com.mirego.kmp.boilerplate.bootstrap.LocaleUtils import com.mirego.trikot.kword.android.AndroidKWord import com.mirego.trikot.viewmodels.declarative.configuration.DefaultTextStyleProvider import com.mirego.trikot.viewmodels.declarative.configuration.TrikotViewModelDeclarative -import java.util.* class AndroidBootstrap(context: Context) : Bootstrap { @@ -28,5 +29,11 @@ class AndroidBootstrap(context: Context) : Bootstrap { imageProvider = AndroidImageProvider(), textStyleProvider = DefaultTextStyleProvider() ) + + val analyticsEnabled = !BuildConfig.DEBUG + SharedAnalyticsConfiguration.analyticsManager = AndroidSharedAnalyticsService( + context = context, + analyticsEnabled = analyticsEnabled + ) } } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt index e545235..fb72ba8 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/bootstrap/AppInformationImpl.kt @@ -5,9 +5,19 @@ import com.mirego.kmp.boilerplate.BuildConfig import com.mirego.kmp.boilerplate.bootstrap.AppInformation import com.mirego.kmp.boilerplate.model.Language import com.mirego.kmp.boilerplate.model.Locale +import com.mirego.kmp.boilerplate.utils.ConcreteFlow +import com.mirego.kmp.boilerplate.utils.wrap +import kotlinx.coroutines.flow.MutableStateFlow class AppInformationImpl(context: Context) : AppInformation { - override val locale: Locale = Locale( + private val internalLocale = MutableStateFlow(currentLocale()) + override fun locale(): ConcreteFlow = internalLocale.wrap() + + fun updateLocale() { + internalLocale.value = currentLocale() + } + + private fun currentLocale() = Locale( if (java.util.Locale.getDefault().language.lowercase() == "fr") Language.FRENCH else Language.ENGLISH, java.util.Locale.getDefault().country ) diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt index c96e1e0..9366731 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/resources/AndroidImageProvider.kt @@ -1,9 +1,20 @@ package com.mirego.kmp.boilerplate.app.resources import android.content.Context +import com.mirego.kmp.boilerplate.R +import com.mirego.kmp.boilerplate.viewmodel.common.SharedImageResource import com.mirego.trikot.viewmodels.declarative.configuration.VMDImageProvider import com.mirego.trikot.viewmodels.declarative.properties.VMDImageResource class AndroidImageProvider : VMDImageProvider { - override fun resourceIdForResource(resource: VMDImageResource, context: Context) = null + 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/application/ApplicationView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/application/ApplicationView.kt index 93aa2f1..77430f9 100644 --- a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/application/ApplicationView.kt +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/application/ApplicationView.kt @@ -5,13 +5,16 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider import com.mirego.kmp.boilerplate.app.ui.root.RootView +import com.mirego.kmp.boilerplate.app.ui.theme.Theme import com.mirego.kmp.boilerplate.viewmodel.application.ApplicationViewModel import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState @Composable fun ApplicationView(applicationViewModel: ApplicationViewModel) { val viewModel: ApplicationViewModel by applicationViewModel.observeAsState() - RootView(rootViewModel = viewModel.rootViewModel) + Theme { + RootView(rootViewModel = viewModel.rootViewModel) + } } @Preview 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 new file mode 100644 index 0000000..c511db3 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Const.kt @@ -0,0 +1,7 @@ +package com.mirego.kmp.boilerplate.app.ui.common + +import androidx.compose.ui.unit.dp + +object Const { + val padding = 16.dp +} diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/EmptyContentView.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/EmptyContentView.kt new file mode 100644 index 0000000..1cc7b8a --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/EmptyContentView.kt @@ -0,0 +1,65 @@ +package com.mirego.kmp.boilerplate.app.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mirego.kmp.boilerplate.app.ui.common.Const.padding +import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.app.ui.projects.ProjectsView +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.usecase.preview.PreviewState +import com.mirego.kmp.boilerplate.viewmodel.common.EmptyViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDImage +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDText + +@Composable +fun EmptyContentView(emptyViewModel: EmptyViewModel, modifier: Modifier = Modifier) { + val viewModel: EmptyViewModel by emptyViewModel.observeAsState() + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + VMDImage( + modifier = Modifier.size(55.dp), + viewModel = viewModel.icon, + colorFilter = ColorFilter.tint(Color.White) + ) + + VMDText( + modifier = Modifier.padding(top = padding * 2), + viewModel = viewModel.title, + color = Color.White, + style = style(TextSize.LARGE_TITLE, TextWeight.REGULAR), + maxLines = 1 + ) + + VMDText( + modifier = Modifier.padding(top = padding), + viewModel = viewModel.message, + color = Color.White, + style = style(TextSize.BODY, TextWeight.REGULAR), + textAlign = TextAlign.Center + ) + } +} + +@Preview +@Composable +fun PreviewEmptyContentView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Data.Empty)) + } +} 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 new file mode 100644 index 0000000..00ae4af --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/ErrorView.kt @@ -0,0 +1,97 @@ +package com.mirego.kmp.boilerplate.app.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mirego.kmp.boilerplate.app.ui.common.Const.padding +import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.app.ui.projects.ProjectsView +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.usecase.preview.PreviewState +import com.mirego.kmp.boilerplate.viewmodel.common.ErrorViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDButton +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDImage +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDText + +@Composable +fun ErrorView(errorViewModel: ErrorViewModel) { + val iconPadding = 4.dp + val viewModel: ErrorViewModel by errorViewModel.observeAsState() + Column( + modifier = Modifier + .statusBarsPadding() + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + VMDImage( + modifier = Modifier.size(55.dp), + viewModel = viewModel.icon, + colorFilter = ColorFilter.tint(Color.White) + ) + + VMDText( + modifier = Modifier.padding(top = padding * 2 - iconPadding), + viewModel = viewModel.title, + style = style(TextSize.LARGE_TITLE, TextWeight.REGULAR), + color = Color.White, + maxLines = 1 + ) + + VMDText( + modifier = Modifier.padding(top = padding), + viewModel = viewModel.message, + style = style(TextSize.BODY, TextWeight.REGULAR), + color = Color.White, + textAlign = TextAlign.Center + ) + + VMDButton( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 320.dp) + .padding(top = padding * 2) + .padding(horizontal = padding * 2) + .clip(RoundedCornerShape(percent = 50)) + .background(Color.Red) + .padding(vertical = 12.dp), + viewModel = viewModel.retryButton + ) { content -> + Text( + modifier = Modifier, + text = content.text, + style = style(TextSize.BODY, TextWeight.REGULAR), + color = Color.White + ) + } + } +} + +@Preview +@Composable +fun PreviewProjectsView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Error)) + } +} 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 new file mode 100644 index 0000000..f505258 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/common/Utils.kt @@ -0,0 +1,20 @@ +package com.mirego.kmp.boilerplate.app.ui.common + +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 +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( + visible = isLoading, + highlight = PlaceholderHighlight.shimmer(highlightColor = Color.ShimmerHighlight), + color = Color.ShimmerBackground + ) +) + +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/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 new file mode 100644 index 0000000..e0b0853 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsContentView.kt @@ -0,0 +1,191 @@ +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 +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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 +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mirego.kmp.boilerplate.app.ui.common.Const.padding +import com.mirego.kmp.boilerplate.app.ui.common.EmptyContentView +import com.mirego.kmp.boilerplate.app.ui.common.loading +import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.app.ui.theme.AccentOrange +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.projects.ProjectItem +import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsContentSection +import com.mirego.trikot.viewmodels.declarative.components.VMDListViewModel +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.PlaceholderState +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDImage +import com.mirego.trikot.viewmodels.declarative.compose.viewmodel.VMDLazyColumn +import com.mirego.trikot.viewmodels.declarative.properties.VMDImageResource + +@Composable +fun ProjectsContentView(listViewModel: VMDListViewModel) { + val viewModel: VMDListViewModel by listViewModel.observeAsState() + val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + VMDLazyColumn( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + contentPadding = PaddingValues(vertical = padding + statusBarPadding, horizontal = padding), + verticalArrangement = Arrangement.spacedBy(padding * 2) + ) { section -> + when (section) { + is ProjectsContentSection.Header -> HeaderView(header = section) + is ProjectsContentSection.NoProjects -> EmptyContentView( + emptyViewModel = section.emptyViewModel, + modifier = Modifier.padding(top = 100.dp) + ) + is ProjectsContentSection.ProjectsList -> ProjectsListView(viewModel = section.viewModel) + } + } +} + +@Composable +private fun HeaderView(header: ProjectsContentSection.Header) { + Column( + modifier = Modifier.padding(horizontal = padding), + verticalArrangement = Arrangement.spacedBy(padding * 2), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = header.title, + style = style(TextSize.TITLE1, TextWeight.SEMI_BOLD), + color = Color.White, + maxLines = 1 + ) + + Text( + text = header.description, + style = style(TextSize.HEADLINE, TextWeight.REGULAR), + color = Color.White, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun ProjectsListView(viewModel: VMDListViewModel) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(padding * 2) + ) { + viewModel.elements.forEach { item -> + ItemView(item) + } + } +} + +@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( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .loading(item.isLoading), + viewModel = item.image, + contentScale = ContentScale.FillWidth, + placeholder = { placeholderImageResource: VMDImageResource, state: PlaceholderState -> + ImagePlaceholder(placeholderImageResource = placeholderImageResource, state = state) + } + ) + + Column( + modifier = Modifier + .padding(horizontal = padding) + ) { + Text( + modifier = Modifier.loading(item.isLoading), + text = item.title, + style = style(TextSize.SUB_HEADLINE, TextWeight.REGULAR), + color = Color.White, + maxLines = 1 + ) + + Text( + modifier = Modifier + .loading(item.isLoading), + text = item.subtitle, + style = style(TextSize.TITLE1, TextWeight.REGULAR), + color = Color.White, + maxLines = 2 + ) + + Text( + modifier = Modifier + .loading(item.isLoading) + .padding(top = 12.dp), + text = item.description, + style = style(TextSize.CAPTION1, TextWeight.REGULAR), + color = Color.AccentOrange, + maxLines = 2 + ) + } + } +} + +@Composable +private fun ImagePlaceholder(placeholderImageResource: VMDImageResource, state: PlaceholderState) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.2f)) + .clip(RoundedCornerShape(16.dp)) + .loading(state == PlaceholderState.LOADING), + contentAlignment = Alignment.Center + ) { + LocalImage( + modifier = Modifier.size(64.dp), + imageResource = placeholderImageResource, + contentScale = ContentScale.FillWidth, + colorFilter = ColorFilter.tint(Color.Gray) + ) + } +} + +@Preview +@Composable +fun PreviewProjectsContentView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects()) + } +} 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 new file mode 100644 index 0000000..656eb24 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/projects/ProjectsView.kt @@ -0,0 +1,76 @@ +package com.mirego.kmp.boilerplate.app.ui.projects + +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.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 +import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsRoot +import com.mirego.kmp.boilerplate.viewmodel.projects.ProjectsViewModel +import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState + +@Composable +fun ProjectsView(projectsViewModel: ProjectsViewModel) { + val viewModel: ProjectsViewModel by projectsViewModel.observeAsState() + 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) { + is ProjectsRoot.Content -> ProjectsContentView(listViewModel = content.sections) + is ProjectsRoot.Error -> ErrorView(errorViewModel = content.errorViewModel) + } + } +} + +@Preview +@Composable +fun PreviewProjectsView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Data.Content)) + } +} + +@Preview +@Composable +fun PreviewProjectsEmptyView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Data.Empty)) + } +} + +@Preview +@Composable +fun PreviewProjectsLoadingView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Loading)) + } +} + +@Preview +@Composable +fun PreviewProjectsErrorView() { + PreviewProvider { + ProjectsView(projectsViewModel = it.createProjects(previewState = PreviewState.Error)) + } +} 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 540a442..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 @@ -3,30 +3,31 @@ package com.mirego.kmp.boilerplate.app.ui.root import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.mirego.kmp.boilerplate.app.ui.preview.PreviewProvider +import com.mirego.kmp.boilerplate.app.ui.projects.ProjectsView +import com.mirego.kmp.boilerplate.app.ui.theme.PrimaryBlack import com.mirego.kmp.boilerplate.viewmodel.root.RootViewModel import com.mirego.trikot.viewmodels.declarative.compose.extensions.observeAsState @Composable fun RootView(rootViewModel: RootViewModel) { val viewModel: RootViewModel by rootViewModel.observeAsState() + val systemUiController = rememberSystemUiController() + systemUiController.setNavigationBarColor(color = Color.PrimaryBlack, darkIcons = false) + systemUiController.setStatusBarColor(color = Color.Transparent, darkIcons = false) + Box( modifier = Modifier .fillMaxSize() .background(Color.White) ) { - Text( - modifier = Modifier - .align(Alignment.Center), - text = "Hi" - ) + ProjectsView(projectsViewModel = viewModel.projectsViewModel) } } diff --git a/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Colors.kt b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Colors.kt new file mode 100644 index 0000000..3543523 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Colors.kt @@ -0,0 +1,8 @@ +package com.mirego.kmp.boilerplate.app.ui.theme + +import androidx.compose.ui.graphics.Color + +val Color.Companion.PrimaryBlack: Color get() = Color(0xFF211E25) +val Color.Companion.AccentOrange: Color get() = Color(0xFFFF3829) +val Color.Companion.ShimmerBackground: Color get() = Color(0xFF4C474f) +val Color.Companion.ShimmerHighlight: Color get() = Color(0xFF938C96) 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 new file mode 100644 index 0000000..c8e6c95 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/TextStyle.kt @@ -0,0 +1,56 @@ +package com.mirego.kmp.boilerplate.app.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +fun style(size: TextSize, weight: TextWeight) = TextStyle( + fontSize = size.fontSize(), + fontStyle = FontStyle.Normal, + fontWeight = weight.fontName() +) + +enum class TextSize { + LARGE_TITLE, + TITLE1, + TITLE2, + TITLE3, + HEADLINE, + BODY, + CALLOUT, + SUB_HEADLINE, + FOOTNOTE, + CAPTION1, + CAPTION2; + + fun fontSize() = when (this) { + LARGE_TITLE -> 34.sp + TITLE1 -> 28.sp + TITLE2 -> 22.sp + TITLE3 -> 20.sp + HEADLINE -> 18.sp + BODY -> 17.sp + CALLOUT -> 16.sp + SUB_HEADLINE -> 15.sp + FOOTNOTE -> 13.sp + CAPTION1 -> 12.sp + CAPTION2 -> 11.sp + } +} + +enum class TextWeight { + LIGHT, + REGULAR, + MEDIUM, + SEMI_BOLD, + BOLD; + + fun fontName() = when (this) { + LIGHT -> FontWeight.Light + REGULAR -> FontWeight.Normal + MEDIUM -> FontWeight.Medium + SEMI_BOLD -> FontWeight.SemiBold + BOLD -> FontWeight.Bold + } +} 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 new file mode 100644 index 0000000..23f31f8 --- /dev/null +++ b/androidApp/src/main/java/com/mirego/kmp/boilerplate/app/ui/theme/Theme.kt @@ -0,0 +1,20 @@ +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.graphics.Color + +@Composable +fun Theme( + content: @Composable () -> Unit +) { + val colors = lightColorScheme( + primary = Color.AccentOrange + ) + + MaterialTheme( + colorScheme = colors, + content = content + ) +} 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/drawable/baseline_image_24.xml b/androidApp/src/main/res/drawable/baseline_image_24.xml new file mode 100644 index 0000000..d35859d --- /dev/null +++ b/androidApp/src/main/res/drawable/baseline_image_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/baseline_question_mark_24.xml b/androidApp/src/main/res/drawable/baseline_question_mark_24.xml new file mode 100644 index 0000000..fbfe98d --- /dev/null +++ b/androidApp/src/main/res/drawable/baseline_question_mark_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/drawable/baseline_warning_24.xml b/androidApp/src/main/res/drawable/baseline_warning_24.xml new file mode 100644 index 0000000..3c9a4b3 --- /dev/null +++ b/androidApp/src/main/res/drawable/baseline_warning_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/androidApp/src/main/res/resources.properties b/androidApp/src/main/res/resources.properties new file mode 100644 index 0000000..d0902b0 --- /dev/null +++ b/androidApp/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=fr-CA diff --git a/androidApp/src/main/res/values/colors.xml b/androidApp/src/main/res/values/colors.xml index 4faecfa..c502957 100644 --- a/androidApp/src/main/res/values/colors.xml +++ b/androidApp/src/main/res/values/colors.xml @@ -1,6 +1,6 @@ - #6200EE - #3700B3 - #03DAC5 + #211E25 + #211E25 + #FF3829 \ No newline at end of file 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 53a2c84..c23922e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,18 @@ [versions] + +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" @@ -16,11 +23,16 @@ 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" +skie = "0.4.20" trikot = "5.2.0" [libraries] +accompanist-placeholder-material = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" } +accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } android-splash = { module = "androidx.core:core-splashscreen", version.ref = "android-splash" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } @@ -28,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" } @@ -38,9 +57,17 @@ koin-androidx-compose-navigation = { module = "io.insert-koin:koin-androidx-comp kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +skie = { module = "co.touchlab.skie:configuration-annotations", version.ref = "skie" } + +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" } @@ -58,9 +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/.swiftlint.yml b/ios/.swiftlint.yml index 7c22191..ee445ac 100644 --- a/ios/.swiftlint.yml +++ b/ios/.swiftlint.yml @@ -7,6 +7,7 @@ disabled_rules: - multiple_closures_with_trailing_closure - inclusive_language - blanket_disable_command + - switch_case_alignment opt_in_rules: - array_init 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/Gemfile.lock b/ios/Gemfile.lock index 684e139..87dd909 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -13,7 +13,25 @@ GEM algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) + artifactory (3.0.15) atomos (0.1.3) + aws-eventstream (1.3.0) + aws-partitions (1.856.0) + aws-sdk-core (3.188.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.73.0) + aws-sdk-core (~> 3, >= 3.188.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.139.0) + aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.6) + aws-sigv4 (1.7.0) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) claide (1.1.0) cocoapods (1.13.0) addressable (~> 2.8) @@ -52,31 +70,190 @@ GEM nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) + colored (1.2) colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) concurrent-ruby (1.2.2) + declarative (0.0.20) + digest-crc (0.6.5) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20231109) + dotenv (2.8.1) + emoji_regex (3.2.3) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) + 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.217.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) ffi (1.16.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.53.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) i18n (1.14.1) concurrent-ruby (~> 1.0) + jmespath (1.6.2) json (2.6.3) + jwt (2.7.1) + mini_magick (4.12.0) + mini_mime (1.1.5) minitest (5.20.0) molinillo (0.8.0) + multi_json (1.15.0) + multipart-post (2.3.0) nanaimo (0.3.0) nap (1.1.0) + naturally (2.2.1) netrc (0.11.0) + optparse (0.1.1) + os (1.1.4) + plist (3.7.0) public_suffix (4.0.7) + 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) ruby-macho (2.5.1) + 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) typhoeus (1.4.0) ethon (>= 0.9.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + uber (0.1.0) + 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) @@ -84,12 +261,17 @@ GEM 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-23 DEPENDENCIES cocoapods (~> 1.13) + fastlane BUNDLED WITH 2.4.20 diff --git a/ios/Podfile b/ios/Podfile index 6aab912..56b377a 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -7,6 +7,12 @@ inhibit_all_warnings! target 'iosApp' do use_frameworks! platform :ios, $deploymentTarget + # Third-party + pod 'AppCenter/Distribute' + pod 'FirebaseCore' + pod 'FirebaseAnalytics' + + # Multiplatform pod 'Shared', :path => '../shared' ENV['TRIKOT_FRAMEWORK_NAME']='Shared' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 75c6213..b51e33f 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: b62596630d9100fb9a2c6dce86f50d0bc9944b74 +PODFILE CHECKSUM: 7896ef8b5f482d9c009e646430313abc8222766e 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 ce1d6bc..a2984dd 100644 --- a/ios/iosApp.xcodeproj/project.pbxproj +++ b/ios/iosApp.xcodeproj/project.pbxproj @@ -19,6 +19,22 @@ 89011D552AE9A8150073544B /* AppInformationImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D532AE9A8150073544B /* AppInformationImpl.swift */; }; 89011D5B2AE9B0310073544B /* PreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89011D5A2AE9B0310073544B /* PreviewHelpers.swift */; }; 895BECFB2AEAB3DC005B1212 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BECFA2AEAB3DC005B1212 /* RootView.swift */; }; + 895BECFE2AEFF078005B1212 /* ProjectsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BECFD2AEFF078005B1212 /* ProjectsView.swift */; }; + 895BED012AEFF111005B1212 /* StatusBarConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED002AEFF111005B1212 /* StatusBarConfigurator.swift */; }; + 895BED032AEFF281005B1212 /* TextStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895BED022AEFF281005B1212 /* TextStyle.swift */; }; + 895BED052AEFFEA2005B1212 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 895BED042AEFFEA2005B1212 /* Colors.xcassets */; }; + 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 */ @@ -39,6 +55,22 @@ 89011D532AE9A8150073544B /* AppInformationImpl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppInformationImpl.swift; sourceTree = ""; }; 89011D5A2AE9B0310073544B /* PreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewHelpers.swift; sourceTree = ""; }; 895BECFA2AEAB3DC005B1212 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 895BECFD2AEFF078005B1212 /* ProjectsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectsView.swift; sourceTree = ""; }; + 895BED002AEFF111005B1212 /* StatusBarConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarConfigurator.swift; sourceTree = ""; }; + 895BED022AEFF281005B1212 /* TextStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextStyle.swift; sourceTree = ""; }; + 895BED042AEFFEA2005B1212 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; + 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 */ @@ -88,9 +120,12 @@ 2152FB032600AC8F00CF470E /* iOSApp.swift */, 89011D562AE9AFF90073544B /* Domain */, 89011D572AE9AFFF0073544B /* UI */, + 89011D5C2AE9B14A0073544B /* Extensions */, 7555FF8C242A565B00829871 /* Info.plist */, 058557BA273AAA24004C7B11 /* Assets.xcassets */, + 895BED042AEFFEA2005B1212 /* Colors.xcassets */, 058557D7273AAEEB004C7B11 /* Preview Content */, + 895BED1E2AF9780C005B1212 /* ToReplace-GoogleService-Info.plist */, ); path = iosApp; sourceTree = ""; @@ -102,6 +137,7 @@ 89011D4C2AE9A4FC0073544B /* AppEnvironment+iOS.swift */, 89011D4A2AE9A4CD0073544B /* BootstrapImpl.swift */, 89011D502AE9A7750073544B /* ImageProvider.swift */, + 895BED1C2AF58517005B1212 /* AnalyticsServiceImpl.swift */, ); path = Domain; sourceTree = ""; @@ -110,6 +146,10 @@ isa = PBXGroup; children = ( 89011D582AE9B00F0073544B /* Application */, + 895BECFF2AEFF0FB005B1212 /* Common */, + 895BED0F2AF3FD0E005B1212 /* Navigation */, + 895BECFC2AEFF06C005B1212 /* Projects */, + 895BED0C2AF3E650005B1212 /* ProjectDetails */, 895BECF92AEAB3D1005B1212 /* Root */, 89011D592AE9B0160073544B /* Previews */, ); @@ -132,6 +172,14 @@ path = Previews; sourceTree = ""; }; + 89011D5C2AE9B14A0073544B /* Extensions */ = { + isa = PBXGroup; + children = ( + 895BED162AF416F5005B1212 /* VMDColor+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 895BECF92AEAB3D1005B1212 /* Root */ = { isa = PBXGroup; children = ( @@ -140,6 +188,46 @@ path = Root; sourceTree = ""; }; + 895BECFC2AEFF06C005B1212 /* Projects */ = { + isa = PBXGroup; + children = ( + 895BECFD2AEFF078005B1212 /* ProjectsView.swift */, + 895BED062AF099BC005B1212 /* ProjectsContentView.swift */, + ); + path = Projects; + sourceTree = ""; + }; + 895BECFF2AEFF0FB005B1212 /* Common */ = { + isa = PBXGroup; + children = ( + 895BED002AEFF111005B1212 /* StatusBarConfigurator.swift */, + 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 = ( @@ -219,6 +307,8 @@ isa = PBXResourcesBuildPhase; 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 */, ); @@ -310,15 +400,29 @@ 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 */, + 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 */, 7555FF83242A565900829871 /* ApplicationView.swift in Sources */, 89011D552AE9A8150073544B /* AppInformationImpl.swift in Sources */, + 895BED0B2AF0A7ED005B1212 /* EmptyContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 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 8169c04..3b3b77e 100644 --- a/ios/iosApp/AppInitializer.swift +++ b/ios/iosApp/AppInitializer.swift @@ -1,12 +1,43 @@ +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 initializeCommon() { @@ -20,4 +51,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/Colors.xcassets/Contents.json b/ios/iosApp/Colors.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/iosApp/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/iosApp/Colors.xcassets/accentOrange.colorset/Contents.json b/ios/iosApp/Colors.xcassets/accentOrange.colorset/Contents.json new file mode 100644 index 0000000..e712388 --- /dev/null +++ b/ios/iosApp/Colors.xcassets/accentOrange.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "41", + "green" : "56", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/iosApp/Colors.xcassets/primaryBlack.colorset/Contents.json b/ios/iosApp/Colors.xcassets/primaryBlack.colorset/Contents.json new file mode 100644 index 0000000..2784a53 --- /dev/null +++ b/ios/iosApp/Colors.xcassets/primaryBlack.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "37", + "green" : "30", + "red" : "33" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/AppInformationImpl.swift b/ios/iosApp/Domain/AppInformationImpl.swift index a9476c8..7aca868 100644 --- a/ios/iosApp/Domain/AppInformationImpl.swift +++ b/ios/iosApp/Domain/AppInformationImpl.swift @@ -1,21 +1,29 @@ import Foundation import Shared +import Trikot class AppInformationImpl: AppInformation { - let locale: Shared.Locale + private let flowProvider: FlowProvider = FlowProvider() + private lazy var localeStateFlow: ConcreteMutableStateFlow = { + let language = LocaleUtils().supportedLanguageCode() == "fr" ? Shared.Language.french : Shared.Language.english + let regionCode = Foundation.Locale.current.regionCode + let currentLocale = Shared.Locale(language: language, regionCode: regionCode) + return flowProvider.mutableStateFlow(initialValue: currentLocale) + }() + let versionNumber: String let diskCachePath: String - init (environmentKey: String) { + init(environmentKey: String) { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "" let build = Bundle.main.infoDictionary?["CFBundleVersion"] ?? "" versionNumber = "\(version).\(build)" let cacheUrl = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)[0] diskCachePath = cacheUrl + "/\(environmentKey)/" - - let language = LocaleUtils().supportedLanguageCode() == "fr" ? Shared.Language.french : Shared.Language.english - let regionCode = Foundation.Locale.current.regionCode - locale = Shared.Locale(language: language, regionCode: regionCode) + } + + func locale() -> ConcreteFlow { + localeStateFlow } } diff --git a/ios/iosApp/Domain/ImageProvider.swift b/ios/iosApp/Domain/ImageProvider.swift index 24a738f..31aaee9 100644 --- a/ios/iosApp/Domain/ImageProvider.swift +++ b/ios/iosApp/Domain/ImageProvider.swift @@ -4,6 +4,16 @@ import Trikot final class ImageProvider: VMDImageProvider { func imageForResource(imageResource: VMDImageResource) -> Image? { - nil + guard let imageResource = imageResource as? SharedImageResource else { return nil } + switch imageResource { + case .emptypageicon: + return Image(systemName: "questionmark.folder.fill") + case .errorpageicon: + 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/LocaleExtensions.swift b/ios/iosApp/Extensions/LocaleExtensions.swift deleted file mode 100644 index 67e0298..0000000 --- a/ios/iosApp/Extensions/LocaleExtensions.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -extension Foundation.Locale { - static var isPreferredLanguagesFrench: Bool { - if let language = Locale.preferredLanguages.first, language.lowercased().contains("fr") { - return true - } else { - return false - } - } - - static var preferredLocale: Locale { - isPreferredLanguagesFrench ? Locale(identifier: "fr-CA") : Locale(identifier: "en-CA") - } -} 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/Application/ApplicationView.swift b/ios/iosApp/UI/Application/ApplicationView.swift index be21bc3..11f7206 100644 --- a/ios/iosApp/UI/Application/ApplicationView.swift +++ b/ios/iosApp/UI/Application/ApplicationView.swift @@ -4,7 +4,8 @@ import Trikot struct ApplicationView: View { @ObservedObject private var observableViewModel: ObservableViewModelAdapter - + @StateObject private var statusBarConfigurator = StatusBarConfigurator() + init(viewModel: ApplicationViewModel) { observableViewModel = viewModel.asObservable() } @@ -15,6 +16,11 @@ struct ApplicationView: View { var body: some View { RootView(viewModel: viewModel.rootViewModel) + .prepareStatusBarConfigurator(statusBarConfigurator) + .environment(\.statusBarConfigurator, statusBarConfigurator) + .onAppear { + statusBarConfigurator.statusBarStyle = .lightContent + } } } diff --git a/ios/iosApp/UI/Common/EmptyContentView.swift b/ios/iosApp/UI/Common/EmptyContentView.swift new file mode 100644 index 0000000..57e4901 --- /dev/null +++ b/ios/iosApp/UI/Common/EmptyContentView.swift @@ -0,0 +1,35 @@ +import Shared +import SwiftUI +import Trikot + +struct EmptyContentView: View { + @ObservedObject private var observableViewModel: ObservableViewModelAdapter + private let padding: CGFloat = 16 + + private var viewModel: EmptyViewModel { + observableViewModel.viewModel + } + + init(viewModel: EmptyViewModel) { + observableViewModel = viewModel.asObservable() + } + + var body: some View { + VStack(spacing: 0) { + VMDImage(viewModel.icon) + .resizable() + .frame(width: 55, height: 55) + .foregroundStyle(Color.white) + + VMDText(viewModel.title) + .textStyle(.largeTitle, .regular, .white) + .lineLimit(1) + .padding(.top, padding * 2) + + VMDText(viewModel.message) + .textStyle(.body, .regular, .white) + .multilineTextAlignment(.center) + .padding(.top, padding) + } + } +} diff --git a/ios/iosApp/UI/Common/ErrorView.swift b/ios/iosApp/UI/Common/ErrorView.swift new file mode 100644 index 0000000..e209c34 --- /dev/null +++ b/ios/iosApp/UI/Common/ErrorView.swift @@ -0,0 +1,48 @@ +import Shared +import SwiftUI +import Trikot + +struct ErrorView: View { + @ObservedObject private var observableViewModel: ObservableViewModelAdapter + private let padding: CGFloat = 16 + + private var viewModel: ErrorViewModel { + observableViewModel.viewModel + } + + init(viewModel: ErrorViewModel) { + observableViewModel = viewModel.asObservable() + } + + var body: some View { + VStack(spacing: 0) { + VMDImage(viewModel.icon) + .resizable() + .frame(width: 55, height: 55) + .foregroundStyle(Color.white) + + VMDText(viewModel.title) + .textStyle(.largeTitle, .regular, .white) + .lineLimit(1) + .padding(.top, padding * 2) + + VMDText(viewModel.message) + .textStyle(.body, .regular, .white) + .multilineTextAlignment(.center) + .padding(.top, padding) + + VMDButton(viewModel.retryButton) { + Text($0.text) + .textStyle(.body, .regular, .white) + .frame(maxWidth: 320) + } + .padding(.vertical, 12) + .background( + Capsule() + .fill(Color(.accentOrange)) + ) + .padding(.top, padding * 2) + .padding(.horizontal, padding) + } + } +} 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/StatusBarConfigurator.swift b/ios/iosApp/UI/Common/StatusBarConfigurator.swift new file mode 100644 index 0000000..e96f856 --- /dev/null +++ b/ios/iosApp/UI/Common/StatusBarConfigurator.swift @@ -0,0 +1,79 @@ +import UIKit +import SwiftUI + +struct StatusBarConfiguratorKey: EnvironmentKey { + static let defaultValue = StatusBarConfigurator() +} + +extension EnvironmentValues { + var statusBarConfigurator: StatusBarConfigurator { + get { self[StatusBarConfiguratorKey.self] } + set { self[StatusBarConfiguratorKey.self] = newValue } + } +} + +class StatusBarConfigurator: ObservableObject { + + private var window: UIWindow? + + var statusBarStyle: UIStatusBarStyle = .darkContent { + didSet { + if statusBarStyle == .lightContent { + window?.makeKeyAndVisible() + } else { + window?.isHidden = true + } + window?.rootViewController?.setNeedsStatusBarAppearanceUpdate() + } + } + + func prepare(scene: UIWindowScene) { + if window == nil { + let window = UIWindow(windowScene: scene) + let viewController = StatusBarConfiguratorViewController() + viewController.configurator = self + window.rootViewController = viewController + window.frame = UIScreen.main.bounds + window.alpha = 0 + self.window = window + } + window?.windowLevel = .statusBar + window?.makeKeyAndVisible() + } + + fileprivate class StatusBarConfiguratorViewController: UIViewController { + weak var configurator: StatusBarConfigurator! + override var preferredStatusBarStyle: UIStatusBarStyle { configurator.statusBarStyle } + } +} + +struct SceneFinder: UIViewRepresentable { + + var getScene: ((UIWindowScene) -> Void)? + + func makeUIView(context: Context) -> SceneFinderView { + SceneFinderView() + } + + func updateUIView(_ uiView: SceneFinderView, context: Context) { + uiView.getScene = getScene + } + + class SceneFinderView: UIView { + var getScene: ((UIWindowScene) -> Void)? + + override func didMoveToWindow() { + if let scene = window?.windowScene { + getScene?(scene) + } + } + } +} + +extension View { + func prepareStatusBarConfigurator(_ configurator: StatusBarConfigurator) -> some View { + background(SceneFinder { scene in + configurator.prepare(scene: scene) + }) + } +} diff --git a/ios/iosApp/UI/Common/TextStyle.swift b/ios/iosApp/UI/Common/TextStyle.swift new file mode 100644 index 0000000..54ea619 --- /dev/null +++ b/ios/iosApp/UI/Common/TextStyle.swift @@ -0,0 +1,77 @@ +import SwiftUI + +extension View { + func textStyle(_ size: TextSize, _ style: TextStyle, _ color: Color) -> some View { + font( + .custom( + style.fontName, + size: size.fontSize + ) + ) + .foregroundColor(color) + .limitDynamicFont() + } + + func limitDynamicFont() -> some View { + dynamicTypeSize(SwiftUI.DynamicTypeSize.xSmall ... SwiftUI.DynamicTypeSize.xxxLarge) + } +} + +enum TextSize { + case largeTitle + case title1 + case title2 + case title3 + case headline + case body + case callout + case subHeadline + case footnote + case caption1 + case caption2 + + var fontSize: CGFloat { + switch self { + case .largeTitle: + return 34 + case .title1: + return 28.0 + case .title2: + return 22.0 + case .title3: + return 20.0 + case .headline: + return 18.0 + case .body: + return 17.0 + case .callout: + return 16.0 + case .subHeadline: + return 15.0 + case .footnote: + return 13.0 + case .caption1: + return 12.0 + case .caption2: + return 11.0 + } + } +} + +enum TextStyle: String { + case light + case regular + case medium + case semiBold + case bold + + var fontName: String { + switch self { + 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 new file mode 100644 index 0000000..4ad133b --- /dev/null +++ b/ios/iosApp/UI/Projects/ProjectsContentView.swift @@ -0,0 +1,106 @@ +import Shared +import SwiftUI +import Trikot + +struct ProjectsContentView: View { + let viewModel: VMDListViewModel + + private let padding: CGFloat = 16 + + var body: some View { + GeometryReader { proxy in + ScrollView(.vertical) { + VStack(spacing: padding * 2) { + ForEach(viewModel.elements, id: \.identifier) { section in + switch onEnum(of: section) { + case let .header(header): + headerView(header: header) + case let .noProjects(noProjects): + EmptyContentView(viewModel: noProjects.emptyViewModel) + .padding(.top, 100) + case let .projectsList(projectsList): + projectListView( + viewModel: projectsList.viewModel, + itemSize: proxy.size.width - 2 * padding + ) + } + } + } + .padding(.all, padding) + } + } + } + + private func headerView(header: ProjectsContentSectionHeader) -> some View { + VStack(spacing: padding * 2) { + Text(header.title) + .textStyle(.title1, .semiBold, .white) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + + Text(header.description_) + .textStyle(.headline, .regular, .white) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + } + .padding(.horizontal, padding) + } + + private func projectListView(viewModel: VMDListViewModel, itemSize: CGFloat) -> some View { + VStack(spacing: 16) { + ForEach(viewModel.elements, id: \.identifier) { item in + Button { + item.tapAction() + } label: { + itemView(viewModel: item) + .frame(width: itemSize) + } + } + } + } + + private func itemView(viewModel: ProjectItem) -> some View { + VStack(alignment: .leading, spacing: padding) { + VMDImage(viewModel.image) + .placeholder { imagePlaceHolder in + ZStack { + Rectangle() + .foregroundColor(Color(.primaryBlack)) + imagePlaceHolder? + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .foregroundStyle(Color.black) + } + } + .resizable() + .scaledToFill() + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .white.opacity(0.05), radius: 6, y: 4) + + VStack(alignment: .leading, spacing: 0) { + Text(viewModel.title) + .textStyle(.subHeadline, .regular, .white) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + + 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) + } + .padding([.horizontal, .bottom], padding) + .redacted(reason: viewModel.isLoading ? .placeholder : []) + } + .background(Color(.primaryBlack)) + } +} diff --git a/ios/iosApp/UI/Projects/ProjectsView.swift b/ios/iosApp/UI/Projects/ProjectsView.swift new file mode 100644 index 0000000..5a7b302 --- /dev/null +++ b/ios/iosApp/UI/Projects/ProjectsView.swift @@ -0,0 +1,62 @@ +import Shared +import SwiftUI +import Trikot + +struct ProjectsView: View { + @ObservedObject private var observableViewModel: ObservableViewModelAdapter + + init(viewModel: ProjectsViewModel) { + observableViewModel = viewModel.asObservable() + } + + var viewModel: ProjectsViewModel { + observableViewModel.viewModel + } + + var body: some View { + contentView + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + Color(.primaryBlack) + .ignoresSafeArea() + ) + .handleNavigation(viewModel, route: viewModel.navigationRoute, navigationTypeOverride: navigationTypeOverride) + } + + @ViewBuilder private var contentView: some View { + if let rootContent = viewModel.rootContent { + switch onEnum(of: rootContent) { + case let .content(content): + ProjectsContentView(viewModel: content.sections) + case let .error(error): + ErrorView(viewModel: error.errorViewModel) + } + } + } +} + +extension ProjectsView { + func navigationTypeOverride(route: VMDNavigationRoute) -> NavigationType? { + if route is NavigationRouteProjectDetails { + return .push + } + + return nil + } +} + +#Preview { + ProjectsView( + viewModel: factoryPreview().createProjects( + previewState: PreviewStateDataEmpty() + ) + ) +} + +#Preview { + ProjectsView( + viewModel: factoryPreview().createProjects( + previewState: PreviewStateError() + ) + ) +} diff --git a/ios/iosApp/UI/Root/RootView.swift b/ios/iosApp/UI/Root/RootView.swift index 911b64f..ce24fff 100644 --- a/ios/iosApp/UI/Root/RootView.swift +++ b/ios/iosApp/UI/Root/RootView.swift @@ -14,9 +14,8 @@ struct RootView: View { } var body: some View { - Text("Hi") - - + 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 8114a19..85ece34 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.ktlint) alias(libs.plugins.mirego.kwordPlugin) alias(libs.plugins.apollo.graphql) + alias(libs.plugins.skie.plugin) } version = "0.1" @@ -18,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) } @@ -31,6 +34,12 @@ kword { generatedDir = file("src/commonMain/generated") } +skie { + analytics { + disableUpload.set(true) + } +} + apollo { val file = File("src/commonMain/kotlin/com/mirego/kmp/boilerplate/graphql/schema.graphqls") packageName.set("com.mirego.kmp.boilerplate") @@ -94,10 +103,13 @@ kotlin { api(libs.kotlinx.serialization.json) 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) @@ -106,10 +118,18 @@ kotlin { dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.mockk.common) } } val androidMain by getting - val androidUnitTest by getting + + val androidUnitTest by getting { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlin.test.junit) + implementation(libs.mockk) + } + } val iosX64Main by getting val iosArm64Main by getting @@ -151,9 +171,29 @@ 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") } + } +} + +tasks.withType>().all { + if (name != "kspCommonMainKotlinMetadata") { + dependsOn("kspCommonMainKotlinMetadata") + } else { + 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") +} + tasks.withType>().all { if (name != "kspCommonMainKotlinMetadata") { dependsOn("kspCommonMainKotlinMetadata") 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 01ea958..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,13 +6,21 @@ 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"), + PROJECTS_HEADER_DESCRIPTION("projects_header_description"), + PROJECTS_EMPTY_CONTENT_MESSAGE("projects_empty_content_message"), + PROJECTS_HEADER_TITLE("projects_header_title"), + 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/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 c50ee67..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-qa.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/AppInformation.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppInformation.kt index 0115a4c..d4c10b9 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppInformation.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/AppInformation.kt @@ -1,9 +1,10 @@ package com.mirego.kmp.boilerplate.bootstrap import com.mirego.kmp.boilerplate.model.Locale +import com.mirego.kmp.boilerplate.utils.ConcreteFlow interface AppInformation { - val locale: Locale + fun locale(): ConcreteFlow val versionNumber: String val diskCachePath: 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/LocaleHeaderInterceptor.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleHeaderInterceptor.kt new file mode 100644 index 0000000..177a10d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/bootstrap/LocaleHeaderInterceptor.kt @@ -0,0 +1,25 @@ +package com.mirego.kmp.boilerplate.bootstrap + +import com.apollographql.apollo3.api.http.HttpRequest +import com.apollographql.apollo3.api.http.HttpResponse +import com.apollographql.apollo3.network.http.HttpInterceptor +import com.apollographql.apollo3.network.http.HttpInterceptorChain +import com.mirego.kmp.boilerplate.repository.locale.LocaleRepository +import kotlinx.coroutines.flow.first +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class LocaleHeaderInterceptor : HttpInterceptor, KoinComponent { + private val localeRepository: LocaleRepository by inject() + + override suspend fun intercept(request: HttpRequest, chain: HttpInterceptorChain): HttpResponse { + val locale = localeRepository.locale().first() + + return chain.proceed( + request + .newBuilder() + .addHeader("X-Accept-Language", locale.language.toLangCode()) + .build() + ) + } +} 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 0ad0a9c..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 @@ -28,24 +28,19 @@ fun buildJson() = Json { fun generalModule(bootstrap: Bootstrap): Module { return module { - val environment = bootstrap.environment - single { createApolloClientBuilder(environment).build() } + single { createApolloClientBuilder(bootstrap).build() } single { KWord } single { bootstrap.environment } single { bootstrap.appInformation } - single { bootstrap.appInformation.locale.language } - single(StringQualifier(ModuleQualifier.REGION_CODE)) { bootstrap.appInformation.locale.regionCode } - single(StringQualifier(ModuleQualifier.DISK_CACHE_PATH)) { bootstrap.appInformation.diskCachePath + "/${bootstrap.appInformation.locale.language.toLangCode()}" } + single(StringQualifier(ModuleQualifier.DISK_CACHE_PATH)) { bootstrap.appInformation.diskCachePath } single { buildJson() } } } -private fun createApolloClientBuilder(appEnvironment: AppEnvironment): ApolloClient.Builder = - ApolloClient.Builder() - .serverUrl(appEnvironment.graphQlApiUrl) - .autoPersistedQueries() +private fun createApolloClientBuilder(bootstrap: Bootstrap): ApolloClient.Builder = ApolloClient.Builder() + .serverUrl(bootstrap.environment.graphQlApiUrl) + .addHttpInterceptor(LocaleHeaderInterceptor()) object ModuleQualifier { const val DISK_CACHE_PATH = "diskCachePath" - const val REGION_CODE = "regionCode" } 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/DataSourceUtils.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceUtils.kt index 6af6039..f355b8a 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceUtils.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/DataSourceUtils.kt @@ -5,9 +5,5 @@ import com.mirego.trikot.datasources.flow.FlowDataSourceExpiringValue import com.mirego.trikot.foundation.date.Date object DataSourceUtils { - fun buildExpiringValue(value: T, request: ExpiringFlowDataSourceRequest) = - FlowDataSourceExpiringValue( - value, - Date.now.epoch + request.expiredInMilliseconds - ) + fun buildExpiringValue(value: T, request: ExpiringFlowDataSourceRequest) = FlowDataSourceExpiringValue(value, Date.now.epoch + request.expiredInMilliseconds) } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDataSource.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDataSource.kt index 21433da..4a86e0f 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDataSource.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/datasource/apollo/ApolloGraphQLDataSource.kt @@ -20,9 +20,7 @@ open class ApolloGraphQLDataSource( diskCachePath?.let { ApolloGraphQLDiskDataSource(diskCachePath) } ) { - override suspend fun internalRead( - request: ApolloGraphQLDataSourceRequest - ): FlowDataSourceExpiringValue { + override suspend fun internalRead(request: ApolloGraphQLDataSourceRequest): FlowDataSourceExpiringValue { val response = apolloClient .query(request.query) .execute() 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 b4d7de4..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 @@ -15,11 +15,7 @@ open class GenericDataSource( ) : BaseExpiringExecutableFlowDataSource, T>( diskCachePath?.let { GenericDiskDataSource(json, dataSerializer, diskCachePath) } ) { - override suspend fun internalRead(request: GenericDataSourceRequest) = - DataSourceUtils.buildExpiringValue( - request.block(), - request - ) + override suspend fun internalRead(request: GenericDataSourceRequest) = DataSourceUtils.buildExpiringValue(request.block(), request) } data class GenericDataSourceRequest( diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/extension/StateDataExtensions.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/extension/StateDataExtensions.kt index 7684e7e..3a9bd04 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/extension/StateDataExtensions.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/extension/StateDataExtensions.kt @@ -52,30 +52,22 @@ fun Flow>.mapNullDataToError(): Flow> = map { sta } } -fun Flow>>.removeExpiredValue( - extraExpiredMilliseconds: Long = 0 -): Flow> { +fun Flow>>.removeExpiredValue(extraExpiredMilliseconds: Long = 0): Flow> { return map { dataState -> when (dataState) { - is DataState.Pending -> stateDataPending( - dataState.value?.valueIfNotExpired(extraExpiredMilliseconds) - ) + is DataState.Pending -> stateDataPending(dataState.value?.valueIfNotExpired(extraExpiredMilliseconds)) is DataState.Data -> dataState.value.valueIfNotExpired(extraExpiredMilliseconds)?.let { stateDataData(it) } ?: stateDataError(Throwable("Data is expired.")) - is DataState.Error -> stateDataError( - dataState.error, - dataState.value?.valueIfNotExpired(extraExpiredMilliseconds) - ) + is DataState.Error -> stateDataError(dataState.error, dataState.value?.valueIfNotExpired(extraExpiredMilliseconds)) } } } -private fun FlowDataSourceExpiringValue.valueIfNotExpired(extraExpiredMilliseconds: Long) = - if ((expiredEpoch + extraExpiredMilliseconds) < Date.now.epoch) { - null - } else { - value - } +private fun FlowDataSourceExpiringValue.valueIfNotExpired(extraExpiredMilliseconds: Long) = if ((expiredEpoch + extraExpiredMilliseconds) < Date.now.epoch) { + null +} else { + value +} 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 e568ed2..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 @@ -7,8 +7,13 @@ query Projects($projectsSlug: PageSlug!) { entries { pageSlug name - introductionText + projectType listImageUrl(width: 1200, height: 1200) + client { + name + } + mainColor + textColor } } } diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/locale/LocaleRepository.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/locale/LocaleRepository.kt new file mode 100644 index 0000000..e8d88c2 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/locale/LocaleRepository.kt @@ -0,0 +1,8 @@ +package com.mirego.kmp.boilerplate.repository.locale + +import com.mirego.kmp.boilerplate.model.Locale +import kotlinx.coroutines.flow.Flow + +interface LocaleRepository { + fun locale(): Flow +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/locale/LocaleRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/locale/LocaleRepositoryImpl.kt new file mode 100644 index 0000000..af4dce1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/repository/locale/LocaleRepositoryImpl.kt @@ -0,0 +1,13 @@ +package com.mirego.kmp.boilerplate.repository.locale + +import com.mirego.kmp.boilerplate.bootstrap.AppInformation +import com.mirego.kmp.boilerplate.model.Locale +import kotlinx.coroutines.flow.Flow +import org.koin.core.annotation.Single + +@Single +class LocaleRepositoryImpl( + private val appInformation: AppInformation +) : LocaleRepository { + override fun locale(): Flow = appInformation.locale() +} 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 ef05eaf..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 @@ -7,45 +7,55 @@ import com.mirego.kmp.boilerplate.adapter.ProjectsQuery_ResponseAdapter import com.mirego.kmp.boilerplate.datasource.ProjectsDataSource import com.mirego.kmp.boilerplate.datasource.apollo.ApolloGraphQLDataSourceRequest import com.mirego.kmp.boilerplate.model.Language +import com.mirego.kmp.boilerplate.repository.locale.LocaleRepository 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 kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import org.koin.core.annotation.Single @Single class ProjectsRepositoryImpl( - private val language: Language, + private val localeRepository: LocaleRepository, private val dataSource: ProjectsDataSource ) : ProjectsRepository { - override fun projects(): Flow>> = - dataSource.read( - ProjectsQuery( - when (language) { - Language.ENGLISH -> "work" - Language.FRENCH -> "projets" - } - ).request(forceRefresh = false) - ).mapValue { - it.value.page - ?.asPage() - ?.blocks?.firstOrNull() - ?.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() dataSource.read( - ProjectsQuery("").request(forceRefresh = true) + buildQuery(language = locale.language) + .request(forceRefresh = true) ).filter { !it.isPending() }.first() } + + private fun buildQuery(language: Language) = ProjectsQuery( + when (language) { + Language.ENGLISH -> "work" + Language.FRENCH -> "projets" + } + ) } private fun ProjectsQuery.request(forceRefresh: Boolean = true) = ApolloGraphQLDataSourceRequest( query = this, serializeJsonMethod = ProjectsQuery_ResponseAdapter.Data::toJson, deSerializeJsonMethod = ProjectsQuery_ResponseAdapter.Data::fromJson, - cacheableId = this.id(), + 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 1c19e29..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,6 +8,7 @@ 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 @@ -21,8 +23,11 @@ class ProjectsUseCasePreview( ProjectItemViewData( id = it.toString(), title = "Project #$it", - description = "A small project description #$it", - imageUrl = "" + subtitle = "A small project description #$it", + description = "iOS & Android applications", + 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 581d470..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 { @@ -19,6 +20,9 @@ sealed interface ProjectsViewData { data class ProjectItemViewData( val id: String, 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 e15e172..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 @@ -20,9 +22,12 @@ class ProjectsUseCaseImpl( it.map { project -> ProjectItemViewData( id = project.pageSlug, - title = project.name, - description = project.introductionText, - imageUrl = project.listImageUrl.toString() + title = project.client.name, + subtitle = project.name, + description = project.projectType, + 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/utils/ConcreteFlow.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/ConcreteFlow.kt new file mode 100644 index 0000000..a555ccb --- /dev/null +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/ConcreteFlow.kt @@ -0,0 +1,47 @@ +package com.mirego.kmp.boilerplate.utils + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +open class ConcreteFlow(private val internalFlow: Flow) : Flow { + override suspend fun collect(collector: FlowCollector) { + internalFlow.collect(collector) + } +} + +fun Flow.wrap(): ConcreteFlow = ConcreteFlow(this) + +open class ConcreteMutableSharedFlow(private val mutableSharedFlow: MutableSharedFlow) : MutableSharedFlow, ConcreteFlow(mutableSharedFlow) { + override val subscriptionCount: StateFlow = mutableSharedFlow.subscriptionCount + + override suspend fun emit(value: T) { + mutableSharedFlow.emit(value) + } + + @ExperimentalCoroutinesApi + override fun resetReplayCache() { + mutableSharedFlow.resetReplayCache() + } + + override fun tryEmit(value: T) = mutableSharedFlow.tryEmit(value) + + override val replayCache: List = mutableSharedFlow.replayCache + + override suspend fun collect(collector: FlowCollector): Nothing { + mutableSharedFlow.collect(collector) + } +} + +class ConcreteMutableStateFlow(private val mutableSharedFlow: MutableStateFlow) : MutableStateFlow, ConcreteMutableSharedFlow(mutableSharedFlow) { + override var value: T + get() = mutableSharedFlow.value + set(value) { + mutableSharedFlow.value = value + } + + override fun compareAndSet(expect: T, update: T) = mutableSharedFlow.compareAndSet(expect, update) +} diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/StateData.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/StateData.kt index 30aa963..09ca154 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/StateData.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/utils/StateData.kt @@ -10,14 +10,9 @@ typealias StateData = DataState fun stateDataPending(value: V? = null): StateData = DataState.pending(value) fun stateDataData(value: V): StateData = DataState.data(value) -fun stateDataError(error: Throwable, value: V? = null): StateData = DataState.error( - error, - value -) +fun stateDataError(error: Throwable, value: V? = null): StateData = DataState.error(error, value) -fun Flow>.flatMapLatestStateData( - getValueTransform: suspend (data: T) -> Flow> -): Flow> { +fun Flow>.flatMapLatestStateData(getValueTransform: suspend (data: T) -> Flow>): Flow> { return flatMapLatest { when (val prioritiseData = it.prioritiseData()) { is DataState.Data -> getValueTransform(prioritiseData.value) diff --git a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/ErrorViewModelImpl.kt b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/ErrorViewModelImpl.kt index 63419c6..6468f73 100644 --- a/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/ErrorViewModelImpl.kt +++ b/shared/src/commonMain/kotlin/com/mirego/kmp/boilerplate/viewmodel/common/ErrorViewModelImpl.kt @@ -19,13 +19,7 @@ class ErrorViewModelImpl( ) : ErrorViewModel, VMDViewModelImpl(coroutineScope) { companion object { - fun build( - i18N: I18N, - titleKey: KWordTranslation, - messageKey: KWordTranslation, - coroutineScope: CoroutineScope, - retryAction: () -> Unit - ) = ErrorViewModelImpl( + fun build(i18N: I18N, titleKey: KWordTranslation, messageKey: KWordTranslation, coroutineScope: CoroutineScope, retryAction: () -> Unit) = ErrorViewModelImpl( icon = SharedImageResource.errorPageIcon, title = i18N[titleKey], message = i18N[messageKey], 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 fcb6333..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,15 +39,22 @@ class ViewModelFactoryPreview( coroutineScope = createCoroutineScope() ) - override fun createProjects(coroutineScope: CoroutineScope) = createProjects( - PreviewState.Data.Content, - coroutineScope + 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() ) - fun createProjects(previewState: PreviewState, coroutineScope: CoroutineScope) = - ProjectsViewModelImpl( - projectsUseCase = useCaseFactoryPreview.projectsUseCase(previewState), - i18N = i18N, - viewModelFactory = this, - coroutineScope = coroutineScope - ) } 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 d638978..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,35 +2,55 @@ 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.impl.VMDListViewModelImpl +import com.mirego.trikot.viewmodels.declarative.components.VMDListViewModel import com.mirego.trikot.viewmodels.declarative.content.VMDIdentifiableContent -import com.mirego.trikot.viewmodels.declarative.viewmodel.VMDLifecycleViewModel -interface ProjectsViewModel : VMDLifecycleViewModel { +interface ProjectsViewModel : NavigationViewModel { + @Published - val rootContent: ProjectsRootContent? + val rootContent: ProjectsRoot? } -sealed interface ProjectsRootContent { +sealed interface ProjectsRoot { data class Content( - val items: VMDListViewModelImpl - ) : ProjectsRootContent - - data class Empty( - val empty: EmptyViewModel - ) : ProjectsRootContent + val sections: VMDListViewModel + ) : ProjectsRoot data class Error( - val error: ErrorViewModel - ) : ProjectsRootContent + val errorViewModel: ErrorViewModel + ) : ProjectsRoot +} + +sealed interface ProjectsContentSection : VMDIdentifiableContent { + data class Header( + val title: String, + val description: String + ) : ProjectsContentSection { + override val identifier: String = "Header" + } + + data class NoProjects( + val emptyViewModel: EmptyViewModel + ) : ProjectsContentSection { + override val identifier: String = "NoProjects" + } + + data class ProjectsList( + val viewModel: VMDListViewModel + ) : ProjectsContentSection { + override val identifier: String = "ProjectsList" + } } data class ProjectItem( override val identifier: String, val title: String, + 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 67b8e7f..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 ) { @@ -38,7 +45,7 @@ class ProjectsViewModelImpl( when (val prioritizedData = stateData.prioritiseData()) { is DataState.Data -> when (val data = prioritizedData.value) { is ProjectsViewData.Content -> buildData(data) - is ProjectsViewData.Empty -> buildEmpty() + is ProjectsViewData.Empty -> buildEmptyData() } is DataState.Error -> buildError() @@ -48,8 +55,31 @@ class ProjectsViewModelImpl( ) } - private fun buildData(viewData: ProjectsViewData.Content) = ProjectsRootContent.Content( - items = list( + private fun buildData(viewData: ProjectsViewData.Content) = ProjectsRoot.Content( + sections = list { + elements = listOf( + buildHeader(), + buildProjectList(viewData) + ) + } + ) + + private fun buildEmptyData() = ProjectsRoot.Content( + sections = list { + elements = listOf( + buildHeader(), + buildEmpty() + ) + } + ) + + private fun buildHeader() = ProjectsContentSection.Header( + i18N[KWordTranslation.PROJECTS_HEADER_TITLE], + i18N[KWordTranslation.PROJECTS_HEADER_DESCRIPTION] + ) + + private fun buildProjectList(viewData: ProjectsViewData.Content) = ProjectsContentSection.ProjectsList( + viewModel = list( elements = viewData.items.map { item -> item.toItem(isLoading = false) } @@ -59,16 +89,27 @@ class ProjectsViewModelImpl( private fun ProjectItemViewData.toItem(isLoading: Boolean) = ProjectItem( identifier = id, title = title, + subtitle = subtitle, description = description, image = remoteImage( imageUrl = imageUrl, placeholderImageResource = SharedImageResource.imagePlaceholder ), + tapAction = { + Analytics.trackViewProject(projectId = id) + navigateToProjectDetails( + ProjectDetailsNavigationData( + id = id, + backgroundColor = backgroundColor, + textColor = textColor + ) + ) + }, isLoading = isLoading ) - private fun buildEmpty() = ProjectsRootContent.Empty( - empty = EmptyViewModelImpl( + private fun buildEmpty() = ProjectsContentSection.NoProjects( + emptyViewModel = EmptyViewModelImpl( title = i18N[KWordTranslation.GENERIC_EMPTY_CONTENT_TITLE], message = i18N[KWordTranslation.PROJECTS_EMPTY_CONTENT_MESSAGE], actionButton = null, @@ -76,8 +117,8 @@ class ProjectsViewModelImpl( ) ) - private fun buildError() = ProjectsRootContent.Error( - error = ErrorViewModelImpl.build( + private fun buildError() = ProjectsRoot.Error( + errorViewModel = ErrorViewModelImpl.build( i18N = i18N, titleKey = KWordTranslation.GENERIC_ERROR_TITLE, messageKey = KWordTranslation.GENERIC_ERROR_MESSAGE, @@ -90,11 +131,18 @@ class ProjectsViewModelImpl( } ) - private fun buildLoading() = ProjectsRootContent.Content( - items = list( - elements = ProjectsUseCasePreview.buildPreviewItems().map { - it.toItem(isLoading = true) - } - ) + private fun buildLoading() = ProjectsRoot.Content( + sections = list { + elements = listOf( + buildHeader(), + ProjectsContentSection.ProjectsList( + viewModel = list( + elements = ProjectsUseCasePreview.buildPreviewItems().map { + it.toItem(isLoading = true) + } + ) + ) + ) + } ) } 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 50fb8ed..2887321 100644 --- a/shared/src/commonMain/resources/translations/translation.en.json +++ b/shared/src/commonMain/resources/translations/translation.en.json @@ -3,5 +3,9 @@ "generic_error_message": "An unfortunate error occurred", "generic_error_title": "Oops...", "generic_retry": "Retry", - "projects_empty_content_message": "There are no projects to show" + "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", + "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 4bb1791..4d00577 100644 --- a/shared/src/commonMain/resources/translations/translation.fr.json +++ b/shared/src/commonMain/resources/translations/translation.fr.json @@ -3,5 +3,9 @@ "generic_error_message": "Une erreur s'est malheureusement produite", "generic_error_title": "Oops...", "generic_retry": "Réessayer", - "projects_empty_content_message": "Il n'y a aucun projets de disponible" + "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", + "project_details_project_type": "Type", + "project_details_release_year": "Année de lancement" } diff --git a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/testutils/BaseTest.kt b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/testutils/BaseTest.kt new file mode 100644 index 0000000..41854b0 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/testutils/BaseTest.kt @@ -0,0 +1,16 @@ +package com.mirego.kmp.boilerplate.testutils + +import com.mirego.trikot.kword.I18N +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.setMain + +open class BaseTest { + val i18N: I18N = I18NMock(withArgs = true) + val testCoroutineScope = CoroutineScope(Dispatchers.Unconfined) + + init { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } +} diff --git a/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/testutils/I18NMock.kt b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/testutils/I18NMock.kt new file mode 100644 index 0000000..ae9dc3c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/testutils/I18NMock.kt @@ -0,0 +1,38 @@ +package com.mirego.kmp.boilerplate.testutils + +import com.mirego.trikot.kword.I18N +import com.mirego.trikot.kword.KWordKey +import com.mirego.trikot.kword.KWordSource + +class I18NMock(var withArgs: Boolean = false) : I18N { + + override fun changeLocaleStrings(source: KWordSource) { + } + + override fun changeLocaleStrings(strings: Map) { + } + + override fun get(key: KWordKey): String { + return key.translationKey + } + + override fun t(key: KWordKey): String { + return key.translationKey + } + + override fun t(key: KWordKey, vararg arguments: Pair): String { + return if (withArgs) { + key.translationKey + "[${arguments.joinToString { "${it.first}: '${it.second}'" }}]" + } else { + key.translationKey + } + } + + override fun t(key: KWordKey, count: Int, vararg arguments: Pair): String { + return if (withArgs) { + "${key.translationKey} $count [${arguments.joinToString { "'$it'" }}]" + } else { + "${key.translationKey} $count" + } + } +} 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 new file mode 100644 index 0000000..3f610de --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/usecase/ProjectsUseCaseTestImpl.kt @@ -0,0 +1,81 @@ +package com.mirego.kmp.boilerplate.usecase + +import com.mirego.kmp.boilerplate.ProjectsQuery +import com.mirego.kmp.boilerplate.repository.projects.ProjectsRepository +import com.mirego.kmp.boilerplate.testutils.BaseTest +import com.mirego.kmp.boilerplate.usecase.projects.ProjectsUseCaseImpl +import com.mirego.kmp.boilerplate.usecase.projects.ProjectsViewData +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 + +class ProjectsUseCaseTestImpl : BaseTest() { + private val repository = mockk() + private val useCase by lazy { + ProjectsUseCaseImpl( + repository + ) + } + + @Test + fun `when there are no projects returned then ProjectsViewData Empty is returned`() = runTest { + every { repository.projects() } returns flowOf(stateDataData(emptyList())) + val cart = useCase.projects().first().value() as ProjectsViewData + + assertTrue { + cart is ProjectsViewData.Empty + } + } + + @Test + fun `when multiple projects are returned then ProjectsViewData Content is returned`() = runTest { + every { repository.projects() } returns flowOf( + stateDataData( + listOf( + mockItem(), + mockItem(), + mockItem() + ) + ) + ) + val cart = useCase.projects().first().value() as ProjectsViewData + + assertTrue { + cart is ProjectsViewData.Content + } + } + + @Test + fun `when one project is returned then ProjectsViewData Content is returned`() = runTest { + every { repository.projects() } returns flowOf( + stateDataData( + listOf( + mockItem() + ) + ) + ) + val cart = useCase.projects().first().value() as ProjectsViewData + + assertTrue { + cart is ProjectsViewData.Content + } + } + + private fun mockItem() = ProjectsQuery.Data.PagePage.ProjectsListBlock.Projects.Entry( + pageSlug = "work/mirego", + name = "Mirego Projects", + projectType = "Application", + 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 new file mode 100644 index 0000000..be06bd6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/mirego/kmp/boilerplate/viewmodel/ProjectsViewModelImplTest.kt @@ -0,0 +1,65 @@ +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 +import com.mirego.kmp.boilerplate.utils.stateDataData +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 kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest + +class ProjectsViewModelImplTest : BaseTest() { + private val useCase = mockk() + private val viewModelFactory = mockk() + + private val viewModel by lazy { + ProjectsViewModelImpl( + useCase, + i18N, + viewModelFactory, + testCoroutineScope + ) + } + + @Test + fun `when an Empty view data is returned then proper sections are displayed`() = runTest { + val viewData = ProjectsViewData.Empty + every { useCase.projects() } returns flowOf(stateDataData(viewData)) + + val sections = (viewModel.rootContent as ProjectsRoot.Content).sections.elements + assertTrue { sections[0] is ProjectsContentSection.Header } + assertTrue { sections[1] is ProjectsContentSection.NoProjects } + } + + @Test + fun `when an Content view data is returned then proper sections are displayed`() = runTest { + val viewData = ProjectsViewData.Content( + listOf( + ProjectItemViewData( + id = "id", + title = "title", + subtitle = "subtitle", + description = "description", + imageUrl = "imageUrl", + backgroundColor = "000000".toVMDColor() ?: VMDColor.None, + textColor = "FFFFFF".toVMDColor() ?: VMDColor.None + ) + ) + ) + every { useCase.projects() } returns flowOf(stateDataData(viewData)) + + val sections = (viewModel.rootContent as ProjectsRoot.Content).sections.elements + assertTrue { sections[0] is ProjectsContentSection.Header } + assertTrue { sections[1] is ProjectsContentSection.ProjectsList } + } +} diff --git a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/app/utils/ByteArrayNativeUtils.kt b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/app/utils/ByteArrayNativeUtils.kt new file mode 100644 index 0000000..cfbb70f --- /dev/null +++ b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/app/utils/ByteArrayNativeUtils.kt @@ -0,0 +1,33 @@ +package com.mirego.kmp.boilerplate.app.utils + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.create +import platform.posix.memcpy + +object ByteArrayNativeUtils { + @OptIn(ExperimentalForeignApi::class) + @ExperimentalUnsignedTypes + fun convert(data: NSData): ByteArray { + return data.bytes?.let { bytes -> + ByteArray(data.length.toInt()).apply { + usePinned { pinned -> + memcpy(pinned.addressOf(0), bytes, data.length) + } + } + } ?: ByteArray(0) + } + + @OptIn(ExperimentalForeignApi::class) + @ExperimentalUnsignedTypes + fun convert(byteArray: ByteArray): NSData { + return byteArray.usePinned { + NSData.create( + bytes = it.addressOf(0), + length = byteArray.size.toULong() + ) + } + } +} diff --git a/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/app/utils/FlowProvider.kt b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/app/utils/FlowProvider.kt new file mode 100644 index 0000000..b89434c --- /dev/null +++ b/shared/src/iosMain/kotlin/com/mirego/kmp/boilerplate/app/utils/FlowProvider.kt @@ -0,0 +1,23 @@ +package com.mirego.kmp.boilerplate.app.utils + +import com.mirego.kmp.boilerplate.utils.ConcreteFlow +import com.mirego.kmp.boilerplate.utils.ConcreteMutableSharedFlow +import com.mirego.kmp.boilerplate.utils.ConcreteMutableStateFlow +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +class FlowProvider { + + fun flowOf(value: T): ConcreteFlow = ConcreteFlow(kotlinx.coroutines.flow.flowOf(value)) + + fun flowOf(vararg elements: T): Flow = ConcreteFlow(kotlinx.coroutines.flow.flowOf(*elements)) + + fun mutableSharedFlow(): ConcreteMutableSharedFlow = ConcreteMutableSharedFlow(MutableSharedFlow()) + + fun mutableSharedFlow(replay: Int = 0, extraBufferCapacity: Int = 0, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND): ConcreteMutableSharedFlow = + ConcreteMutableSharedFlow(MutableSharedFlow(replay, extraBufferCapacity, onBufferOverflow)) + + fun mutableStateFlow(initialValue: T): ConcreteMutableStateFlow = ConcreteMutableStateFlow(MutableStateFlow(initialValue)) +}