diff --git a/.github/workflows/an_pr_build.yml b/.github/workflows/an_pr_build.yml index 3697eb60..69829062 100644 --- a/.github/workflows/an_pr_build.yml +++ b/.github/workflows/an_pr_build.yml @@ -3,6 +3,14 @@ name: Android – PR Check on: pull_request: types: [ opened, synchronize ] + paths: + - 'android/**' + - 'shared/**' + - '.github/workflows/an_pr_build.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: lint: @@ -21,22 +29,21 @@ jobs: run: | ./gradlew ktlintCheck - - name: Run architecture lint + - name: Run architecture tests + run: | + ./gradlew :shared:umbrella:testAlphaDebugUnitTest --tests "konsistTest.*" --no-build-cache --no-configuration-cache + + - name: Run tests run: | - ./gradlew :shared:core:testAlphaDebugUnitTest --tests "konsistTest.*" --no-build-cache --no-configuration-cache + ./gradlew testAlphaDebugUnitTest --no-build-cache --no-configuration-cache - name: Publish Test Report uses: mikepenz/action-junit-report@v4 if: success() || failure() with: - report_paths: '**/build/test-results/testAlphaDebugUnitTest/TEST-*.xml' + report_paths: '**/build/test-results/**/TEST-*.xml' - name: Build run: | ./gradlew generateTwine ./gradlew :android:app:bundleDebug - -# - name: UI tests -# run: | -# start_emu_headless.sh -# ./gradlew :android:app:connectedCheck diff --git a/.github/workflows/ios-develop.yml b/.github/workflows/ios_develop.yml similarity index 100% rename from .github/workflows/ios-develop.yml rename to .github/workflows/ios_develop.yml diff --git a/.github/workflows/ios_pr_build.yml b/.github/workflows/ios_pr_build.yml index 1e8b255c..efbe2c7f 100644 --- a/.github/workflows/ios_pr_build.yml +++ b/.github/workflows/ios_pr_build.yml @@ -3,6 +3,14 @@ name: iOS – PR check on: pull_request: types: [ opened, synchronize ] + paths: + - 'ios/**' + - 'shared/**' + - '.github/workflows/an_pr_build.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: test: diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios_release.yml similarity index 100% rename from .github/workflows/ios-release.yml rename to .github/workflows/ios_release.yml diff --git a/ProjectStructure.png b/ProjectStructure.png deleted file mode 100644 index 7d5f64cf..00000000 Binary files a/ProjectStructure.png and /dev/null differ diff --git a/ProjectStructure.puml b/ProjectStructure.puml deleted file mode 100644 index 53fc555e..00000000 --- a/ProjectStructure.puml +++ /dev/null @@ -1,63 +0,0 @@ -@startuml -'https://plantuml.com/component-diagram -skinparam componentStyle rectangle - -skinparam component { - BackgroundColor<> #3DDC84 - BorderColor<> #0B4E29 - FontColor<> #white - BackgroundColor<> #7F52FF - BorderColor<> #312161 -} - -package "android" #B2F1CE { - [:android:app] <> - [:android:sample] <> - [:android:samplesharedviewmodel] <> - [:android:samplecomposemultiplatform] <> - [:android:shared] <> -} - -package "shared" #C9B6FF { - [:shared:base] <> - [:shared:core] <> - [:shared:sample] <> - [:shared:samplesharedviewmodel] <> - [:shared:samplecomposemultiplatform] <> - [:shared:samplecomposenavigation] <> -} - -interface "Android Application" #3DDC84 - -"Android Application" <.. [:android:app] -[:android:app] --> [:android:sample] -[:android:app] --> [:android:samplesharedviewmodel] -[:android:app] --> [:android:samplecomposemultiplatform] - -[:android:sample] -left-> [:android:shared] -[:android:samplesharedviewmodel] -left-> [:android:shared] -[:android:samplecomposemultiplatform] -left-> [:android:shared] - -[:android:app] -down-> [:shared:core] -interface "iOS XCFramework (KMPShared)" #007AFF -"iOS XCFramework (KMPShared)" <.left. [:shared:core] - -[:android:sample] --> [:shared:core] -[:android:samplesharedviewmodel] --> [:shared:core] -[:android:samplecomposemultiplatform] --> [:shared:core] - -[:shared:core] --> [:shared:sample] -[:shared:core] --> [:shared:samplesharedviewmodel] -[:shared:core] --> [:shared:samplecomposemultiplatform] -[:shared:core] --> [:shared:samplecomposenavigation] - -[:shared:sample] --> [:shared:base] -[:shared:samplesharedviewmodel] --> [:shared:base] -[:shared:samplecomposemultiplatform] --> [:shared:base] -[:shared:samplecomposenavigation] --> [:shared:base] - -[:shared:samplesharedviewmodel] -left-> [:shared:sample] -[:shared:samplecomposemultiplatform] -left-> [:shared:samplesharedviewmodel] -[:shared:samplecomposenavigation] -left-> [:shared:samplesharedviewmodel] -[:shared:samplecomposenavigation] -left-> [:shared:samplecomposemultiplatform] -@enduml \ No newline at end of file diff --git a/README.md b/README.md index ffe8c43d..c9324b55 100644 --- a/README.md +++ b/README.md @@ -3,127 +3,392 @@ ## Use Matee Starter as a base for a new project - Use this repository as a template when creating a new repository for your project -- Rename the project (don't forget to change `rootProject.name` in `settings.gradle` and `id` - and `AppName` in `Application.kt`) -- Rename iOS project - you can use prepared script, for more info see +- Run the rename script to rename the project: `./scripts/rename-project.sh` (this handles Android and shared modules) +- Rename iOS project - you can use the prepared script in `ios/scripts/rename.sh`, for more info see the [iOS readme](./ios/README.md) ## About This repo contains our template for multiplatform mobile project. Both Android and iOS -implementations -are present with shared modules containing all common business logic organized in Clean -architecture. -The project contains four sample screens: - -- one with **native UI** and **native view model** (for Android Compose UI and view model in Kotlin, - for iOS - SwiftUI and view model in Swift) -- second one with **native** (Compose and SwiftUI) **UI** and **shared view model** -- the third one with **shared Compose Multiplatform UI** and **shared view models** -- and the last one with **shared Compose Multiplatform UI** and **shared view models** and also * - *compose mutliplatform navigation** +implementations are present with shared modules containing all common business logic organized in Clean +architecture. + +**By default, everything up to UI is shared between platforms:** +- **Data layer** (Repositories, Sources) +- **Domain layer** (Use Cases, Domain Models) +- **Presentation layer** (View Models, Compose Multiplatform UI) + +**Only navigation is native** - each platform implements its own navigation layer to integrate shared screens into the native navigation structure. + +The project contains a **sample feature module** (`samplefeature`) that demonstrates how to structure a complete feature with: +- Infrastructure services (networking) +- Data sources and repositories +- Domain use cases +- Shared view models +- Compose Multiplatform UI +- Native navigation integration ## Architecture -Clean (common modules) + MVVM (platform-specific modules) architecture is used for its testability -and ease of *modularization*. +Clean Architecture + MVVM is used for its testability and ease of modularization. Code is divided into several layers: -- infrastructure (`Source`) -- data (`Repository`) -- domain (`UseCase`) +- **Data** (`Service`, `Source`, `Repository`) - Data access layer including HTTP services, data sources, and repositories +- **Domain** (`UseCase`, `Model`) - Business logic +- **Presentation** (`ViewModel`, `UI`) - Shared view models and Compose Multiplatform UI + +Navigation is handled platform-specifically to integrate shared screens into native navigation structures. ![Diagram](ProjectStructure.png) -### Shared +### Shared Modules -Shared modules in general handle networking, persistence and contain UseCases which bridge platform -specific code with common code. +#### `:shared:base` +Contains all base classes and common utilities needed across feature modules: +- **Base View Models**: `BaseViewModel`, `BaseScopedViewModel` for shared view models +- **Base Use Cases**: `UseCaseResult`, `UseCaseResultNoParams`, `UseCaseFlowResult` interfaces +- **Base Models**: `Result`, `ErrorResult` for error handling +- Common utilities, error handling, and infrastructure providers -There is `:shared:core` that combines feature modules and core and generates `XCFramework` for iOS -and is the main module imported in android modules. +#### `:shared:auth` +Authentication module providing: +- **AuthService**: Service for authentication operations (e.g., logout) +- **TokenRefresher**: Interface for token refresh functionality +- **MockTokenRefresher**: Mock implementation that returns a mock token (⚠️ **must be replaced with a real implementation**) -`:shared:base` is a module containing all the base and common classes needed in feature modules. +> **⚠️ Important**: The `MockTokenRefresher` in `AuthModule.kt` is a placeholder implementation that returns a hardcoded "mockToken". You **must** replace it with a real `TokenRefresher` implementation that uses your authentication service (FirebaseAuth, Auth0, etc.) to refresh tokens. See the [Implementing Token Refresh](#implementing-token-refresh) section below. -Structure inside each module is organized with Clean architecture in mind to several layers of -abstraction where everything in domain or data layer is marked as `internal` to prevent confusion. +#### `:shared:analytics` +Analytics module providing analytics tracking functionality across platforms. -> The whole project relies heavily on dependency injection +#### `:shared:umbrella` +Combines all feature modules and generates `Framework` for iOS. This is the main module imported in Android modules and provides the Koin initialization. -### Android +#### `:shared:samplefeature` +A complete example feature demonstrating: +- **Service**: `JokeService` - Ktor-based networking service (data layer) +- **Data Source**: `JokeSource` interface and `JokeSourceImpl` - abstraction over services +- **Repository**: `JokeRepository` interface and `JokeRepositoryImpl` - domain data access +- **Use Case**: `GetRandomJokeUseCase` - business logic +- **View Model**: `SampleFeatureViewModel` extending `BaseViewModel` +- **UI**: `SampleFeatureMainScreen` - Compose Multiplatform screen -Android code is separated into several feature-modules with `android:app` module providing -navigation root and `android:shared` module containing shared android code like common components or -values. Following standards the Android-specific modules use MVVM architecture where ViewModels use -UseCases as gateway to shared *model* layer (in case you use native view models). +### Android Modules + +#### `:android:app` +Main application module providing: +- Application entry point +- Root navigation setup +- Koin initialization + +#### `:android:shared` +Shared Android code like common navigation components and utilities. + +#### `:android:samplefeature` +Native navigation integration for the sample feature, demonstrating how to integrate shared Compose Multiplatform screens into Android navigation. ### iOS - More info in the [iOS readme](./ios/README.md) -## Sharing options +> **Note:** The whole project relies heavily on dependency injection (Koin for shared/Android, Factory for iOS) + +## Creating a new feature + +To create a new feature, follow the structure demonstrated in `samplefeature`: + +### 1. Create the shared feature module + +1. Create a new module in `shared/` (e.g., `shared/myfeature`) +2. Copy `build.gradle.kts` from `shared/samplefeature` and update the `namespace` +3. Add the module to `settings.gradle.kts`: + ```kotlin + include(":shared:myfeature") + ``` +4. Add dependency in `shared/umbrella/build.gradle.kts`: + - **If you don't need to use the module from iOS code**, use `commonMainImplementation`: + ```kotlin + commonMainImplementation(project(":shared:myfeature")) + ``` + - **If you need to use the module from iOS code**, use `commonMainApi` and also add it to `KmpConfig`: + ```kotlin + commonMainApi(project(":shared:myfeature")) + ``` + Then add it to the export list in `build-logic/convention/src/main/kotlin/config/KmpConfig.kt`: + ```kotlin + export(project(":shared:myfeature")) + ``` +5. Add the DI module to `shared/umbrella/src/commonMain/kotlin/kmp/shared/umbrella/di/Module.kt`: + ```kotlin + modules( + baseModule, + // ... other modules + myFeatureModule, + ) + ``` + +### 2. Structure your feature module + +Follow the Clean Architecture layers: + +``` +shared/myfeature/src/commonMain/kotlin/kmp/shared/myfeature/ +├── data/ +│ ├── model/ # DTOs (Data Transfer Objects) +│ ├── service/ # HTTP services (Ktor clients) +│ ├── source/ # Data source interfaces and implementations +│ └── repository/ # Repository implementations +├── domain/ +│ ├── model/ # Domain models +│ ├── repository/ # Repository interfaces +│ └── usecase/ # Use case interfaces and implementations +├── presentation/ +│ ├── vm/ # View models (extend BaseViewModel) +│ └── ui/ # Compose Multiplatform screens +└── di/ + └── Module.kt # Koin module +``` + +### 3. Create base classes + +- **View Models**: Extend `BaseViewModel` where: + - `S` is your state (implements `VmState`) + - `I` is your intent (implements `VmIntent`) + - `E` is your event (implements `VmEvent`) + +- **Use Cases**: Implement one of the following interfaces: + - `UseCaseResult` or `UseCaseResultNoParams` - for single-shot operations returning `Result` + - `UseCaseFlow` or `UseCaseFlowNoParams` - for operations returning `Flow` + - `UseCaseFlowResult` or `UseCaseFlowResultNoParams` - for operations returning `Flow>` + +- **Repositories**: Define interface in `domain/repository/`, implement in `data/repository/` + +- **Sources**: Define interface in `data/source/`, implement in `data/source/impl/` + +### 4. Create Android navigation module + +1. Create `android/myfeature` module +2. Copy `build.gradle.kts` from `android/samplefeature` +3. Add to `settings.gradle.kts`: + ```kotlin + include(":android:myfeature") + ``` +4. Add dependency in `android/app/build.gradle.kts`: + ```kotlin + implementation(project(":android:myfeature")) + ``` +5. Create navigation structure: + + **Create a FeatureGraph** (e.g., `MyFeatureGraph.kt`): + - Extend `FeatureGraph` and define all screens (destinations) for this feature as `Destination` objects + - Each `Destination` can define navigation arguments using the `arguments` property + - For destinations with arguments, create an `Args` class to extract them from `NavBackStackEntry` + - The graph can have a parent graph for nested navigation + ```kotlin + import android.os.Bundle + import androidx.navigation.NavType + import androidx.navigation.navArgument + import kmp.android.shared.navigation.Destination + import kmp.android.shared.navigation.FeatureGraph + + object MyFeatureGraph : FeatureGraph(parent = null) { + override val path = "myFeature" + + data object Main : Destination(this) { + override val routeDefinition: String = "main" + } + + data object Detail : Destination(this) { + override val routeDefinition: String = "detail" + private const val ItemIdArg = "itemId" + + override val arguments: List = listOf( + navArgument(ItemIdArg) { + type = NavType.StringType + } + ) + + internal class Args( + val itemId: String = "", + ) { + constructor(arguments: Bundle?) : this( + arguments?.getString(ItemIdArg) ?: "", + ) + } + } + } + ``` + + **Create a NavGraphBuilder extension** (e.g., `MyFeatureNavigation.kt`): + - This extension function should contain all navigation nodes (routes) for the feature + - It receives `NavHostController` and creates navigation callbacks to pass down to route extensions + - Call all route extension functions within the navigation block, passing callbacks as needed + ```kotlin + fun NavGraphBuilder.myFeatureNavGraph( + navHostController: NavHostController, + ) { + navigation( + startDestination = MyFeatureGraph.Main.route, + route = MyFeatureGraph.rootPath, + ) { + myFeatureMainRoute( + onNavigateToDetail = { itemId -> + navHostController.navigate(MyFeatureGraph.Detail(itemId)) + } + ) + myFeatureDetailRoute( + onBack = { navHostController.popBackStack() } + ) + // Add all routes for this feature + } + } + ``` + + You can also create `NavController` extension functions for navigation: + ```kotlin + import androidx.navigation.NavController + + internal fun NavController.navigateToMyFeatureDetail(itemId: String = "") { + navigate(MyFeatureGraph.Detail(itemId)) + } + ``` + + **Create Route composables** (e.g., `MyFeatureMain.kt`): + - Use the `composableDestination` extension (or `bottomSheetDestination`, `dialogDestination` for other types) + - The route extension should only receive navigation callbacks as parameters (not `NavHostController` directly) + - Extract navigation arguments using the `Args` class from the destination + - The Route composable can accept navigation callbacks as parameters + - Handle view model state, events, and integrate your shared Compose Multiplatform screen + ```kotlin + fun NavGraphBuilder.myFeatureDetailRoute( + onBack: () -> Unit, + ) { + composableDestination( + destination = MyFeatureGraph.Detail, + ) { navBackStackEntry -> + val args = MyFeatureGraph.Detail.Args(navBackStackEntry.arguments) + + MyFeatureDetailRoute( + itemId = args.itemId, + onBack = onBack, + ) + } + } + + @Composable + internal fun MyFeatureDetailRoute( + itemId: String, + viewModel: MyFeatureViewModel = koinViewModel(), + onBack: () -> Unit, + ) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = viewModel) { + viewModel.events.collectLatest { event -> + when (event) { + is MyFeatureEvent.NavigateBack -> onBack() + } + } + } + + MyFeatureDetailScreen( + itemId = itemId, + state = state, + onIntent = { viewModel.onIntent(it) }, + ) + } + ``` + + **Add the nav graph to root navigation** in `android/app/src/main/kotlin/kmp/android/ui/Root.kt`: + ```kotlin + NavHost(navController, startDestination = ...) { + myFeatureNavGraph(navController) + // ... other nav graphs + } + ``` + + The `Destination` class and `NavGraphBuilder` extensions (`composableDestination`, `bottomSheetDestination`, `dialogDestination`) are provided in `android/shared` module and handle route construction with arguments automatically. See the `Destination` and `FeatureGraph` abstract classes for details on how to define navigation arguments. + +### 5. Integrate on iOS + +Add your feature's shared screen to iOS navigation. See the iOS README for details. + +## Tests + +### Android -As mentioned above, there are three options for how much code you want to share between platforms. +There are UI tests prepared in `android/app/androidTest`. You can take +inspiration and write tests for your own screens with the prepared structure and extensions. -### Native UI and view models +## Android Build Variants -If you choose not to share view models neither UI, you can go ahead and -delete `samplesharedviewmodels` and `samplecomposemultiplatform` modules in both `shared` -and `android` and `samplecomposenavigation` only in `shared` and create own repositories, use cases, -sources, ... according to what you find in the `:shared:sample` module and UI and view models -according to `:android:sample`. You can also remove compose multiplatform plugin from -`libs.versions.toml`. +The project uses Android build variants with two dimensions: -### Native UI and shared view models +1. **Build Type** (debug/release): + - `debug` - Development builds with debug signing + - `release` - Release builds with release signing -If you choose to share view models, but use native UI, you can delete -the `samplecomposemultiplatform` module in both `shared`and `android` and `samplecomposenavigation` -only in `shared`. You can also remove compose multiplatform plugin from `libs.versions.toml`. -Refactor base classes that you will need: move `samplesharedviewmodel/base` files in -`:shared:samplesharedviewmodel` to the `:shared:base` module (from `commonMain` as well as `iosMain` -and `androidMain`) to have base classes you can extend. Also in iOS project move -`SampleSharedViewModel/Toolkit` to `UIToolkit`. Then you can write your shared view models in the -same way as the `SampleSharedViewModel` is written and used (especially check the usage on iOS with -helpful extension methods). +2. **API Variant** (alpha/production): + - `alpha` - Connected to alpha/staging data sources (app name prefixed with "[A]") + - `production` - Connected to production data sources -If you do not want to use shared view models inside SwiftUI views, you can remove the `expect` from -`BaseScopedViewModel` (and the `actual` class), the -whole `BaseIntentViewModel` interface and the whole `SwiftViewModelCoroutines`, to simplify the base -view model. +Available build variants: +- `alphaDebug` - Alpha API with debug build +- `alphaRelease` - Alpha API with release build +- `productionDebug` - Production API with debug build +- `productionRelease` - Production API with release build -### Shared UI and view models +Build specific variants: +```bash +# Build alpha debug variant +./gradlew assembleAlphaDebug -If you go all out and decide to share both UI and view models, take inspiration -from `SampleComposeMultiplatformScreenViewController` when calling you view from the swift code. For -Android there are no changes needed, see in `:android:samplecomposemultiplatform` for yourself. -You can move the classes in `ui/test` to shared module. +# Build production release variant +./gradlew assembleProductionRelease +``` -### Shared material navigation +## Convention Plugins -Starting with `Compose multiplatform 1.7.0` (in combination with `androidx navigation-compose` and -`compose material-navigation` libraries) we can use Material navigation in multiplatform. You can -see a minimal example in `:shared:samplecomposenavigation`. +The project uses Gradle convention plugins (located in `build-logic/convention`) to standardize build configuration across modules. These plugins automatically apply common configurations, dependencies, and settings. -> **Beware:** Using libraries mentioned above breaks swipe back navigation when using compose -> multiplatform views inside UIKit navigation on iOS, so in case you want it working, downgrade -`compose multiplatform` to 1.6.11 and remove `androidx navigation-compose` and -`compose material-navigation` libraries. +### Available Convention Plugins -## Creating new feature module in shared +#### Android Modules +- **`android-application-compose`** - For Android application modules with Compose support + - Applies Android application plugin, Compose compiler, and Compose dependencies + - Configures build variants (alpha/production), signing, and Twine string generation +- **`android-application-core`** - For Android application modules without Compose + - Same as above but without Compose configuration +- **`android-library-compose`** - For Android library modules with Compose support + - Applies Android library plugin and Compose dependencies +- **`android-library-core`** - For Android library modules without Compose + - Applies Android library plugin with standard Android configuration -- Create a new module and copy `build.gradle.kts` content from one of the existing modules, change - `namespace` and dependencies on other modules as needed -- Add dependency to `settings.gradle.kts`, `build.gradle.kts` of `:shared:core` and `.kmm()` - in `KmmConfig` in `build-logic` -- Add DI module to `Module` in `:shared:core` +#### Kotlin Multiplatform Modules +- **`kmp-library-core`** - For KMP library modules + - Configures Kotlin Multiplatform with Android and iOS targets + - Applies Moko Resources for shared string resources + - Sets up common dependencies and test configuration +- **`kmp-library-compose`** - For KMP library modules with Compose Multiplatform + - Extends `kmp-library-core` and adds Compose Multiplatform support + - Configures Compose compiler and dependencies +- **`kmp-framework-library`** - For KMP modules that generate iOS frameworks + - Extends `kmp-library-core` and configures iOS framework generation + - Used by `:shared:umbrella` module to generate the Framework for iOS -## Tests +### Usage -### Android +Simply apply the convention plugin in your module's `build.gradle.kts`: + +```kotlin +plugins { + alias(libs.plugins.mateeStarter.android.application.compose) + // or + alias(libs.plugins.mateeStarter.kmp.library.compose) +} +``` -There are UI tests prepared for all three screens in `android/app/androidTest`. You can take -inspiration and write tests for own screens with prepared structure and extensions. +The plugin IDs are defined in `gradle/libs.versions.toml` and can be customized after renaming the project. ## Technologies @@ -145,6 +410,54 @@ Accessing network is usually the most used IO operation for mobile apps so Ktor simple and extensible API and because it's multiplatform capable with different engines for each platform. +### Authentication & Token Storage + +The project includes secure token storage for authentication tokens. The `AuthProvider` interface and its implementation (`AuthProviderImpl`) handle token storage and refresh logic. + +#### How Tokens Are Stored + +Tokens are stored securely using platform-specific secure storage mechanisms: + +- **Android**: Tokens are encrypted using `SecureSharedPreferences`, which uses Android KeyStore with AES/GCM encryption. The encryption key is stored in the hardware-backed Android KeyStore, ensuring tokens are protected at rest. +- **iOS**: Tokens are stored in the iOS Keychain. + +The `AuthProvider` interface provides: +- `token: String?` - Property to get/set the current authentication token +- `refreshToken(): String?` - Suspending function to refresh the token when it expires + +#### Who Should Store Tokens + +You should use **FirebaseAuth** or any other authentication service (e.g., Auth0, AWS Cognito, custom backend) to handle user authentication and receive tokens. Once you receive the authentication token from your authentication service, store it in the `AuthProvider`. + +The `AuthProvider` is responsible for securely storing and managing authentication tokens. It's configured in the dependency injection modules: + +- **Android**: Configured in `BaseModule.android.kt` using `SharedPreferencesFactory` with `SharedPreferencesType.ENCRYPTED` to ensure secure storage. +- **iOS**: Configured in `BaseModule.ios.kt` using `KeychainFactory` to store tokens in the iOS Keychain. + +The `HttpClient` is automatically configured to use `AuthProvider` for bearer token authentication, including automatic token refresh when requests fail with 401 Unauthorized. + +#### Implementing Token Refresh + +> **⚠️ Important**: The project includes a `MockTokenRefresher` in `:shared:auth` module that returns a hardcoded mock token. This is only for development/testing purposes and **must be replaced** with a real implementation before production. + +The `AuthProviderImpl` requires a `TokenRefresher` implementation to handle token refresh. You **must** replace the `MockTokenRefresher` with a real implementation: + +**1. Create your `TokenRefresher` implementation:** + +Use your authentication service (FirebaseAuth, Auth0, etc.) to refresh the token. + +**2. Replace `MockTokenRefresher` in `AuthModule.kt`:** + +Update `shared/auth/src/commonMain/kotlin/kmp/shared/auth/di/AuthModule.kt` to use your implementation: + +**1. Implement the `TokenRefresher` interface:** + +Use your authentication service (FirebaseAuth, Auth0, etc.) to refresh the token. + +**2. Provide the `TokenRefresher` in your DI modules:** + +The `AuthProviderImpl` will automatically use the provided `TokenRefresher` when `refreshToken()` is called, ensuring that concurrent refresh requests are deduplicated and handled efficiently. After refreshing, the new token will be automatically stored in `AuthProvider` and used for subsequent API requests. + ### Resources #### Twine @@ -163,22 +476,19 @@ string messages. Error strings are stored in the `twine/errors.txt` file. Gradle `generateErrorsTwine` first generates `strings.xml` files from `errors.txt` and then gradle task `generateMRCommonMain` generates `MR` class that can be used in the common code. -### UI - Jetpack Compose - -#### Android - -**Jetpack Compose** is the go to for Android UI nowadays. +### UI - Compose Multiplatform -#### iOS +We use **Compose Multiplatform** for both Android and iOS. The UI is written once in shared modules and works on both platforms. -We recommend going with **SwiftUI**, unless you want to for some views or screens use Compose -Multiplatform (below) +For platform-specific UI components that need native implementations, we use `expect`/`actual` declarations: +- Define `expect` declarations in `commonMain` for platform-specific views +- Implement `actual` declarations in platform-specific source sets (`androidMain`/`iosMain`) -#### Shared +On iOS, `expect` views can be implemented via: +- **CInterop** - for C-based native libraries +- **Swift** - using either UIKit or SwiftUI, which are then integrated into Compose via Factory pattern -**Compose Multiplatform** (from Jetbrains) is still young, but you can try it out and for some -simple screens (or maybe whole simple projects) it might be the right choice. It can save a lot of -time since each view will be written only once and used on both platforms. +The Factory pattern allows Swift implementations to be provided to Compose Multiplatform code, enabling seamless integration of native iOS UI components when needed. ### iOS diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c1827d7c..c4cebff9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,9 +8,7 @@ android { } dependencies { - implementation(project(":shared:core")) + implementation(project(":shared:umbrella")) implementation(project(":android:shared")) - implementation(project(":android:sample")) - implementation(project(":android:samplesharedviewmodel")) - implementation(project(":android:samplecomposemultiplatform")) + implementation(project(":android:samplefeature")) } diff --git a/android/app/src/androidTest/kotlin/kmp/android/screen/SampleComposeMultiplatformScreen.kt b/android/app/src/androidTest/kotlin/kmp/android/screen/SampleComposeMultiplatformScreen.kt deleted file mode 100644 index fb1e59af..00000000 --- a/android/app/src/androidTest/kotlin/kmp/android/screen/SampleComposeMultiplatformScreen.kt +++ /dev/null @@ -1,61 +0,0 @@ -package kmp.android.screen - -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.rules.ActivityScenarioRule -import kmp.android.extension.hasTestTag -import kmp.android.extension.onNodeWithTag -import kmp.android.extension.ShortDuration -import kmp.android.extension.waitUntilExactlyOneExists -import kmp.shared.samplecomposenavigation.presentation.ui.test.TestTags - -internal interface SampleComposeMultiplatformScreen : Screen { - fun checkContentTextVisible() - fun checkSampleTextVisible() - fun checkButtonClick() -} - -private class SampleComposeMultiplatformScreenImpl( - private val testRule: AndroidComposeTestRule, A>, -) : SampleComposeMultiplatformScreen { - - override fun checkContentTextVisible() { - with(testRule) { - waitUntilExactlyOneExists(hasText("This is a sample with compose multiplatform UI and shared VM"), ShortDuration) - - onNodeWithText("This is a sample with compose multiplatform UI and shared VM") - .assertIsDisplayed() - } - } - - override fun checkSampleTextVisible() { - with(testRule) { - waitUntilExactlyOneExists(hasTestTag(TestTags.SampleComposeMultiplatformScreen.SampleText), ShortDuration) - - onNodeWithTag(TestTags.SampleComposeMultiplatformScreen.SampleText) - .assertIsDisplayed() - } - } - - override fun checkButtonClick() { - with(testRule) { - onNodeWithText("Go to next screen") - .assertIsDisplayed() - .assertHasClickAction() - } - } -} - -internal fun AndroidComposeTestRule, A>.onSampleComposeMultiplatformScreen( - action: SampleComposeMultiplatformScreen.() -> Unit, -) = onScreen(SampleComposeMultiplatformScreenImpl(this), action) - -fun AndroidComposeTestRule, A>.performNavigationToSampleComposeMultiplatform() { - onNodeWithText("Compose Multiplatform") - .performClick() -} diff --git a/android/app/src/androidTest/kotlin/kmp/android/screen/SampleScreen.kt b/android/app/src/androidTest/kotlin/kmp/android/screen/SampleScreen.kt deleted file mode 100644 index 8b904d82..00000000 --- a/android/app/src/androidTest/kotlin/kmp/android/screen/SampleScreen.kt +++ /dev/null @@ -1,55 +0,0 @@ -package kmp.android.screen - -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.ext.junit.rules.ActivityScenarioRule -import kmp.android.extension.hasTestTag -import kmp.android.extension.onNodeWithTag -import kmp.android.extension.ShortDuration -import kmp.android.extension.waitUntilExactlyOneExists -import kmp.shared.samplecomposenavigation.presentation.ui.test.TestTags - -internal interface SampleScreen : Screen { - fun checkContentTextVisible() - fun checkSampleTextVisible() - fun checkButtonClick() -} - -private class SampleScreenImpl( - private val testRule: AndroidComposeTestRule, A>, -) : SampleScreen { - - override fun checkContentTextVisible() { - with(testRule) { - waitUntilExactlyOneExists(hasText("This is a sample with android compose UI and android VM"), ShortDuration) - - onNodeWithText("This is a sample with android compose UI and android VM") - .assertIsDisplayed() - } - } - - override fun checkSampleTextVisible() { - with(testRule) { - waitUntilExactlyOneExists(hasTestTag(TestTags.SampleScreen.SampleText), ShortDuration) - - onNodeWithTag(TestTags.SampleScreen.SampleText) - .assertIsDisplayed() - } - } - - override fun checkButtonClick() { - with(testRule) { - onNodeWithText("Click me!") - .assertIsDisplayed() - .assertHasClickAction() - } - } -} - -internal fun AndroidComposeTestRule, A>.onSampleScreen( - action: SampleScreen.() -> Unit, -) = onScreen(SampleScreenImpl(this), action) diff --git a/android/app/src/androidTest/kotlin/kmp/android/screen/SampleSharedViewModelScreen.kt b/android/app/src/androidTest/kotlin/kmp/android/screen/SampleSharedViewModelScreen.kt deleted file mode 100644 index da7c3bf1..00000000 --- a/android/app/src/androidTest/kotlin/kmp/android/screen/SampleSharedViewModelScreen.kt +++ /dev/null @@ -1,61 +0,0 @@ -package kmp.android.screen - -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertHasClickAction -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.rules.ActivityScenarioRule -import kmp.android.extension.hasTestTag -import kmp.android.extension.onNodeWithTag -import kmp.android.extension.ShortDuration -import kmp.android.extension.waitUntilExactlyOneExists -import kmp.shared.samplecomposenavigation.presentation.ui.test.TestTags - -internal interface SampleSharedViewModelScreen : Screen { - fun checkContentTextVisible() - fun checkSampleTextVisible() - fun checkButtonClick() -} - -private class SampleSharedViewModelScreenImpl( - private val testRule: AndroidComposeTestRule, A>, -) : SampleSharedViewModelScreen { - - override fun checkContentTextVisible() { - with(testRule) { - waitUntilExactlyOneExists(hasText("This is a sample with android compose UI and shared VM"), ShortDuration) - - onNodeWithText("This is a sample with android compose UI and shared VM") - .assertIsDisplayed() - } - } - - override fun checkSampleTextVisible() { - with(testRule) { - waitUntilExactlyOneExists(hasTestTag(TestTags.SampleSharedViewModelScreen.SampleText), ShortDuration) - - onNodeWithTag(TestTags.SampleSharedViewModelScreen.SampleText) - .assertIsDisplayed() - } - } - - override fun checkButtonClick() { - with(testRule) { - onNodeWithText("Click me!") - .assertIsDisplayed() - .assertHasClickAction() - } - } -} - -internal fun AndroidComposeTestRule, A>.onSampleSharedViewModelScreen( - action: SampleSharedViewModelScreen.() -> Unit, -) = onScreen(SampleSharedViewModelScreenImpl(this), action) - -fun AndroidComposeTestRule, A>.performNavigationToSampleSharedViewModel() { - onNodeWithText("Shared VMs") - .performClick() -} diff --git a/android/app/src/androidTest/kotlin/kmp/android/test/SampleComposeMultiplatformScreenTest.kt b/android/app/src/androidTest/kotlin/kmp/android/test/SampleComposeMultiplatformScreenTest.kt deleted file mode 100644 index df006cd9..00000000 --- a/android/app/src/androidTest/kotlin/kmp/android/test/SampleComposeMultiplatformScreenTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package kmp.android.test - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import kmp.android.MainActivity -import kmp.android.screen.onSampleComposeMultiplatformScreen -import kmp.android.screen.performNavigationToSampleComposeMultiplatform -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.BeforeClass -import org.junit.Rule -import org.junit.Test -import org.koin.test.KoinTest - -internal class SampleComposeMultiplatformScreenTest : KoinTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Before - // you can specify what should happen before every test - fun navigateToSampleComposeMultiplatform() { - with(composeTestRule) { - performNavigationToSampleComposeMultiplatform() - } - } - - @Test - fun testTexts() { - with(composeTestRule) { - onSampleComposeMultiplatformScreen { - checkContentTextVisible() - - checkSampleTextVisible() - } - } - } - - @Test - fun testButton() { - with(composeTestRule) { - onSampleComposeMultiplatformScreen { - checkButtonClick() - } - } - } - - companion object { - @JvmStatic - @BeforeClass - // can be renamed to describe what it does - fun beforeTest() { - runBlocking { - // here comes code that needs to be executed before the test even starts (e.g. logout) - } - } - } -} \ No newline at end of file diff --git a/android/app/src/androidTest/kotlin/kmp/android/test/SampleScreenTest.kt b/android/app/src/androidTest/kotlin/kmp/android/test/SampleScreenTest.kt deleted file mode 100644 index a1d99107..00000000 --- a/android/app/src/androidTest/kotlin/kmp/android/test/SampleScreenTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package kmp.android.test - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import kmp.android.MainActivity -import kmp.android.screen.onSampleScreen -import kotlinx.coroutines.runBlocking -import org.junit.BeforeClass -import org.junit.Rule -import org.junit.Test -import org.koin.test.KoinTest - -internal class SampleScreenTest : KoinTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Test - fun testTexts() { - with(composeTestRule) { - onSampleScreen { - checkContentTextVisible() - - checkSampleTextVisible() - } - } - } - - @Test - fun testButton() { - with(composeTestRule) { - onSampleScreen { - checkButtonClick() - } - } - } - - companion object { - @JvmStatic - @BeforeClass - // can be renamed to describe what it does - fun beforeTest() { - runBlocking { - // here comes code that needs to be executed before the test even starts (e.g. logout) - } - } - } -} \ No newline at end of file diff --git a/android/app/src/androidTest/kotlin/kmp/android/test/SampleSharedViewModelScreenTest.kt b/android/app/src/androidTest/kotlin/kmp/android/test/SampleSharedViewModelScreenTest.kt deleted file mode 100644 index e61b2396..00000000 --- a/android/app/src/androidTest/kotlin/kmp/android/test/SampleSharedViewModelScreenTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package kmp.android.test - -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import kmp.android.MainActivity -import kmp.android.screen.onSampleSharedViewModelScreen -import kmp.android.screen.performNavigationToSampleSharedViewModel -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.BeforeClass -import org.junit.Rule -import org.junit.Test -import org.koin.test.KoinTest - -internal class SampleSharedViewModelScreenTest : KoinTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - @Before - // you can specify what should happen before every test - fun navigateToSampleSharedViewModel() { - with(composeTestRule) { - performNavigationToSampleSharedViewModel() - } - } - - @Test - fun testTexts() { - with(composeTestRule) { - onSampleSharedViewModelScreen { - checkContentTextVisible() - - checkSampleTextVisible() - } - } - } - - @Test - fun testButton() { - with(composeTestRule) { - onSampleSharedViewModelScreen { - checkButtonClick() - } - } - } - - companion object { - @JvmStatic - @BeforeClass - // can be renamed to describe what it does - fun beforeTest() { - runBlocking { - // here comes code that needs to be executed before the test even starts (e.g. logout) - } - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/kmp/android/MainActivity.kt b/android/app/src/main/kotlin/kmp/android/MainActivity.kt index d5e07758..305e61f0 100644 --- a/android/app/src/main/kotlin/kmp/android/MainActivity.kt +++ b/android/app/src/main/kotlin/kmp/android/MainActivity.kt @@ -1,15 +1,13 @@ package kmp.android import android.os.Bundle +import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat -import kmp.android.di.initDependencyInjection -import kmp.android.shared.core.system.BaseActivity -import kmp.android.shared.style.AppTheme import kmp.android.ui.Root -import org.koin.core.context.GlobalContext +import kmp.shared.base.presentation.ui.AppTheme -class MainActivity : BaseActivity() { +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/android/app/src/main/kotlin/kmp/android/di/RootModule.kt b/android/app/src/main/kotlin/kmp/android/di/RootModule.kt index c1418ba1..51331080 100644 --- a/android/app/src/main/kotlin/kmp/android/di/RootModule.kt +++ b/android/app/src/main/kotlin/kmp/android/di/RootModule.kt @@ -2,9 +2,7 @@ package kmp.android.di import android.app.Application import android.content.Context -import kmp.android.sample.di.androidSampleModule -import kmp.android.shared.di.androidSharedModule -import kmp.shared.core.di.initKoin +import kmp.shared.umbrella.di.initKoin import org.koin.dsl.module fun Application.initDependencyInjection() { @@ -15,8 +13,6 @@ fun Application.initDependencyInjection() { modules( contextModule, - androidSharedModule, - androidSampleModule, ) } } diff --git a/android/app/src/main/kotlin/kmp/android/navigation/NavBarFeature.kt b/android/app/src/main/kotlin/kmp/android/navigation/NavBarFeature.kt deleted file mode 100644 index 3ccea590..00000000 --- a/android/app/src/main/kotlin/kmp/android/navigation/NavBarFeature.kt +++ /dev/null @@ -1,16 +0,0 @@ -package kmp.android.navigation - -import dev.icerock.moko.resources.desc.StringDesc -import dev.icerock.moko.resources.desc.desc -import kmp.android.sample.navigation.SampleGraph -import kmp.android.samplecomposemultiplatform.navigation.SampleComposeMultiplatformGraph -import kmp.android.samplesharedviewmodel.navigation.SampleSharedViewModelGraph -import kmp.shared.base.MR -import kmp.shared.samplecomposenavigation.presentation.navigation.SampleComposeNavigationGraph - -enum class NavBarFeature(val route: String, val titleRes: StringDesc) { - Sample(SampleGraph.rootPath, MR.strings.bottom_bar_item_1.desc()), - SampleSharedViewModel(SampleSharedViewModelGraph.rootPath, MR.strings.bottom_bar_item_2.desc()), - SampleComposeMultiplatform(SampleComposeMultiplatformGraph.rootPath, MR.strings.bottom_bar_item_3.desc()), - SampleComposeNavigation(SampleComposeNavigationGraph.rootPath, MR.strings.bottom_bar_item_4.desc()), -} diff --git a/android/app/src/main/kotlin/kmp/android/ui/Root.kt b/android/app/src/main/kotlin/kmp/android/ui/Root.kt index 656c62c0..4112c6d2 100644 --- a/android/app/src/main/kotlin/kmp/android/ui/Root.kt +++ b/android/app/src/main/kotlin/kmp/android/ui/Root.kt @@ -1,37 +1,14 @@ package kmp.android.ui import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material.BottomNavigation -import androidx.compose.material.BottomNavigationItem -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBox -import androidx.compose.material.icons.filled.AccountCircle -import androidx.compose.material.icons.filled.Face -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.primarySurface import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost -import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import dev.icerock.moko.resources.compose.localized -import kmp.android.navigation.NavBarFeature -import kmp.android.sample.navigation.SampleGraph -import kmp.android.sample.navigation.sampleNavGraph -import kmp.android.samplecomposemultiplatform.navigation.sampleComposeMultiplatformNavGraph -import kmp.android.samplesharedviewmodel.navigation.sampleSharedViewModelNavGraph -import kmp.android.shared.style.Elevation -import kmp.shared.samplecomposenavigation.presentation.navigation.sampleComposeNavigationNavGraph +import kmp.android.samplefeature.navigation.SampleFeatureGraph +import kmp.android.samplefeature.navigation.sampleFeatureNavGraph @Composable fun Root(modifier: Modifier = Modifier) { @@ -39,69 +16,13 @@ fun Root(modifier: Modifier = Modifier) { Scaffold( modifier = modifier, - bottomBar = { BottomBar(navController) }, ) { padding -> Box(modifier = Modifier.padding(padding)) { NavHost( navController, - startDestination = SampleGraph.rootPath, + startDestination = SampleFeatureGraph.rootPath, ) { - sampleNavGraph(navController) - - sampleSharedViewModelNavGraph(navController) - - sampleComposeMultiplatformNavGraph(navController) - - sampleComposeNavigationNavGraph(navController, onShowMessage = {}) - } - } - } -} - -@Composable -private fun BottomBar(navController: NavHostController, modifier: Modifier = Modifier) { - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - - Surface( - elevation = Elevation.huge, - color = MaterialTheme.colors.primarySurface, - modifier = modifier, - ) { - BottomNavigation( - Modifier.navigationBarsPadding(), - elevation = 0.dp, - ) { - NavBarFeature.entries.forEach { screen -> - BottomNavigationItem( - icon = { - when (screen) { - NavBarFeature.Sample -> Icon(Icons.Filled.Person, "") - NavBarFeature.SampleSharedViewModel -> Icon( - Icons.Filled.AccountCircle, - "", - ) - - NavBarFeature.SampleComposeMultiplatform -> Icon( - Icons.Filled.AccountBox, - "", - ) - - NavBarFeature.SampleComposeNavigation -> Icon( - Icons.Filled.Face, - "", - ) - } - }, - label = { Text(screen.titleRes.localized()) }, - selected = currentRoute?.startsWith(screen.route + "/") ?: false, - onClick = { - navController.navigate(screen.route) { - popUpTo(navController.graph.startDestinationId) - launchSingleTop = true - } - }, - ) + sampleFeatureNavGraph(navController) } } } diff --git a/android/sample/src/main/kotlin/kmp/android/sample/di/Module.kt b/android/sample/src/main/kotlin/kmp/android/sample/di/Module.kt deleted file mode 100644 index 721d449a..00000000 --- a/android/sample/src/main/kotlin/kmp/android/sample/di/Module.kt +++ /dev/null @@ -1,9 +0,0 @@ -package kmp.android.sample.di - -import kmp.android.sample.vm.SampleViewModel -import org.koin.core.module.dsl.viewModelOf -import org.koin.dsl.module - -val androidSampleModule = module { - viewModelOf(::SampleViewModel) -} diff --git a/android/sample/src/main/kotlin/kmp/android/sample/navigation/SampleNavigation.kt b/android/sample/src/main/kotlin/kmp/android/sample/navigation/SampleNavigation.kt deleted file mode 100644 index ae06c197..00000000 --- a/android/sample/src/main/kotlin/kmp/android/sample/navigation/SampleNavigation.kt +++ /dev/null @@ -1,18 +0,0 @@ -package kmp.android.sample.navigation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.navigation -import kmp.android.sample.ui.sampleMainRoute -import kmp.shared.samplesharedviewmodel.vm.SampleSharedViewModel - -fun NavGraphBuilder.sampleNavGraph( - navHostController: NavHostController, -) { - navigation( - startDestination = SampleGraph.Main.route, - route = SampleGraph.rootPath, - ) { - sampleMainRoute() - } -} diff --git a/android/sample/src/main/kotlin/kmp/android/sample/ui/SampleMain.kt b/android/sample/src/main/kotlin/kmp/android/sample/ui/SampleMain.kt deleted file mode 100644 index 64cbcb14..00000000 --- a/android/sample/src/main/kotlin/kmp/android/sample/ui/SampleMain.kt +++ /dev/null @@ -1,103 +0,0 @@ -package kmp.android.sample.ui - -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -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.padding -import androidx.compose.material.Button -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import kmp.android.sample.navigation.SampleGraph -import kmp.android.sample.vm.SampleEvent -import kmp.android.sample.vm.SampleIntent -import kmp.android.sample.vm.SampleState -import kmp.android.sample.vm.SampleViewModel -import kmp.shared.samplecomposenavigation.presentation.ui.test.TestTags -import kmp.shared.samplecomposenavigation.presentation.ui.test.testTag -import kmp.android.shared.navigation.composableDestination -import kmp.android.shared.style.Space -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.compose.koinViewModel - -internal fun NavGraphBuilder.sampleMainRoute() { - composableDestination( - destination = SampleGraph.Main, - ) { - SampleMainRoute() - } -} - -@Composable -internal fun SampleMainRoute( - viewModel: SampleViewModel = koinViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = viewModel) { - viewModel.onIntent(SampleIntent.OnAppeared) - } - - val context = LocalContext.current - LaunchedEffect(key1 = viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - is SampleEvent.ShowMessage -> Toast.makeText( - context, - event.message, - Toast.LENGTH_SHORT, - ).show() - } - } - } - - SampleMainScreen( - state = state, - onIntent = viewModel::onIntent, - ) -} - -@Composable -private fun SampleMainScreen( - state: SampleState, - onIntent: (SampleIntent) -> Unit, -) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AnimatedContent(targetState = state.loading, label = "AnimatedLoading") { loading -> - if (loading) { - CircularProgressIndicator() - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Space.medium), - modifier = Modifier.padding(Space.medium), - ) { - Text( - text = "This is a sample with android compose UI and android VM", - textAlign = TextAlign.Center, - ) - - Text( - text = state.sampleText?.value ?: "", - modifier = Modifier.testTag(TestTags.SampleScreen.SampleText), - ) - - Button(onClick = { onIntent(SampleIntent.OnButtonTapped) }) { - Text(text = "Click me!") - } - } - } - } - } -} diff --git a/android/sample/src/main/kotlin/kmp/android/sample/vm/SampleViewModel.kt b/android/sample/src/main/kotlin/kmp/android/sample/vm/SampleViewModel.kt deleted file mode 100644 index 1679333d..00000000 --- a/android/sample/src/main/kotlin/kmp/android/sample/vm/SampleViewModel.kt +++ /dev/null @@ -1,56 +0,0 @@ -package kmp.android.sample.vm - -import kmp.android.shared.vm.BaseIntentViewModel -import kmp.android.shared.vm.VmEvent -import kmp.android.shared.vm.VmIntent -import kmp.android.shared.vm.VmState -import kmp.shared.analytics.domain.model.ToastAnalytics -import kmp.shared.analytics.domain.model.ToastAnalytics.ViewType -import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase -import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase.Params -import kmp.shared.base.ErrorResult -import kmp.shared.base.Result -import kmp.shared.sample.domain.model.SampleText -import kmp.shared.sample.domain.usecase.GetSampleTextUseCase - -class SampleViewModel( - private val getSampleText: GetSampleTextUseCase, - private val trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, -) : BaseIntentViewModel(SampleState()) { - - override suspend fun applyIntent(intent: SampleIntent) { - when (intent) { - SampleIntent.OnAppeared -> loadSampleText() - SampleIntent.OnButtonTapped -> showToast() - } - } - - private suspend fun loadSampleText() { - update { copy(loading = true) } - when (val result = getSampleText()) { - is Result.Success -> update { copy(sampleText = result.data, loading = false) } - is Result.Error -> update { copy(error = result.error, loading = false) } - } - } - - private suspend fun showToast() { - trackAnalyticsEventUseCase(Params(ToastAnalytics.ToastPresentedEvent(ViewType.Native))) - - _events.emit(SampleEvent.ShowMessage("Button was tapped")) - } -} - -data class SampleState( - val loading: Boolean = false, - val sampleText: SampleText? = null, - val error: ErrorResult? = null, -) : VmState - -sealed interface SampleIntent : VmIntent { - data object OnAppeared : SampleIntent - data object OnButtonTapped : SampleIntent -} - -sealed interface SampleEvent : VmEvent { - data class ShowMessage(val message: String) : SampleEvent -} diff --git a/android/samplecomposemultiplatform/build.gradle.kts b/android/samplecomposemultiplatform/build.gradle.kts deleted file mode 100644 index 0672782a..00000000 --- a/android/samplecomposemultiplatform/build.gradle.kts +++ /dev/null @@ -1,12 +0,0 @@ -plugins { - alias(libs.plugins.mateeStarter.android.library.compose) -} - -android { - namespace = "kmp.android.samplecomposemultiplatform" -} - -dependencies { - implementation(project(":shared:core")) - implementation(project(":android:shared")) -} diff --git a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/navigation/SampleComposeMultiplatformGraph.kt b/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/navigation/SampleComposeMultiplatformGraph.kt deleted file mode 100644 index 8353bd14..00000000 --- a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/navigation/SampleComposeMultiplatformGraph.kt +++ /dev/null @@ -1,17 +0,0 @@ -package kmp.android.samplecomposemultiplatform.navigation - -import kmp.android.shared.navigation.Destination -import kmp.android.shared.navigation.FeatureGraph - -object SampleComposeMultiplatformGraph : FeatureGraph(parent = null) { - - override val path = "sampleComposeMultiplatform" - - data object Main : Destination(this) { - override val routeDefinition: String = "main" - } - - data object Next : Destination(this) { - override val routeDefinition: String = "next" - } -} diff --git a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/navigation/SampleComposeMultiplatformNavigation.kt b/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/navigation/SampleComposeMultiplatformNavigation.kt deleted file mode 100644 index c5446147..00000000 --- a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/navigation/SampleComposeMultiplatformNavigation.kt +++ /dev/null @@ -1,25 +0,0 @@ -package kmp.android.samplecomposemultiplatform.navigation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.navigation -import kmp.android.samplecomposemultiplatform.ui.navigateToComposeMultiplatformNext -import kmp.android.samplecomposemultiplatform.ui.sampleComposeMultiplatformMainRoute -import kmp.android.samplecomposemultiplatform.ui.sampleComposeMultiplatformNextRoute - -fun NavGraphBuilder.sampleComposeMultiplatformNavGraph( - navHostController: NavHostController, -) { - navigation( - startDestination = SampleComposeMultiplatformGraph.Main.route, - route = SampleComposeMultiplatformGraph.rootPath, - ) { - sampleComposeMultiplatformMainRoute( - navigateToNext = { navHostController.navigateToComposeMultiplatformNext() }, - ) - - sampleComposeMultiplatformNextRoute( - navigateBack = { navHostController.popBackStack() }, - ) - } -} diff --git a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/ui/SampleComposeMultiplatformNext.kt b/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/ui/SampleComposeMultiplatformNext.kt deleted file mode 100644 index a9aa7d58..00000000 --- a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/ui/SampleComposeMultiplatformNext.kt +++ /dev/null @@ -1,97 +0,0 @@ -package kmp.android.samplecomposemultiplatform.ui - -import android.widget.Toast -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.displayCutout -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import dev.icerock.moko.resources.compose.stringResource -import kmp.android.samplecomposemultiplatform.navigation.SampleComposeMultiplatformGraph -import kmp.android.shared.navigation.composableDestination -import kmp.shared.base.MR -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleNextScreen -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextEvent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextIntent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextViewModel -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.compose.koinViewModel - -internal fun NavController.navigateToComposeMultiplatformNext() { - navigate(SampleComposeMultiplatformGraph.Next()) -} - -internal fun NavGraphBuilder.sampleComposeMultiplatformNextRoute(navigateBack: () -> Unit) { - composableDestination( - destination = SampleComposeMultiplatformGraph.Next, - ) { - SampleComposeMultiplatformNextRoute(navigateBack = navigateBack) - } -} - -@Composable -internal fun SampleComposeMultiplatformNextRoute( - navigateBack: () -> Unit, - viewModel: SampleNextViewModel = koinViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = viewModel) { - viewModel.onViewAppeared() - } - - val context = LocalContext.current - LaunchedEffect(key1 = viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - is SampleNextEvent.ShowMessage -> Toast.makeText( - context, - event.message, - Toast.LENGTH_SHORT, - ).show() - - SampleNextEvent.NavigateBack -> navigateBack() - } - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = stringResource(MR.strings.next_screen_title)) }, - windowInsets = WindowInsets.displayCutout, - navigationIcon = { - IconButton( - onClick = { viewModel.onIntent(SampleNextIntent.OnBackTapped) }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = stringResource(MR.strings.back), - tint = MaterialTheme.colors.onPrimary, - ) - } - }, - ) - }, - ) { padding -> - SampleNextScreen( - state = state, - onIntent = { viewModel.onIntent(it) }, - modifier = Modifier.consumeWindowInsets(padding), - ) - } -} \ No newline at end of file diff --git a/android/sample/.gitignore b/android/samplefeature/.gitignore similarity index 100% rename from android/sample/.gitignore rename to android/samplefeature/.gitignore diff --git a/android/sample/build.gradle.kts b/android/samplefeature/build.gradle.kts similarity index 62% rename from android/sample/build.gradle.kts rename to android/samplefeature/build.gradle.kts index edf00b7b..2c7e7d3c 100644 --- a/android/sample/build.gradle.kts +++ b/android/samplefeature/build.gradle.kts @@ -3,10 +3,10 @@ plugins { } android { - namespace = "kmp.android.sample" + namespace = "kmp.android.samplefeature" } dependencies { - implementation(project(":shared:core")) implementation(project(":android:shared")) + implementation(project(":shared:umbrella")) } diff --git a/android/sample/src/main/kotlin/kmp/android/sample/navigation/SampleGraph.kt b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureGraph.kt similarity index 59% rename from android/sample/src/main/kotlin/kmp/android/sample/navigation/SampleGraph.kt rename to android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureGraph.kt index a0f14334..86e82bfd 100644 --- a/android/sample/src/main/kotlin/kmp/android/sample/navigation/SampleGraph.kt +++ b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureGraph.kt @@ -1,11 +1,11 @@ -package kmp.android.sample.navigation +package kmp.android.samplefeature.navigation import kmp.android.shared.navigation.Destination import kmp.android.shared.navigation.FeatureGraph -object SampleGraph : FeatureGraph(parent = null) { +object SampleFeatureGraph : FeatureGraph(parent = null) { - override val path = "sample" + override val path = "sampleFeature" data object Main : Destination(this) { override val routeDefinition: String = "main" diff --git a/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt new file mode 100644 index 00000000..75874113 --- /dev/null +++ b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt @@ -0,0 +1,17 @@ +package kmp.android.samplefeature.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.navigation +import kmp.android.samplefeature.ui.sampleFeatureMainRoute + +fun NavGraphBuilder.sampleFeatureNavGraph( + navHostController: NavHostController, +) { + navigation( + startDestination = SampleFeatureGraph.Main.route, + route = SampleFeatureGraph.rootPath, + ) { + sampleFeatureMainRoute() + } +} diff --git a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/ui/SampleComposeMultiplaformMain.kt b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt similarity index 62% rename from android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/ui/SampleComposeMultiplaformMain.kt rename to android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt index b12b1e46..4d4d4fd5 100644 --- a/android/samplecomposemultiplatform/src/main/kotlin/kmp/android/samplecomposemultiplatform/ui/SampleComposeMultiplaformMain.kt +++ b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt @@ -1,4 +1,4 @@ -package kmp.android.samplecomposemultiplatform.ui +package kmp.android.samplefeature.ui import android.widget.Toast import androidx.compose.foundation.layout.WindowInsets @@ -15,28 +15,27 @@ import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraphBuilder import dev.icerock.moko.resources.compose.stringResource -import kmp.android.samplecomposemultiplatform.navigation.SampleComposeMultiplatformGraph +import kmp.android.samplefeature.navigation.SampleFeatureGraph import kmp.android.shared.navigation.composableDestination import kmp.shared.base.MR -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleComposeMultiplatformScreen -import kmp.shared.samplesharedviewmodel.vm.SampleSharedEvent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedIntent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedViewModel +import kmp.shared.samplefeature.presentation.ui.SampleFeatureMainScreen +import kmp.shared.samplefeature.presentation.vm.SampleFeatureEvent +import kmp.shared.samplefeature.presentation.vm.SampleFeatureIntent +import kmp.shared.samplefeature.presentation.vm.SampleFeatureViewModel import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel -internal fun NavGraphBuilder.sampleComposeMultiplatformMainRoute(navigateToNext: () -> Unit) { +internal fun NavGraphBuilder.sampleFeatureMainRoute() { composableDestination( - destination = SampleComposeMultiplatformGraph.Main, + destination = SampleFeatureGraph.Main, ) { - SampleComposeMultiplatformMainRoute(navigateToNext = navigateToNext) + SampleFeatureMainRoute() } } @Composable -internal fun SampleComposeMultiplatformMainRoute( - navigateToNext: () -> Unit, - viewModel: SampleSharedViewModel = koinViewModel(), +internal fun SampleFeatureMainRoute( + viewModel: SampleFeatureViewModel = koinViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -45,16 +44,14 @@ internal fun SampleComposeMultiplatformMainRoute( } val context = LocalContext.current - LaunchedEffect(key1 = viewModel) { + LaunchedEffect(viewModel) { viewModel.events.collectLatest { event -> when (event) { - is SampleSharedEvent.ShowMessage -> Toast.makeText( + is SampleFeatureEvent.ShowMessage -> Toast.makeText( context, event.message, Toast.LENGTH_SHORT, ).show() - - SampleSharedEvent.GoToNext -> navigateToNext() } } } @@ -62,12 +59,12 @@ internal fun SampleComposeMultiplatformMainRoute( Scaffold( topBar = { TopAppBar( - title = { Text(text = stringResource(MR.strings.bottom_bar_item_3)) }, + title = { Text(text = stringResource(MR.strings.sample_feature_title)) }, windowInsets = WindowInsets.displayCutout, ) }, ) { padding -> - SampleComposeMultiplatformScreen( + SampleFeatureMainScreen( state = state, onIntent = { viewModel.onIntent(it) }, modifier = Modifier.consumeWindowInsets(padding), diff --git a/android/samplesharedviewmodel/.gitignore b/android/samplesharedviewmodel/.gitignore deleted file mode 100644 index 42afabfd..00000000 --- a/android/samplesharedviewmodel/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/android/samplesharedviewmodel/build.gradle.kts b/android/samplesharedviewmodel/build.gradle.kts deleted file mode 100644 index 27885fc4..00000000 --- a/android/samplesharedviewmodel/build.gradle.kts +++ /dev/null @@ -1,12 +0,0 @@ -plugins { - alias(libs.plugins.mateeStarter.android.library.compose) -} - -android { - namespace = "kmp.android.samplesharedviewmodel" -} - -dependencies { - implementation(project(":shared:core")) - implementation(project(":android:shared")) -} diff --git a/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/navigation/SampleSharedViewModelGraph.kt b/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/navigation/SampleSharedViewModelGraph.kt deleted file mode 100644 index f68ddc54..00000000 --- a/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/navigation/SampleSharedViewModelGraph.kt +++ /dev/null @@ -1,13 +0,0 @@ -package kmp.android.samplesharedviewmodel.navigation - -import kmp.android.shared.navigation.Destination -import kmp.android.shared.navigation.FeatureGraph - -object SampleSharedViewModelGraph : FeatureGraph(parent = null) { - - override val path = "sampleSharedViewModel" - - data object Main : Destination(this) { - override val routeDefinition: String = "main" - } -} diff --git a/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/navigation/SampleSharedViewModelNavigation.kt b/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/navigation/SampleSharedViewModelNavigation.kt deleted file mode 100644 index de5e60c8..00000000 --- a/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/navigation/SampleSharedViewModelNavigation.kt +++ /dev/null @@ -1,17 +0,0 @@ -package kmp.android.samplesharedviewmodel.navigation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.navigation -import kmp.android.samplesharedviewmodel.ui.sampleSharedViewModelMainRoute - -fun NavGraphBuilder.sampleSharedViewModelNavGraph( - navHostController: NavHostController, -) { - navigation( - startDestination = SampleSharedViewModelGraph.Main.route, - route = SampleSharedViewModelGraph.rootPath, - ) { - sampleSharedViewModelMainRoute() - } -} diff --git a/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/ui/SampleSharedViewModelMain.kt b/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/ui/SampleSharedViewModelMain.kt deleted file mode 100644 index 7bbf319d..00000000 --- a/android/samplesharedviewmodel/src/main/kotlin/kmp/android/samplesharedviewmodel/ui/SampleSharedViewModelMain.kt +++ /dev/null @@ -1,105 +0,0 @@ -package kmp.android.samplesharedviewmodel.ui - -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -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.padding -import androidx.compose.material.Button -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import kmp.android.samplesharedviewmodel.navigation.SampleSharedViewModelGraph -import kmp.shared.samplecomposenavigation.presentation.ui.test.TestTags -import kmp.shared.samplecomposenavigation.presentation.ui.test.testTag -import kmp.android.shared.navigation.composableDestination -import kmp.android.shared.style.Space -import kmp.shared.samplesharedviewmodel.vm.SampleSharedEvent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedIntent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedState -import kmp.shared.samplesharedviewmodel.vm.SampleSharedViewModel -import kotlinx.coroutines.flow.collectLatest -import org.koin.androidx.compose.koinViewModel - -internal fun NavGraphBuilder.sampleSharedViewModelMainRoute() { - composableDestination( - destination = SampleSharedViewModelGraph.Main, - ) { - SampleSharedViewModelMainRoute() - } -} - -@Composable -internal fun SampleSharedViewModelMainRoute( - viewModel: SampleSharedViewModel = koinViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = viewModel) { - viewModel.onViewAppeared() - } - - val context = LocalContext.current - LaunchedEffect(key1 = viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - is SampleSharedEvent.ShowMessage -> Toast.makeText( - context, - event.message, - Toast.LENGTH_SHORT, - ).show() - - SampleSharedEvent.GoToNext -> TODO() - } - } - } - - SampleMainScreen( - state = state, - onIntent = { viewModel.onIntent(it) }, - ) -} - -@Composable -private fun SampleMainScreen( - state: SampleSharedState, - onIntent: (SampleSharedIntent) -> Unit, -) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AnimatedContent(targetState = state.loading, label = "AnimatedLoading") { loading -> - if (loading) { - CircularProgressIndicator() - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(Space.medium), - modifier = Modifier.padding(Space.medium), - ) { - Text( - text = "This is a sample with android compose UI and shared VM", - textAlign = TextAlign.Center, - ) - - Text( - text = state.sampleText?.value ?: "", - modifier = Modifier.testTag(TestTags.SampleSharedViewModelScreen.SampleText), - ) - - Button(onClick = { onIntent(SampleSharedIntent.OnButtonTapped) }) { - Text(text = "Click me!") - } - } - } - } - } -} diff --git a/android/shared/build.gradle.kts b/android/shared/build.gradle.kts index a29beba8..f232c42a 100644 --- a/android/shared/build.gradle.kts +++ b/android/shared/build.gradle.kts @@ -8,6 +8,6 @@ android { dependencies { - implementation(project(":shared:core")) + implementation(project(":shared:umbrella")) implementation(libs.googlePlayServices.location) } diff --git a/android/shared/src/main/kotlin/kmp/android/shared/core/system/BaseActivity.kt b/android/shared/src/main/kotlin/kmp/android/shared/core/system/BaseActivity.kt deleted file mode 100644 index 78cdfab9..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/core/system/BaseActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kmp.android.shared.core.system - -import androidx.activity.ComponentActivity - -public abstract class BaseActivity : ComponentActivity() diff --git a/android/shared/src/main/kotlin/kmp/android/shared/core/system/BaseViewModel.kt b/android/shared/src/main/kotlin/kmp/android/shared/core/system/BaseViewModel.kt deleted file mode 100644 index 817f64cc..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/core/system/BaseViewModel.kt +++ /dev/null @@ -1,65 +0,0 @@ -package kmp.android.shared.core.system - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext - -public abstract class BaseViewModel( - private val defaultDispatcher: CoroutineDispatcher = Dispatchers.IO, -) : ViewModel() { - - /** - * Collect flow in [viewModelScope] - * @param flow Flow that will be collected - * @param collector function which will be called on each emit. - * @return reference to the coroutine as a [Job] - */ - protected fun collect( - flow: Flow, - context: CoroutineContext = Dispatchers.Default, - collector: suspend (T) -> Unit, - ): Job = launch(context) { - flow.collect(collector) - } - - /** - * Launch coroutine in [viewModelScope] with [context] - * @param context additional to [CoroutineScope.coroutineContext] context of the coroutine. - * @param start coroutine start option. The default value is [CoroutineStart.DEFAULT]. - * @param block the coroutine code which will be invoked in the context of the provided scope. - * @return reference to the coroutine as a [Job] - */ - protected fun launch( - context: CoroutineContext = defaultDispatcher, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit, - ): Job = viewModelScope.launch(context, start, block) -} - -/** - * Holds state of UI component bound to [BaseStateViewModel] - */ -public interface State - -public abstract class BaseStateViewModel( - initialState: S, - defaultDispatcher: CoroutineDispatcher = Dispatchers.IO, -) : BaseViewModel(defaultDispatcher) { - - private val stateFlow = MutableStateFlow(initialState) - public val state: Flow = stateFlow - - protected fun update(body: S.() -> S) { - stateFlow.value = body(stateFlow.value) - } - - public fun lastState(): S = stateFlow.value -} diff --git a/android/shared/src/main/kotlin/kmp/android/shared/core/ui/util/PermissionRequest.kt b/android/shared/src/main/kotlin/kmp/android/shared/core/ui/util/PermissionRequest.kt deleted file mode 100644 index 142207f7..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/core/ui/util/PermissionRequest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package kmp.android.shared.core.ui.util - -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember - -abstract class PermissionRequest( - protected open val launcher: ActivityResultLauncher, - open val granted: State, -) { - abstract fun requestPermission() -} - -@Composable -private fun rememberPermissionRequest( - factory: ( - launcher: ActivityResultLauncher, - granted: State, - ) -> T, -): T { - val granted = remember { mutableStateOf(false) } - val launcher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - onResult = { granted.value = it }, - ) - - return remember { factory(launcher, granted) } -} diff --git a/android/shared/src/main/kotlin/kmp/android/shared/di/Module.kt b/android/shared/src/main/kotlin/kmp/android/shared/di/Module.kt deleted file mode 100644 index 3d045398..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/di/Module.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kmp.android.shared.di - -import org.koin.dsl.module - -val androidSharedModule = module {} diff --git a/android/shared/src/main/kotlin/kmp/android/shared/extension/Error.kt b/android/shared/src/main/kotlin/kmp/android/shared/extension/Error.kt deleted file mode 100644 index fde22837..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/extension/Error.kt +++ /dev/null @@ -1,21 +0,0 @@ -package kmp.android.shared.extension - -import android.annotation.SuppressLint -import androidx.compose.material.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext -import kmp.shared.base.ErrorResult -import kotlinx.coroutines.flow.Flow - -@SuppressLint("ComposableNaming") -@Suppress("konsist.every internal or public compose function has a modifier") -@Composable -infix fun Flow.showIn(snackHost: SnackbarHostState) { - val context = LocalContext.current - LaunchedEffect(this, snackHost) { - collect { error -> - snackHost.showSnackbar(error.localizedMessage.toString(context)) - } - } -} diff --git a/android/shared/src/main/kotlin/kmp/android/shared/extension/Modifier.kt b/android/shared/src/main/kotlin/kmp/android/shared/extension/Modifier.kt deleted file mode 100644 index b3a133de..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/extension/Modifier.kt +++ /dev/null @@ -1,40 +0,0 @@ -package kmp.android.shared.extension - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import kotlin.math.roundToInt - -fun Modifier.pushedByIme(additionalSpace: Int = 0) = composed { - var bottomPosition by remember { mutableStateOf(0) } - val spaceFromBottom = LocalView.current.height - bottomPosition - val insets = WindowInsets.ime - - val bottomOffset = (insets.getBottom(LocalDensity.current) - spaceFromBottom + additionalSpace) - .coerceAtLeast(0) - - onGloballyPositioned { - if (bottomPosition == 0) { - // Get only first position - bottomPosition = (it.positionInWindow().y + it.size.height).roundToInt() - } - }.offset { IntOffset(0, -bottomOffset) } -} - -fun Modifier.pushedByIme(additionalSpace: Dp = 0.dp) = composed { - val density = LocalDensity.current.density - pushedByIme((additionalSpace.value * density).toInt()) -} diff --git a/android/shared/src/main/kotlin/kmp/android/shared/style/Border.kt b/android/shared/src/main/kotlin/kmp/android/shared/style/Border.kt deleted file mode 100644 index 75ce60f7..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/style/Border.kt +++ /dev/null @@ -1,10 +0,0 @@ -package kmp.android.shared.style - -import androidx.compose.ui.unit.dp - -object Border { - val thin = 1.dp - val medium = 2.dp - val mediumLarge = 3.dp - val thick = 4.dp -} \ No newline at end of file diff --git a/android/shared/src/main/kotlin/kmp/android/shared/style/Elevation.kt b/android/shared/src/main/kotlin/kmp/android/shared/style/Elevation.kt deleted file mode 100644 index eacc24af..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/style/Elevation.kt +++ /dev/null @@ -1,10 +0,0 @@ -package kmp.android.shared.style - -import androidx.compose.ui.unit.dp - -object Elevation { - val small = 1.dp - val normal = 3.dp - val big = 6.dp - val huge = 8.dp -} \ No newline at end of file diff --git a/android/shared/src/main/kotlin/kmp/android/shared/style/Radius.kt b/android/shared/src/main/kotlin/kmp/android/shared/style/Radius.kt deleted file mode 100644 index 8ea56481..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/style/Radius.kt +++ /dev/null @@ -1,9 +0,0 @@ -package kmp.android.shared.style - -import androidx.compose.ui.unit.dp - -object Radius { - val small = 3.dp - val medium = 6.dp - val large = 9.dp -} \ No newline at end of file diff --git a/android/shared/src/main/kotlin/kmp/android/shared/style/Space.kt b/android/shared/src/main/kotlin/kmp/android/shared/style/Space.kt deleted file mode 100644 index 14cb6c3f..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/style/Space.kt +++ /dev/null @@ -1,16 +0,0 @@ -package kmp.android.shared.style - -import androidx.compose.ui.unit.dp - -object Space { - val xxsmall = 2.dp - val xsmall = 4.dp - val small = 8.dp - val mediumSmall = 12.dp - val medium = 16.dp - val mediumLarge = 20.dp - val large = 24.dp - val xlarge = 32.dp - val xxlarge = 44.dp - val xxxlarge = 64.dp -} \ No newline at end of file diff --git a/android/shared/src/main/kotlin/kmp/android/shared/style/Theme.kt b/android/shared/src/main/kotlin/kmp/android/shared/style/Theme.kt deleted file mode 100644 index d340462c..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/style/Theme.kt +++ /dev/null @@ -1,75 +0,0 @@ -package kmp.android.shared.style - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes -import androidx.compose.material.Typography -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -// https://coolors.co/f5ab00-b8a422-9aa133-7b9d44-d95700-e0e0e0-f0f0f0 -val lightColors = Colors( - primary = Color(0xFFF5AB00), - primaryVariant = Color(0xFFB8A422), - - secondary = Color(0xFF7B9D44), - secondaryVariant = Color(0xFF9AA133), - - background = Color(0xFFF0F0F0), - surface = Color(0xFFE0E0E0), - - error = Color(0xFFD95700), - - onPrimary = Color(0xFFFFFFFF), - onSecondary = Color(0xFF000000), - onBackground = Color(0xFF000000), - onSurface = Color(0xFF000000), - onError = Color(0xFF000000), - - isLight = true, -) - -// https://coolors.co/f5ab00-b8a422-9aa133-7b9d44-d95700-1f1f1f-141414 -val darkColors = Colors( - primary = Color(0xFFF5AB00), - primaryVariant = Color(0xFFB8A422), - - secondary = Color(0xFF7B9D44), - secondaryVariant = Color(0xFF9AA133), - - background = Color(0xFF141414), - surface = Color(0xFF1F1F1F), - - error = Color(0xFFD95700), - - onPrimary = Color(0xFFFFFFFF), - onSecondary = Color(0xFF000000), - onBackground = Color(0xFFFFFFFF), - onSurface = Color(0xFFFFFFFF), - onError = Color(0xFFFFFFFF), - - isLight = false, -) - -val typography = Typography( - // Define typohraphy -) - -val shapes = Shapes( - small = RoundedCornerShape(Radius.large), - medium = RoundedCornerShape(Radius.medium), - large = RoundedCornerShape(Radius.small), -) - -@Composable -fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) darkColors else lightColors - MaterialTheme( - colors = colors, - typography = typography, - shapes = shapes, - content = content, - ) -} diff --git a/android/shared/src/main/kotlin/kmp/android/shared/vm/BaseIntentViewModel.kt b/android/shared/src/main/kotlin/kmp/android/shared/vm/BaseIntentViewModel.kt deleted file mode 100644 index 72f6ce34..00000000 --- a/android/shared/src/main/kotlin/kmp/android/shared/vm/BaseIntentViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package kmp.android.shared.vm - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -abstract class BaseIntentViewModel(initialState: S) : - ViewModel() { - - private val _state = MutableStateFlow(initialState) - val state = _state.asStateFlow() - - protected val _events = MutableSharedFlow() - val events = _events.asSharedFlow() - - fun onIntent(intent: I) { - viewModelScope.launch { - applyIntent(intent) - } - } - - protected fun update(body: S.() -> S) { - _state.value = body(state.value) - } - - protected abstract suspend fun applyIntent(intent: I) -} - -/** - * Base state of every view that is held in view model. - */ -@Immutable -interface VmState - -/** - * Base intent interface - */ -interface VmIntent - -/** - * Base event interface - */ -interface VmEvent diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 006e9a75..558cb80f 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) compileOnly(libs.androidTools.gradle) compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.compose.gradlePlugin) + compileOnly(libs.ktlint.gradlePlugin) } gradlePlugin { @@ -23,8 +25,6 @@ gradlePlugin { dependency = libs.plugins.mateeStarter.android.application.compose, pluginName = "AndroidApplicationComposeConventionPlugin", ) - } - plugins { plugin( dependency = libs.plugins.mateeStarter.android.application.core, pluginName = "AndroidApplicationConventionPlugin", @@ -38,12 +38,16 @@ gradlePlugin { pluginName = "AndroidLibraryConventionPlugin", ) plugin( - dependency = libs.plugins.mateeStarter.kmm.library, - pluginName = "KmmLibraryConventionPlugin", + dependency = libs.plugins.mateeStarter.kmp.library.core, + pluginName = "KmpLibraryConventionPlugin", + ) + plugin( + dependency = libs.plugins.mateeStarter.kmp.library.compose, + pluginName = "KmpLibraryComposeConventionPlugin", ) plugin( - dependency = libs.plugins.mateeStarter.kmm.xcframework.library, - pluginName = "KmmXCFrameworkLibraryConventionPlugin", + dependency = libs.plugins.mateeStarter.kmp.framework.library, + pluginName = "KmpFrameworkLibraryConventionPlugin", ) } } diff --git a/build-logic/convention/src/main/kotlin/config/KmmConfig.kt b/build-logic/convention/src/main/kotlin/config/KmpConfig.kt similarity index 81% rename from build-logic/convention/src/main/kotlin/config/KmmConfig.kt rename to build-logic/convention/src/main/kotlin/config/KmpConfig.kt index e8f8af38..ccdcf596 100644 --- a/build-logic/convention/src/main/kotlin/config/KmmConfig.kt +++ b/build-logic/convention/src/main/kotlin/config/KmpConfig.kt @@ -6,9 +6,9 @@ import org.gradle.api.Project import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -object KmmConfig { +object KmpConfig { private fun includeX86(project: Project): Boolean = - getBooleanProperty(project, "X86", true) + getBooleanProperty(project, "X86", false) private fun includeArm64(project: Project): Boolean = getBooleanProperty(project, "ARM64", true) @@ -36,14 +36,14 @@ object KmmConfig { } fun KotlinMultiplatformExtension.getIosTargets(project: Project, tvOSEnabled: Boolean = false): List = - KmmConfig.getSupportedMobilePlatforms(this, project) + + KmpConfig.getSupportedMobilePlatforms(this, project) + if (tvOSEnabled) { - KmmConfig.getSupportedTvPlatforms(this, project) + KmpConfig.getSupportedTvPlatforms(this, project) } else { emptyList() } -fun KotlinMultiplatformExtension.kmm( +fun KotlinMultiplatformExtension.kmp( project: Project, nativeName: String, tvOSEnabled: Boolean = false, @@ -55,10 +55,8 @@ fun KotlinMultiplatformExtension.kmm( isStatic = false export(libs.mokoResources) export(project(":shared:base")) - export(project(":shared:sample")) - export(project(":shared:samplesharedviewmodel")) - export(project(":shared:samplecomposemultiplatform")) - export(project(":shared:samplecomposenavigation")) + export(project(":shared:analytics")) + export(project(":shared:samplefeature")) } it.binaries { compilerOptions.freeCompilerArgs.add("-Xbinary=bundleId=kmp.shared.$nativeName") diff --git a/build-logic/convention/src/main/kotlin/config/TestConfig.kt b/build-logic/convention/src/main/kotlin/config/TestConfig.kt deleted file mode 100644 index af20fe3c..00000000 --- a/build-logic/convention/src/main/kotlin/config/TestConfig.kt +++ /dev/null @@ -1,13 +0,0 @@ -package config - -import extensions.libs -import extensions.testImplementation -import org.gradle.api.Project -import org.gradle.kotlin.dsl.dependencies - -internal fun Project.configureTests() { - dependencies { - testImplementation(libs.junit) - testImplementation(libs.konsist) - } -} diff --git a/build-logic/convention/src/main/kotlin/extensions/PojectExtensions.kt b/build-logic/convention/src/main/kotlin/extensions/PojectExtensions.kt index 34ba5050..9fe03427 100644 --- a/build-logic/convention/src/main/kotlin/extensions/PojectExtensions.kt +++ b/build-logic/convention/src/main/kotlin/extensions/PojectExtensions.kt @@ -11,11 +11,15 @@ import org.gradle.api.plugins.PluginManager import org.gradle.api.provider.Provider import org.gradle.kotlin.dsl.DependencyHandlerScope import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.the import org.gradle.plugin.use.PluginDependency +import org.jetbrains.compose.ComposeExtension +import org.jetbrains.compose.ComposePlugin import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension import org.jetbrains.kotlin.gradle.dsl.kotlinExtension +import org.jlleitschuh.gradle.ktlint.KtlintExtension val Project.libs get() = the() @@ -65,3 +69,16 @@ fun Project.kotlin(configure: KotlinProjectExtension.() -> Unit) = extensions.configure { configure() } + +val Project.compose: ComposePlugin.Dependencies + get() = extensions.getByType().dependencies + +fun Project.ktlint( + configure: Action, +) { + extensions.configure(KtlintExtension::class.java, configure) +} + +fun Project.ktlintRuleset(dependencyNotation: Any): org.gradle.api.artifacts.Dependency? { + return dependencies.add("ktlintRuleset", dependencyNotation) +} diff --git a/build-logic/convention/src/main/kotlin/plugin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/plugin/AndroidApplicationConventionPlugin.kt index 6b5bed8b..6d8d2481 100644 --- a/build-logic/convention/src/main/kotlin/plugin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/plugin/AndroidApplicationConventionPlugin.kt @@ -30,7 +30,7 @@ class AndroidApplicationConventionPlugin : Plugin { } apply() - apply() + apply() extensions.configure { configureKotlinAndroid(this) diff --git a/build-logic/convention/src/main/kotlin/plugin/TestsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/plugin/AndroidTestsConventionPlugin.kt similarity index 73% rename from build-logic/convention/src/main/kotlin/plugin/TestsConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/plugin/AndroidTestsConventionPlugin.kt index 6cd78d7e..db46c31c 100644 --- a/build-logic/convention/src/main/kotlin/plugin/TestsConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/plugin/AndroidTestsConventionPlugin.kt @@ -9,7 +9,7 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.dependencies -class TestsConventionPlugin : Plugin { +class AndroidTestsConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { @@ -23,13 +23,9 @@ class TestsConventionPlugin : Plugin { testImplementation(libs.junit) testImplementation(libs.konsist) - debugImplementation(libs.androidx.uiautomator) debugImplementation(libs.koin.test) debugImplementation(platform(libs.compose.bom)) -// debugImplementation(libs.compose.test.manifest) androidTestImplementation(platform(libs.compose.bom)) -// androidTestImplementation(libs.compose.test.runner) - androidTestImplementation(libs.espresso.core) } } } diff --git a/build-logic/convention/src/main/kotlin/plugin/KmmXCFrameworkLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/plugin/KmpFrameworkLibraryConventionPlugin.kt similarity index 78% rename from build-logic/convention/src/main/kotlin/plugin/KmmXCFrameworkLibraryConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/plugin/KmpFrameworkLibraryConventionPlugin.kt index eecbaecf..c5abd743 100644 --- a/build-logic/convention/src/main/kotlin/plugin/KmmXCFrameworkLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/plugin/KmpFrameworkLibraryConventionPlugin.kt @@ -1,6 +1,6 @@ package plugin -import config.kmm +import config.kmp import constants.ProjectConstants import org.gradle.api.Plugin import org.gradle.api.Project @@ -9,14 +9,14 @@ import org.gradle.kotlin.dsl.configure import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @Suppress("unused") -class KmmXCFrameworkLibraryConventionPlugin : Plugin { +class KmpFrameworkLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - apply() + apply() extensions.configure { - kmm( + kmp( project = project, nativeName = ProjectConstants.iosShared, ) diff --git a/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt new file mode 100644 index 00000000..943ed8d7 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt @@ -0,0 +1,41 @@ +package plugin + +import extensions.apply +import extensions.compose +import extensions.ktlintRuleset +import extensions.libs +import extensions.pluginManager +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.invoke +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +@Suppress("unused") +class KmpLibraryComposeConventionPlugin : Plugin { + + override fun apply(target: Project) { + with(target) { + pluginManager { + apply(libs.plugins.jetbrains.compose.plugin) + apply(libs.plugins.jetbrains.compose.compiler) + } + + apply() + + extensions.configure { + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + ktlintRuleset(libs.ktlint.composeRules) + } + } + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/plugin/KmmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/plugin/KmpLibraryConventionPlugin.kt similarity index 80% rename from build-logic/convention/src/main/kotlin/plugin/KmmLibraryConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/plugin/KmpLibraryConventionPlugin.kt index de5d2ef7..3b5ab73d 100644 --- a/build-logic/convention/src/main/kotlin/plugin/KmmLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/plugin/KmpLibraryConventionPlugin.kt @@ -3,9 +3,9 @@ package plugin import com.android.build.api.dsl.LibraryExtension import config.configureBuildVariants import config.configureKotlinAndroid -import config.configureTests import config.getIosTargets import extensions.apply +import extensions.ktlint import extensions.libs import extensions.pluginManager import org.gradle.api.Plugin @@ -16,7 +16,7 @@ import org.gradle.kotlin.dsl.invoke import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @Suppress("unused") -class KmmLibraryConventionPlugin : Plugin { +class KmpLibraryConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { @@ -30,6 +30,15 @@ class KmmLibraryConventionPlugin : Plugin { apply() + ktlint { + filter { + exclude { entry -> + val path = entry.file.absolutePath + path.contains("/generated/") || path.contains("build/generated") + } + } + } + extensions.configure { targets.configureEach { compilations.configureEach { @@ -74,11 +83,21 @@ class KmmLibraryConventionPlugin : Plugin { implementation(libs.ktor.auth) } + commonTest.dependencies { + implementation(libs.junit) + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + androidMain.dependencies { implementation(libs.ktor.android) implementation(libs.lifecycle.viewModel) } + androidUnitTest.dependencies { + implementation(libs.konsist) + } + iosMain.dependencies { implementation(libs.ktor.ios) } @@ -87,7 +106,6 @@ class KmmLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - configureTests() } } } diff --git a/build.gradle.kts b/build.gradle.kts index 6dd251ea..5c3b1175 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,9 +9,11 @@ plugins { alias(libs.plugins.ktlint) apply false alias(libs.plugins.mokoResources) apply false alias(libs.plugins.skie) apply false + alias(libs.plugins.jetbrains.compose.plugin) apply false alias(libs.plugins.jetbrains.compose.compiler) apply false alias(libs.plugins.versions) alias(libs.plugins.versionCatalogUpdate) + alias(libs.plugins.jetbrains.kotlin.jvm) apply false } fun String.isNonStable(): Boolean { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de2d6ac1..530e6067 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ material-icons = "1.7.3" lifecycle = "2.9.3" paging = "3.3.6" composeBom = "2025.08.01" -jetbrains-composePlugin = "1.8.2" # Downgrade to 1.6.11 if you want to use compose multiplatform with UIKit navigation (it fixes the swipe back) +jetbrains-composePlugin = "1.8.2" activity = "1.10.1" navigation = "2.9.3" accompanist = "0.36.0" @@ -29,6 +29,7 @@ ktLint-rules = "0.3.8" kotlinxImmutableCollections = "0.4.0" konsist = "0.13.0" junit = "4.13.2" +coroutine-test = "1.8.1" mokoResources = "0.25.0" junitVersion = "1.2.1" espressoCore = "3.6.1" @@ -41,6 +42,7 @@ molecule = "2.1.0" skie = "0.10.6" firebase = "22.5.0" googleServices = "4.4.3" +jetbrainsKotlinJvm = "2.2.10" [libraries] # Kotlin @@ -58,13 +60,10 @@ dateTime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "d atomicFu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicFu" } # Koin koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } -# Can be removed in case you do not use compose multiplatform koin-core-viewModel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } -# Can be removed in case you do not use compose multiplatform koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } -# Can be removed in case you do not use compose multiplatform koin-compose-viewModel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" } # AndroidX @@ -89,6 +88,7 @@ compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material = { module = "androidx.compose.material:material" } compose-uiTest = { module = "androidx.compose.ui:ui-test-junit4" } compose-materialIconsCore = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material-icons" } +compose-gradlePlugin = { module = "org.jetbrains.compose:org.jetbrains.compose.gradle.plugin", version.ref = "jetbrains-composePlugin" } # Activity activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } # Navigation @@ -121,15 +121,16 @@ mokoResources-compose = { module = "dev.icerock.moko:resources-compose", version konsist = { module = "com.lemonappdev:konsist", version.ref = "konsist" } # Tests junit = { module = "junit:junit", version.ref = "junit" } -ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } +kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutine-test" } # Skie skie-annotations = { module = "co.touchlab.skie:configuration-annotations", version.ref = "skie" } # Molecule molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" } # Firebase firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx", version.ref = "firebase" } +# Ktlint +ktlint-gradlePlugin = { module = "org.jlleitschuh.gradle.ktlint:org.jlleitschuh.gradle.ktlint.gradle.plugin", version.ref = "ktLint" } [bundles] settings = [ @@ -158,15 +159,16 @@ versionCatalogUpdate = { id = "nl.littlerobots.version-catalog-update", version. versions = { id = "com.github.ben-manes.versions", version.ref = "versions" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktLint" } mokoResources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "mokoResources" } -jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrains-composePlugin" } skie = { id = "co.touchlab.skie", version.ref = "skie" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } -# Can be removed in case you do not use compose multiplatform +jetbrains-compose-plugin = { id = "org.jetbrains.compose", version.ref = "jetbrains-composePlugin" } jetbrains-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } # Convention plugins mateeStarter-android-application-compose = "android-application-compose:none" mateeStarter-android-application-core = "android-application:none" mateeStarter-android-library-compose = "android-library-compose:none" mateeStarter-android-library-core = "android-library:none" -mateeStarter-kmm-library = "kmm-library:none" -mateeStarter-kmm-xcframework-library = "kmm-xcframework-library:none" +mateeStarter-kmp-library-core = "kmp-library:none" +mateeStarter-kmp-library-compose = "kmp-library-compose:none" +mateeStarter-kmp-framework-library = "kmp-framework-library:none" +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } diff --git a/ios/Application/AppDelegate.swift b/ios/Application/AppDelegate.swift index b7ce973f..1552924f 100644 --- a/ios/Application/AppDelegate.swift +++ b/ios/Application/AppDelegate.swift @@ -9,12 +9,10 @@ import Atlantis import DependencyInjection import Factory -import KeychainProvider import OSLog import SharedDomain import UIKit import UIToolkit -import UserDefaultsProvider import Utilities import WidgetKit @@ -29,9 +27,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { setupEnvironment() - // Clear keychain on first run - clearKeychain() - // Setup firebase for debug firebaseDebugSetup() @@ -65,18 +60,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { #endif } - // MARK: Clear keychain - private func clearKeychain() { - do { - let _: Bool = try Container.shared.userDefaultsProvider().read(.hasRunBefore) - } catch UserDefaultsProviderError.valueForKeyNotFound { - do { - try Container.shared.keychainProvider().deleteAll() - try Container.shared.userDefaultsProvider().update(.hasRunBefore, value: true) - } catch {} - } catch {} - } - // MARK: Firebase debug setup private func firebaseDebugSetup() { // Enable Firebase Analytics debug mode for non production environments diff --git a/ios/Application/AppRootView.swift b/ios/Application/AppRootView.swift deleted file mode 100644 index aa9fd23f..00000000 --- a/ios/Application/AppRootView.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// Created by Tomáš Batěk on 22.10.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import KMPShared -import NavigatorUI -import Sample -import SampleComposeMultiplatform -import SampleComposeNavigation -import SampleSharedViewModel -import SwiftUI -import UIToolkit - -struct AppRootView: View { - - private let navigator = Navigator( - configuration: NavigationConfiguration() - ) - - var body: some View { - TabView { - sampleTab - - sampleSharedViewModelTab - - sampleComposeMultiplatformTab - - sampleComposeNavigationTab - } - .environment(\.navigator, navigator) - } - - @ViewBuilder - private var sampleTab: some View { - SampleView() - .tabItem { - Image(uiImage: AppTheme.Images.person) - Text(MR.strings().bottom_bar_item_1.toLocalized()) - } - } - - @ViewBuilder - private var sampleSharedViewModelTab: some View { - SampleSharedViewModelRootView() - .tabItem { - Image(uiImage: AppTheme.Images.personCirle) - - Text(MR.strings().bottom_bar_item_2.toLocalized()) - } - } - - @ViewBuilder - private var sampleComposeMultiplatformTab: some View { - SampleComposeMultiplatformView() - .tabItem { - Image(uiImage: AppTheme.Images.personSquare) - - Text(MR.strings().bottom_bar_item_3.toLocalized()) - } - } - - @ViewBuilder - private var sampleComposeNavigationTab: some View { - SampleComposeNavigationView() - .tabItem { - Image(uiImage: AppTheme.Images.personTwo) - - Text(MR.strings().bottom_bar_item_4.toLocalized()) - } - } -} diff --git a/ios/Application/DependencyInjection/Package.swift b/ios/Application/DependencyInjection/Package.swift index b24bce7f..fbe23dd5 100644 --- a/ios/Application/DependencyInjection/Package.swift +++ b/ios/Application/DependencyInjection/Package.swift @@ -11,10 +11,6 @@ let package = Package( .library( name: "DependencyInjection", targets: ["DependencyInjection"] - ), - .library( - name: "DependencyInjectionMocks", - targets: ["DependencyInjectionMocks"] ) ], dependencies: [ @@ -24,14 +20,7 @@ let package = Package( .package(url: "https://github.com/hmlongco/Factory.git", .upToNextMajor(from: "2.3.0")), .package(name: "SharedDomain", path: "../../DomainLayer/SharedDomain"), .package(name: "Utilities", path: "../../DomainLayer/Utilities"), - - // Toolkits - - // Providers - .package(name: "AnalyticsProvider", path: "../../DataLayer/Providers/AnalyticsProvider"), - .package(name: "KeychainProvider", path: "../../DataLayer/Providers/KeychainProvider"), - .package(name: "NetworkProvider", path: "../../DataLayer/Providers/NetworkProvider"), - .package(name: "UserDefaultsProvider", path: "../../DataLayer/Providers/UserDefaultsProvider") + .package(name: "AnalyticsProvider", path: "../../DataLayer/Providers/AnalyticsProvider") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -42,23 +31,7 @@ let package = Package( .product(name: "Factory", package: "Factory"), .product(name: "SharedDomain", package: "SharedDomain"), .product(name: "Utilities", package: "Utilities"), - - // Toolkits - - // Providers - .product(name: "AnalyticsProvider", package: "AnalyticsProvider"), - .product(name: "KeychainProvider", package: "KeychainProvider"), - .product(name: "NetworkProvider", package: "NetworkProvider"), - .product(name: "UserDefaultsProvider", package: "UserDefaultsProvider") - ] - ), - .target( - name: "DependencyInjectionMocks", - dependencies: [ - "DependencyInjection", - .product(name: "Factory", package: "Factory"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "SharedDomainMocks", package: "SharedDomain") + .product(name: "AnalyticsProvider", package: "AnalyticsProvider") ] ) ] diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPDependency.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPDependency.swift index 3a5c6424..739df246 100644 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPDependency.swift +++ b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPDependency.swift @@ -3,6 +3,7 @@ // Copyright © 2023 Matee. All rights reserved. // +import AnalyticsProvider import Factory import Foundation import KMPShared @@ -31,8 +32,12 @@ final class KMPKoinDependency: KMPDependency { let koinApplication = KoinIOSKt.doInitKoinIos( doOnStartup: onStartup, - analyticsProvider: Container.shared.analyticsProvider(), - config: ConfigImpl() + analyticsProvider: { () -> any AnalyticsProvider in + IosAnalyticsProviderImpl() + }, + config: { () -> any Config in + ConfigImpl() + } ) _koin = koinApplication.koin } diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPUseCases.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPUseCases.swift deleted file mode 100644 index e454adbb..00000000 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPUseCases.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Petr Chmelar on 06.10.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Factory -import KMPShared -import SharedDomain - -public extension Container { - // Koin - private var kmp: Factory { self { KMPKoinDependency() }.singleton } - - // Analytics - var trackAnalyticsEventUseCase: Factory { self { self.kmp().getProtocol(TrackAnalyticsEventUseCase.self) } } - - // Sample - var getSampleTextUseCase: Factory { self { self.kmp().getProtocol(GetSampleTextUseCase.self) } } -} diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift index 2db12053..550a047d 100644 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift +++ b/ios/Application/DependencyInjection/Sources/DependencyInjection/KMPViewModels.swift @@ -12,6 +12,5 @@ public extension Container { private var kmp: Factory { self { KMPKoinDependency() }.singleton } // Sample - var sampleSharedViewModel: Factory { self { self.kmp().get(SampleSharedViewModel.self) } } - var sampleNextViewModel: Factory { self { self.kmp().get(SampleNextViewModel.self) } } + var sampleFeatureViewModel: Factory { self { self.kmp().get(SampleFeatureViewModel.self) } } } diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/Providers.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/Providers.swift deleted file mode 100644 index 4cef22cb..00000000 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/Providers.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Created by Petr Chmelar on 06.10.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import AnalyticsProvider -import Factory -import KeychainProvider -import KMPShared -import NetworkProvider -import UIKit -import UserDefaultsProvider -import Utilities - -public extension Container { - var keychainProvider: Factory { self { SystemKeychainProvider() } } - var analyticsProvider: Factory { self { IosAnalyticsProviderImpl() } } - var networkProvider: Factory { self { - SystemNetworkProvider( - readAuthToken: { try self.keychainProvider().read(.authToken) }, - delegate: UIApplication.shared.delegate as? NetworkProviderDelegate - ) - }} - var userDefaultsProvider: Factory { self { SystemUserDefaultsProvider() } } -} diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/Repositories.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/Repositories.swift deleted file mode 100644 index 11182adc..00000000 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/Repositories.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Created by Petr Chmelar on 06.10.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Factory -import SharedDomain - -public extension Container { -// Example of registering a repository -// var analyticsRepository: Factory { self { -// AnalyticsRepositoryImpl( -// analyticsProvider: self.analyticsProvider() -// ) -// }} -} diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjection/UseCases.swift b/ios/Application/DependencyInjection/Sources/DependencyInjection/UseCases.swift deleted file mode 100644 index f5062971..00000000 --- a/ios/Application/DependencyInjection/Sources/DependencyInjection/UseCases.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Created by Petr Chmelar on 06.10.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Factory -import SharedDomain - -public extension Container { -// Example of registering a use case -// var trackAnalyticsEventUseCase: Factory { self { -// TrackAnalyticsEventUseCaseImpl( -// analyticsRepository: self.analyticsRepository() -// ) -// }} -} diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjectionMocks/UseCaseMocks.swift b/ios/Application/DependencyInjection/Sources/DependencyInjectionMocks/UseCaseMocks.swift deleted file mode 100644 index 0502266c..00000000 --- a/ios/Application/DependencyInjection/Sources/DependencyInjectionMocks/UseCaseMocks.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by Petr Chmelar on 07.10.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -#if DEBUG -import CoreLocation -import DependencyInjection -import Factory -import KMPShared -@testable import SharedDomain -import SharedDomainMocks - -public extension Container { - func registerUseCaseMocks() { - - // Sample - getSampleTextUseCase.register { GetSampleTextUseCaseMock(executeReturnValue: ResultSuccess(data: SampleText.stub)) } - } -} -#endif diff --git a/ios/Application/DependencyInjection/Sources/DependencyInjectionMocks/ViewModelMocks.swift b/ios/Application/DependencyInjection/Sources/DependencyInjectionMocks/ViewModelMocks.swift deleted file mode 100644 index fb36e347..00000000 --- a/ios/Application/DependencyInjection/Sources/DependencyInjectionMocks/ViewModelMocks.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by Julia Jakubcova on 02/08/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -#if DEBUG -import CoreLocation -import DependencyInjection -import Factory -import KMPShared -@testable import SharedDomain -import SharedDomainMocks - -public extension Container { - func registerViewModelMocks() { - - // Sample - sampleSharedViewModel.register { - SampleSharedViewModel(getSampleText: self.getSampleTextUseCase(), trackAnalyticsEventUseCase: self.trackAnalyticsEventUseCase()) - } - } -} -#endif diff --git a/ios/Application/MateeStarterApp.swift b/ios/Application/MateeStarterApp.swift index 34afc555..7c44025a 100644 --- a/ios/Application/MateeStarterApp.swift +++ b/ios/Application/MateeStarterApp.swift @@ -3,6 +3,7 @@ // Copyright © 2025 Matee. All rights reserved. // +import SampleFeature import SwiftUI import WidgetKit @@ -15,7 +16,7 @@ struct MateeStarterApp: App { var body: some Scene { WindowGroup { - AppRootView() + SampleFeatureView() } .onChange(of: scenePhase) { phase in switch phase { diff --git a/ios/DataLayer/Providers/AnalyticsProvider/Sources/AnalyticsProvider/IosAnalyticsProviderImpl.swift b/ios/DataLayer/Providers/AnalyticsProvider/Sources/AnalyticsProvider/IosAnalyticsProviderImpl.swift index 12dfbd66..ccb5e18a 100644 --- a/ios/DataLayer/Providers/AnalyticsProvider/Sources/AnalyticsProvider/IosAnalyticsProviderImpl.swift +++ b/ios/DataLayer/Providers/AnalyticsProvider/Sources/AnalyticsProvider/IosAnalyticsProviderImpl.swift @@ -19,7 +19,8 @@ public class IosAnalyticsProviderImpl: AnalyticsProvider { } } - public func logEvent(event: AnalyticsEvent) { + public func logEvent(event: AnalyticsEvent) -> Result { Analytics.logEvent(event.eventName, parameters: event.parameters) + return ResultSuccess(data: KotlinUnit()) } } diff --git a/ios/DataLayer/Providers/KeychainProvider/.gitignore b/ios/DataLayer/Providers/KeychainProvider/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/ios/DataLayer/Providers/KeychainProvider/Package.swift b/ios/DataLayer/Providers/KeychainProvider/Package.swift deleted file mode 100644 index b555cc46..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/Package.swift +++ /dev/null @@ -1,41 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "KeychainProvider", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "KeychainProvider", - targets: ["KeychainProvider"] - ), - .library( - name: "KeychainProviderMocks", - targets: ["KeychainProviderMocks"] - ) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", .upToNextMajor(from: "4.0.0")) - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "KeychainProvider", - dependencies: [ - .product(name: "KeychainAccess", package: "KeychainAccess") - ] - ), - .target( - name: "KeychainProviderMocks", - dependencies: [ - "KeychainProvider" - ] - ) - ] -) diff --git a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/Bundle+Extensions.swift b/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/Bundle+Extensions.swift deleted file mode 100644 index 9dc4824d..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/Bundle+Extensions.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Created by Petr Chmelar on 28.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -public extension Bundle { - /// Return the main bundle when in the app or an app extension. - /// Taken from: https://stackoverflow.com/questions/26189060 - static var app: Bundle { - var components = main.bundleURL.path.split(separator: "/") - var bundle: Bundle? - - if let index = components.lastIndex(where: { $0.hasSuffix(".app") }) { - components.removeLast((components.count - 1) - index) - bundle = Bundle(path: components.joined(separator: "/")) - } - - return bundle ?? main - } -} diff --git a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/KeychainProvider.swift b/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/KeychainProvider.swift deleted file mode 100644 index 0e27793c..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/KeychainProvider.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Created by Petr Chmelar on 01/08/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -public enum KeychainCoding: String, CaseIterable { - case authToken - case userId -} - -public protocol KeychainProvider { - - /// Try to read a value for the given key - func read(_ key: KeychainCoding) throws -> String - - /// Create or update the given key with a given value - func update(_ key: KeychainCoding, value: String) throws - - /// Delete value for the given key - func delete(_ key: KeychainCoding) throws - - /// Delete all records - func deleteAll() throws -} diff --git a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/KeychainProviderError.swift b/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/KeychainProviderError.swift deleted file mode 100644 index 86ca456f..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/KeychainProviderError.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Created by Petr Chmelar on 28.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -public enum KeychainProviderError: Error { - case invalidBundleIdentifier - case valueForKeyNotFound -} diff --git a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/SystemKeychainProvider.swift b/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/SystemKeychainProvider.swift deleted file mode 100644 index 3b551b43..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProvider/SystemKeychainProvider.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Created by Petr Chmelar on 01/08/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation -import KeychainAccess -import OSLog - -public struct SystemKeychainProvider { - public init() {} -} - -extension SystemKeychainProvider: KeychainProvider { - - public func read(_ key: KeychainCoding) throws -> String { - guard let bundleId = Bundle.app.bundleIdentifier else { throw KeychainProviderError.invalidBundleIdentifier } - let keychain = Keychain(service: bundleId, accessGroup: "group.\(bundleId)") - guard let value = keychain[key.rawValue] else { throw KeychainProviderError.valueForKeyNotFound } - return value - } - - public func update(_ key: KeychainCoding, value: String) throws { - guard let bundleId = Bundle.app.bundleIdentifier else { throw KeychainProviderError.invalidBundleIdentifier } - let keychain = Keychain(service: bundleId, accessGroup: "group.\(bundleId)") - keychain[key.rawValue] = value - } - - public func delete(_ key: KeychainCoding) throws { - guard let bundleId = Bundle.app.bundleIdentifier else { throw KeychainProviderError.invalidBundleIdentifier } - let keychain = Keychain(service: bundleId, accessGroup: "group.\(bundleId)") - try keychain.remove(key.rawValue) - } - - public func deleteAll() throws { - for key in KeychainCoding.allCases { - try delete(key) - } - } -} diff --git a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProviderMocks/KeychainProviderMock.swift b/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProviderMocks/KeychainProviderMock.swift deleted file mode 100644 index 68f22309..00000000 --- a/ios/DataLayer/Providers/KeychainProvider/Sources/KeychainProviderMocks/KeychainProviderMock.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// Created by Petr Chmelar on 28.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation -import KeychainProvider - -public final class KeychainProviderMock: KeychainProvider { - - public init() {} - - // MARK: - read - - public var readThrowableError: Error? - public var readCallsCount = 0 - public var readCalled: Bool { - return readCallsCount > 0 - } - public var readReceivedKey: KeychainCoding? - public var readReceivedInvocations: [KeychainCoding] = [] - public var readReturnValue: String! // swiftlint:disable:this implicitly_unwrapped_optional - public var readClosure: ((KeychainCoding) throws -> String)? - - public func read(_ key: KeychainCoding) throws -> String { - if let error = readThrowableError { - throw error - } - readCallsCount += 1 - readReceivedKey = key - readReceivedInvocations.append(key) - if let readClosure { - return try readClosure(key) - } else { - return readReturnValue - } - } - - // MARK: - update - - public var updateValueThrowableError: Error? - public var updateValueCallsCount = 0 - public var updateValueCalled: Bool { - return updateValueCallsCount > 0 - } - public var updateValueReceivedArguments: (key: KeychainCoding, value: String)? - public var updateValueReceivedInvocations: [(key: KeychainCoding, value: String)] = [] - public var updateValueClosure: ((KeychainCoding, String) throws -> Void)? - - public func update(_ key: KeychainCoding, value: String) throws { - if let error = updateValueThrowableError { - throw error - } - updateValueCallsCount += 1 - updateValueReceivedArguments = (key: key, value: value) - updateValueReceivedInvocations.append((key: key, value: value)) - try updateValueClosure?(key, value) - } - - // MARK: - delete - - public var deleteThrowableError: Error? - public var deleteCallsCount = 0 - public var deleteCalled: Bool { - return deleteCallsCount > 0 - } - public var deleteReceivedKey: KeychainCoding? - public var deleteReceivedInvocations: [KeychainCoding] = [] - public var deleteClosure: ((KeychainCoding) throws -> Void)? - - public func delete(_ key: KeychainCoding) throws { - if let error = deleteThrowableError { - throw error - } - deleteCallsCount += 1 - deleteReceivedKey = key - deleteReceivedInvocations.append(key) - try deleteClosure?(key) - } - - // MARK: - deleteAll - - public var deleteAllThrowableError: Error? - public var deleteAllCallsCount = 0 - public var deleteAllCalled: Bool { - return deleteAllCallsCount > 0 - } - public var deleteAllClosure: (() throws -> Void)? - - public func deleteAll() throws { - if let error = deleteAllThrowableError { - throw error - } - deleteAllCallsCount += 1 - try deleteAllClosure?() - } - -} diff --git a/ios/DataLayer/Providers/NetworkProvider/.gitignore b/ios/DataLayer/Providers/NetworkProvider/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/ios/DataLayer/Providers/NetworkProvider/Package.swift b/ios/DataLayer/Providers/NetworkProvider/Package.swift deleted file mode 100644 index 6a65410c..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Package.swift +++ /dev/null @@ -1,38 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "NetworkProvider", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "NetworkProvider", - targets: ["NetworkProvider"] - ), - .library( - name: "NetworkProviderMocks", - targets: ["NetworkProviderMocks"] - ) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "NetworkProvider", - dependencies: [] - ), - .target( - name: "NetworkProviderMocks", - dependencies: [ - "NetworkProvider" - ] - ) - ] -) diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/JSONEncoding.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/JSONEncoding.swift deleted file mode 100644 index 26fde014..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/JSONEncoding.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -// Taken from Alamofire - -import Foundation - -/// Uses `JSONSerialization` to create a JSON representation of the parameters object, which is set as the body of the request. -/// The `Content-Type` HTTP header field of an encoded request is set to `application/json`. -public struct JSONEncoding: ParameterEncoding { - - // MARK: Properties - - /// Returns a `JSONEncoding` instance with default writing options. - public static var `default`: JSONEncoding { JSONEncoding() } - - // MARK: Initialization - - public init() {} - - // MARK: Encoding - - public func encode(_ urlRequest: URLRequest, with parameters: [String: Any]) throws -> URLRequest { - var urlRequest = urlRequest - - let data = try JSONSerialization.data(withJSONObject: parameters) - urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.httpBody = data - - return urlRequest - } -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/ParameterEncoding.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/ParameterEncoding.swift deleted file mode 100644 index 7143ee68..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/ParameterEncoding.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -/// A type used to define how a set of parameters are applied to a `URLRequest`. -public protocol ParameterEncoding { - /// Creates a `URLRequest` by encoding parameters and applying them on the passed request. - /// - /// - Parameters: - /// - urlRequest: `URLRequest` value onto which parameters will be encoded. - /// - parameters: `[String: Any]` to encode onto the request. - /// - /// - Returns: The encoded `URLRequest`. - /// - Throws: Any `Error` produced during parameter encoding. - func encode(_ urlRequest: URLRequest, with parameters: [String: Any]) throws -> URLRequest -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/URLEncoding.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/URLEncoding.swift deleted file mode 100644 index e21979e8..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Encoding/URLEncoding.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -// Taken from Alamofire - -import Foundation - -/// Creates a url-encoded query string to be set as or appended to any existing URL query string. -public struct URLEncoding: ParameterEncoding { - - // MARK: Properties - - /// Returns a default `URLEncoding` instance with a `.methodDependent` destination. - public static var `default`: URLEncoding { URLEncoding() } - - // MARK: Initialization - - public init() {} - - // MARK: Encoding - - public func encode(_ urlRequest: URLRequest, with parameters: [String: Any]) throws -> URLRequest { - var urlRequest = urlRequest - - if let url = urlRequest.url, var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), !parameters.isEmpty { - let percentEncodedQuery = (urlComponents.percentEncodedQuery.map { $0 + "&" } ?? "") + query(parameters) - urlComponents.percentEncodedQuery = percentEncodedQuery - urlRequest.url = urlComponents.url - } - - return urlRequest - } - - /// Creates a percent-escaped, URL encoded query string components from the given key-value pair recursively. - /// - /// - Parameters: - /// - key: Key of the query component. - /// - value: Value of the query component. - /// - /// - Returns: The percent-escaped, URL encoded query string components. - public func queryComponents(fromKey key: String, value: Any) -> [(String, String)] { - var components: [(String, String)] = [] - switch value { - case let bool as Bool: - components.append((escape(key), escape(bool ? "true" : "false"))) - case let number as NSNumber: - components.append((escape(key), escape("\(number)"))) - default: - components.append((escape(key), escape("\(value)"))) - } - return components - } - - /// Creates a percent-escaped string following RFC 3986 for a query string key or value. - /// - /// - Parameter string: `String` to be percent-escaped. - /// - /// - Returns: The percent-escaped `String`. - public func escape(_ string: String) -> String { - string.addingPercentEncoding(withAllowedCharacters: .URLQueryAllowed) ?? string - } - - private func query(_ parameters: [String: Any]) -> String { - var components: [(String, String)] = [] - - for key in parameters.keys.sorted(by: <) { - let value = parameters[key]! - components += queryComponents(fromKey: key, value: value) - } - return components.map { "\($0)=\($1)" }.joined(separator: "&") - } -} - -public extension CharacterSet { - /// Creates a CharacterSet from RFC 3986 allowed characters. - /// - /// RFC 3986 states that the following characters are "reserved" characters. - /// - /// - General Delimiters: ":", "#", "[", "]", "@", "?", "/" - /// - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "=" - /// - /// In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow - /// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/" - /// should be percent-escaped in the query string. - static let URLQueryAllowed: CharacterSet = { - let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 - let subDelimitersToEncode = "!$&'()*+,;=" - let encodableDelimiters = CharacterSet(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") - - return CharacterSet.urlQueryAllowed.subtracting(encodableDelimiters) - }() -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Logger+Extensions.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Logger+Extensions.swift deleted file mode 100644 index b62cd33e..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/Logger+Extensions.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Created by Petr Chmelar on 05.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import OSLog - -extension Logger { - static func log(request: URLRequest) { - var requestLog = "\n-------------------------->\n" - requestLog += "➡️ \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")\n" - if let body = request.httpBody { - requestLog += "\(String(decoding: body, as: UTF8.self))\n" - } - Logger(subsystem: Bundle.main.bundleIdentifier ?? "-", category: "NetworkProvider").info("\(requestLog)") - } - - static func log(response: URLResponse, data: Data) { - guard let httpResponse = response as? HTTPURLResponse else { return } - var responseLog = "\n<--------------------------\n" - responseLog += "⬅️ \(httpResponse.url?.absoluteString ?? "")\n" - responseLog += (200...299).contains(httpResponse.statusCode) ? "✅" : "❌" - responseLog += " Status Code: \(httpResponse.statusCode)\n" - responseLog += "\(String(decoding: data, as: UTF8.self))\n" - Logger(subsystem: Bundle.main.bundleIdentifier ?? "-", category: "NetworkProvider").info("\(responseLog)") - } -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkEndpoint.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkEndpoint.swift deleted file mode 100644 index 83e3d0f3..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkEndpoint.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -public protocol NetworkEndpoint { - - /// The endpoint's base `URL`. - var baseURL: URL { get } - - /// The path to be appended to `baseURL` to form the full `URL`. - var path: String { get } - - /// The HTTP method used in the request. - var method: NetworkMethod { get } - - /// The headers to be used in the request. - var headers: [String: String]? { get } - - /// The type of HTTP task to be performed. - var task: NetworkTask { get } -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkMethod.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkMethod.swift deleted file mode 100644 index 61ff38ed..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkMethod.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -public enum NetworkMethod: String { - case delete = "DELETE" - case get = "GET" - case patch = "PATCH" - case post = "POST" - case put = "PUT" -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkProvider.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkProvider.swift deleted file mode 100644 index 8a3485ad..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkProvider.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Created by Petr Chmelar on 14/02/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import Foundation - -public protocol NetworkProviderDelegate: AnyObject { - func didReceiveHttpUnauthorized() -} - -public protocol NetworkProvider { - - var delegate: NetworkProviderDelegate? { get set } - - /// - /// Function for triggering a specified network call. - /// Automatically throws API errors. - /// - /// - parameter endpoint: NetworkTarget which specify API endpoint to be called. - /// - parameter withInterceptor: Optional parameter to specify whether build-in interceptor should be enabled. - /// - returns: Data from a network call. - /// - @discardableResult - func request(_ endpoint: NetworkEndpoint, withInterceptor: Bool) async throws -> Data -} - -// This extension exists only to provide default values for parameters -public extension NetworkProvider { - @discardableResult - func request(_ endpoint: NetworkEndpoint, withInterceptor: Bool = true) async throws -> Data { - try await request(endpoint, withInterceptor: withInterceptor) - } -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkProviderError.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkProviderError.swift deleted file mode 100644 index 3b036ff0..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkProviderError.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Created by Petr Chmelar on 13.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -public enum NetworkProviderError: Error { - case requestFailed(statusCode: NetworkStatusCode, message: String) -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkStatusCode.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkStatusCode.swift deleted file mode 100644 index facfb297..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkStatusCode.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Created by Petr Chmelar on 13.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -public enum NetworkStatusCode: Int { - case badRequest = 400 - case unathorized = 401 - case forbidden = 403 - case notFound = 404 - case methodNotAllowed = 405 - case conflict = 409 - case internalServerError = 500 - case unknown = 0 -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkTask.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkTask.swift deleted file mode 100644 index f4e7e8d7..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/NetworkTask.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -public enum NetworkTask { - /// A request with no additional data. - case requestPlain - - /// A requests body set with encoded parameters. - case requestParameters(parameters: [String: Any], encoding: ParameterEncoding) -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/SystemNetworkProvider.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/SystemNetworkProvider.swift deleted file mode 100644 index 24c046fd..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProvider/SystemNetworkProvider.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// Created by Petr Chmelar on 02.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import OSLog -import UIKit - -public struct SystemNetworkProvider { - - private let readAuthToken: () throws -> String - private weak var _delegate: NetworkProviderDelegate? - - public init( - readAuthToken: @escaping () throws -> String, - delegate: NetworkProviderDelegate? - ) { - self.readAuthToken = readAuthToken - self._delegate = delegate - } - - private let serviceHeaders = [ - "Client-Type": "ios", - "Client-App": Bundle.main.bundleIdentifier ?? "undefined", - "Client-AppVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "undefined", - "Client-OS": UIDevice.current.systemVersion, - "Client-HW": UIDevice.current.identifierForVendor?.uuidString ?? "undefined" - ] -} - -extension SystemNetworkProvider: NetworkProvider { - - public var delegate: NetworkProviderDelegate? { - get { - _delegate - } - set { - _delegate = newValue - } - } - - public func request(_ endpoint: NetworkEndpoint, withInterceptor: Bool) async throws -> Data { - - // Create request - var request = URLRequest(url: endpoint.baseURL.appendingPathComponent(endpoint.path)) - request.httpMethod = endpoint.method.rawValue - - // Add headers - serviceHeaders.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) } - if let headers = endpoint.headers { - headers.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) } - } - - // Add auth token if available - do { - let authToken = try readAuthToken() - request.addValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - } catch {} - - // Prepare request based on task - switch endpoint.task { - case .requestPlain: - break - case let .requestParameters(parameters, encoding): - request = try encoding.encode(request, with: parameters) - } - - // Fire request - #if DEBUG - Logger.log(request: request) - #endif - let (data, response) = try await URLSession.shared.data(for: request) - #if DEBUG - Logger.log(response: response, data: data) - #endif - - // Catch HTTP errors - if let httpResponse = response as? HTTPURLResponse { - if withInterceptor, httpResponse.statusCode == NetworkStatusCode.unathorized.rawValue { - delegate?.didReceiveHttpUnauthorized() - } - - if !(200...299).contains(httpResponse.statusCode) { - throw NetworkProviderError.requestFailed( - statusCode: NetworkStatusCode(rawValue: httpResponse.statusCode) ?? .unknown, - message: String(decoding: data, as: UTF8.self) - ) - } - } - - return data - } -} diff --git a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProviderMocks/NetworkProviderMock.swift b/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProviderMocks/NetworkProviderMock.swift deleted file mode 100644 index 314783f0..00000000 --- a/ios/DataLayer/Providers/NetworkProvider/Sources/NetworkProviderMocks/NetworkProviderMock.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Created by Petr Chmelar on 19/06/2020. -// Copyright © 2020 Matee. All rights reserved. -// - -import Foundation -import NetworkProvider - -public final class NetworkProviderMock { - - public var requestCallsCount = 0 - public var requestReturnData: Data? - public var requestReturnError: Error? - - private weak var _delegate: NetworkProviderDelegate? - - public init() {} -} - -extension NetworkProviderMock: NetworkProvider { - - public var delegate: NetworkProviderDelegate? { - get { - _delegate - } - set { - _delegate = newValue - } - } - - public func request(_ endpoint: NetworkEndpoint, withInterceptor: Bool) async throws -> Data { - requestCallsCount += 1 - if let error = requestReturnError { - throw error - } else if let data = requestReturnData { - return data - } else { - throw NetworkProviderError.requestFailed(statusCode: .notFound, message: "") - } - } -} diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/.gitignore b/ios/DataLayer/Providers/UserDefaultsProvider/.gitignore deleted file mode 100644 index 3b298120..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ -DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/Package.swift b/ios/DataLayer/Providers/UserDefaultsProvider/Package.swift deleted file mode 100644 index 042e0d4a..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/Package.swift +++ /dev/null @@ -1,38 +0,0 @@ -// swift-tools-version: 5.7 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "UserDefaultsProvider", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "UserDefaultsProvider", - targets: ["UserDefaultsProvider"] - ), - .library( - name: "UserDefaultsProviderMocks", - targets: ["UserDefaultsProviderMocks"] - ) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "UserDefaultsProvider", - dependencies: [] - ), - .target( - name: "UserDefaultsProviderMocks", - dependencies: [ - "UserDefaultsProvider" - ] - ) - ] -) diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/Bundle+Extensions.swift b/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/Bundle+Extensions.swift deleted file mode 100644 index 9dc4824d..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/Bundle+Extensions.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Created by Petr Chmelar on 28.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -public extension Bundle { - /// Return the main bundle when in the app or an app extension. - /// Taken from: https://stackoverflow.com/questions/26189060 - static var app: Bundle { - var components = main.bundleURL.path.split(separator: "/") - var bundle: Bundle? - - if let index = components.lastIndex(where: { $0.hasSuffix(".app") }) { - components.removeLast((components.count - 1) - index) - bundle = Bundle(path: components.joined(separator: "/")) - } - - return bundle ?? main - } -} diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/SystemUserDefaultsProvider.swift b/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/SystemUserDefaultsProvider.swift deleted file mode 100644 index 20b4d5cf..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/SystemUserDefaultsProvider.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Created by Petr Chmelar on 14/10/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import Foundation - -public struct SystemUserDefaultsProvider { - public init() {} -} - -extension SystemUserDefaultsProvider: UserDefaultsProvider { - - public func read(_ key: UserDefaultsCoding) throws -> T { - guard let bundleId = Bundle.app.bundleIdentifier, let defaults = UserDefaults(suiteName: "group.\(bundleId)") else { - throw UserDefaultsProviderError.invalidBundleIdentifier - } - guard let object = defaults.object(forKey: key.rawValue) as? T else { - throw UserDefaultsProviderError.valueForKeyNotFound - } - return object - } - - public func update(_ key: UserDefaultsCoding, value: T) throws { - guard let bundleId = Bundle.app.bundleIdentifier, let defaults = UserDefaults(suiteName: "group.\(bundleId)") else { - throw UserDefaultsProviderError.invalidBundleIdentifier - } - defaults.set(value, forKey: key.rawValue) - } - - public func delete(_ key: UserDefaultsCoding) throws { - guard let bundleId = Bundle.app.bundleIdentifier, let defaults = UserDefaults(suiteName: "group.\(bundleId)") else { - throw UserDefaultsProviderError.invalidBundleIdentifier - } - defaults.removeObject(forKey: key.rawValue) - } - - public func deleteAll() throws { - for key in UserDefaultsCoding.allCases { - try delete(key) - } - } -} diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/UserDefaultsProvider.swift b/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/UserDefaultsProvider.swift deleted file mode 100644 index e457e736..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/UserDefaultsProvider.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by Petr Chmelar on 14/10/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -public enum UserDefaultsCoding: String, CaseIterable { - case hasRunBefore -} - -public protocol UserDefaultsProvider { - - /// Try to read a value for the given key - func read(_ key: UserDefaultsCoding) throws -> T - - /// Create or update the given key with a given value - func update(_ key: UserDefaultsCoding, value: T) throws - - /// Delete value for the given key - func delete(_ key: UserDefaultsCoding) throws - - /// Delete all records - func deleteAll() throws -} diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/UserDefaultsProviderError.swift b/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/UserDefaultsProviderError.swift deleted file mode 100644 index 37d0a6ea..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProvider/UserDefaultsProviderError.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Created by Petr Chmelar on 28.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -public enum UserDefaultsProviderError: Error { - case invalidBundleIdentifier - case valueForKeyNotFound -} diff --git a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProviderMocks/UserDefaultsProviderMock.swift b/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProviderMocks/UserDefaultsProviderMock.swift deleted file mode 100644 index 4323ac25..00000000 --- a/ios/DataLayer/Providers/UserDefaultsProvider/Sources/UserDefaultsProviderMocks/UserDefaultsProviderMock.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Created by Petr Chmelar on 28.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation -import UserDefaultsProvider - -public final class UserDefaultsProviderMock { - - public var readCallsCount = 0 - public var readReturnValue: Any? - - public var updateCallsCount = 0 - public var deleteCallsCount = 0 - public var deleteAllCallsCount = 0 -} - -extension UserDefaultsProviderMock: UserDefaultsProvider { - - public func read(_ key: UserDefaultsCoding) throws -> T { - readCallsCount += 1 - guard let returnValue = readReturnValue as? T else { throw UserDefaultsProviderError.valueForKeyNotFound } - return returnValue - } - - public func update(_ key: UserDefaultsCoding, value: T) throws { - updateCallsCount += 1 - } - - public func delete(_ key: UserDefaultsCoding) throws { - deleteCallsCount += 1 - } - - public func deleteAll() throws { - deleteAllCallsCount += 1 - } -} diff --git a/ios/DomainLayer/SharedDomain/Package.swift b/ios/DomainLayer/SharedDomain/Package.swift index 6c1a4cbf..4738746a 100644 --- a/ios/DomainLayer/SharedDomain/Package.swift +++ b/ios/DomainLayer/SharedDomain/Package.swift @@ -14,10 +14,6 @@ let package = Package( .library( name: "SharedDomain", targets: ["SharedDomain"] - ), - .library( - name: "SharedDomainMocks", - targets: ["SharedDomainMocks"] ) ], dependencies: [ @@ -36,21 +32,6 @@ let package = Package( linkerSettings: [ .unsafeFlags(["-Xlinker", "-no_application_extension"]) ] - ), - .target( - name: "SharedDomainMocks", - dependencies: [ - "SharedDomain", - "Utilities" - ] - ), - .testTarget( - name: "SharedDomainTests", - dependencies: [ - "SharedDomain", - "SharedDomainMocks", - "Utilities" - ] ) ] ) diff --git a/ios/DomainLayer/SharedDomain/Sources/SharedDomain/SourceType.swift b/ios/DomainLayer/SharedDomain/Sources/SharedDomain/SourceType.swift deleted file mode 100644 index b7b7ce18..00000000 --- a/ios/DomainLayer/SharedDomain/Sources/SharedDomain/SourceType.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Created by Petr Chmelar on 08.03.2021 -// Copyright © 2021 Matee. All rights reserved. -// - -public enum SourceType: Equatable { - case local - case remote -} diff --git a/ios/DomainLayer/SharedDomain/Sources/SharedDomainMocks/Mocks/KMPSharedMocks.swift b/ios/DomainLayer/SharedDomain/Sources/SharedDomainMocks/Mocks/KMPSharedMocks.swift deleted file mode 100644 index db07d214..00000000 --- a/ios/DomainLayer/SharedDomain/Sources/SharedDomainMocks/Mocks/KMPSharedMocks.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Created by David Kadlček on 14.04.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Foundation -import KMPShared - -public final class GetSampleTextUseCaseMock: UseCaseResultNoParamsMock, GetSampleTextUseCase {} diff --git a/ios/DomainLayer/SharedDomain/Sources/SharedDomainMocks/Stubs/SampleText/SampleText+Stubs.swift b/ios/DomainLayer/SharedDomain/Sources/SharedDomainMocks/Stubs/SampleText/SampleText+Stubs.swift deleted file mode 100644 index b5654f97..00000000 --- a/ios/DomainLayer/SharedDomain/Sources/SharedDomainMocks/Stubs/SampleText/SampleText+Stubs.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Created by Petr Chmelar on 08.10.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import KMPShared - -public extension SampleText { - static let stub = SampleText( - value: "Mocked sample text value" - ) -} diff --git a/ios/DomainLayer/SharedDomain/Tests/SharedDomainTests/ArrayOfTuples+Equatable.swift b/ios/DomainLayer/SharedDomain/Tests/SharedDomainTests/ArrayOfTuples+Equatable.swift deleted file mode 100644 index f074bf4f..00000000 --- a/ios/DomainLayer/SharedDomain/Tests/SharedDomainTests/ArrayOfTuples+Equatable.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Created by Petr Chmelar on 29.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -// Returns true if arrays of tuples contains the same elements. -// https://github.com/apple/swift/issues/46668 - -// TODO: Class is a copy of the same file in Utilities package, remove - -public func == ( - lhs: [(A, B)], - rhs: [(A, B)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} - -// swiftlint:disable large_tuple -public func == ( - lhs: [(A, B, C)], - rhs: [(A, B, C)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} - -public func == ( - lhs: [(A, B, C, D)], - rhs: [(A, B, C, D)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} - -public func == ( - lhs: [(A, B, C, D, E)], - rhs: [(A, B, C, D, E)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} -// swiftlint:enable large_tuple diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/ArrayOfTuples+Equatable.swift b/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/ArrayOfTuples+Equatable.swift deleted file mode 100644 index f8e0b28e..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/ArrayOfTuples+Equatable.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// Created by Petr Chmelar on 29.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -// Returns true if arrays of tuples contains the same elements. -// https://github.com/apple/swift/issues/46668 - -public func == ( - lhs: [(A, B)], - rhs: [(A, B)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} - -// swiftlint:disable large_tuple -public func == ( - lhs: [(A, B, C)], - rhs: [(A, B, C)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} - -public func == ( - lhs: [(A, B, C, D)], - rhs: [(A, B, C, D)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} - -public func == ( - lhs: [(A, B, C, D, E)], - rhs: [(A, B, C, D, E)] -) -> Bool { - guard lhs.count == rhs.count else { return false } - for (idx, lElement) in lhs.enumerated() { - guard lElement == rhs[idx] else { - return false - } - } - return true -} -// swiftlint:enable large_tuple diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Data+Extensions.swift b/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Data+Extensions.swift deleted file mode 100644 index cd0a6dd6..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Data+Extensions.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Created by Petr Chmelar on 03.04.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -public extension Data { - func map(_ type: D.Type, atKeyPath keyPath: String? = nil) throws -> D { - if let keyPath { - let toplevel = try JSONSerialization.jsonObject(with: self) - if let nestedJson = (toplevel as AnyObject).value(forKeyPath: keyPath) { - if JSONSerialization.isValidJSONObject(nestedJson) { - let nestedJsonData = try JSONSerialization.data(withJSONObject: nestedJson) - return try JSONDecoder().decode(D.self, from: nestedJsonData) - } else { - let wrappedJsonObject = ["value": nestedJson] - let nestedJsonData = try JSONSerialization.data(withJSONObject: wrappedJsonObject) - return try JSONDecoder().decode(DecodableWrapper.self, from: nestedJsonData).value - } - } else { - throw DecodingError.dataCorrupted(.init( - codingPath: [], - debugDescription: "Nested JSON not found for key path \"\(keyPath)\"") - ) - } - } else { - return try JSONDecoder().decode(D.self, from: self) - } - } - - private struct DecodableWrapper: Decodable { - let value: T - } -} diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Decodable+Extensions.swift b/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Decodable+Extensions.swift deleted file mode 100644 index 29b71dc7..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Decodable+Extensions.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by Petr Chmelar on 12.03.2021 -// Copyright © 2021 Matee. All rights reserved. -// - -import Foundation - -public extension Decodable { - static func stub(in bundle: Bundle) -> Data { - stub(for: bundle.url(forResource: String(describing: self), withExtension: "json")) - } - - static func stubList(in bundle: Bundle) -> Data { - stub(for: bundle.url(forResource: "\(String(describing: self))List", withExtension: "json")) - } - - private static func stub(for url: URL?) -> Data { - guard let url, let data = try? Data(contentsOf: url) else { return Data() } - return data - } -} diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Encodable+Extensions.swift b/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Encodable+Extensions.swift deleted file mode 100644 index d634622d..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/Encodable+Extensions.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Created by Petr Chmelar on 28/08/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation - -public extension Encodable { - func encode() throws -> [String: Any] { - let data = try JSONEncoder().encode(self) - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw EncodingError.invalidValue(data, .init(codingPath: [], debugDescription: "Object can't be encoded")) - } - return json - } -} diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/String+Extensions.swift b/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/String+Extensions.swift deleted file mode 100644 index 278cb3a8..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/Extensions/String+Extensions.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Created by Petr Chmelar on 23/07/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation - -extension String { - /// Conversion from String to Date using a given formatter. - func toDate(formatter: DateFormatter = Formatter.Date.default) -> Date? { - formatter.date(from: self) - } -} diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/Formatter.swift b/ios/DomainLayer/Utilities/Sources/Utilities/Formatter.swift deleted file mode 100644 index 0dd0ec68..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/Formatter.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// Created by Petr Chmelar on 21/02/2020. -// Copyright © 2020 Matee. All rights reserved. -// - -import Foundation - -/// Helper for defining global formatters -/// - It is expensive to recreate formatters everytime you need them, so it is a good idea to hold them here -/// - Idea taken from [ChibiCode](http://www.chibicode.org/?p=41) -public struct Formatter { - - public enum Date { - public static let iso8601 = ISO8601DateFormatter() - public static let `default` = createDateFormatter() - } - - public enum Number { - public static let `default` = createNumberFormatter() - } - - /// Creates a DateFormatter based on a given date and time styles. - private static func createDateFormatter( - dateStyle: DateFormatter.Style = .medium, - timeStyle: DateFormatter.Style = .short - ) -> DateFormatter { - let formatter = DateFormatter() - formatter.dateStyle = dateStyle - formatter.timeStyle = timeStyle - formatter.doesRelativeDateFormatting = true - return formatter - } - - /// Creates a DateFormatter based on a given template. - private static func createDateFormatter(template: String) -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: template, options: 0, locale: formatter.locale) - formatter.doesRelativeDateFormatting = true - return formatter - } - - /// Creates a DateFormatter based on a given date format. - /// - Please note that this conversion does not respect user's locale/preferences. - private static func createDateFormatter(dateFormat: String) -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = dateFormat - return formatter - } - - /// Creates a NumberFormatter based on a given number style. - private static func createNumberFormatter( - numberStyle: NumberFormatter.Style = .decimal, - separator: String? = nil - ) -> NumberFormatter { - let formatter = NumberFormatter() - formatter.numberStyle = numberStyle - formatter.groupingSeparator = separator - return formatter - } -} diff --git a/ios/DomainLayer/Utilities/Sources/Utilities/NetworkingConstants.swift b/ios/DomainLayer/Utilities/Sources/Utilities/NetworkingConstants.swift deleted file mode 100644 index 7ac8f8db..00000000 --- a/ios/DomainLayer/Utilities/Sources/Utilities/NetworkingConstants.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Created by Petr Chmelar on 23/07/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -public struct NetworkingConstants { - public static var baseURL: String { - switch Environment.api { - case .alpha: "https://devstack-server-production.up.railway.app" - case .production: "https://devstack-server-production.up.railway.app" - } - } - - public static var rocketsURL: String { - switch Environment.api { - case .alpha: "https://apollo-fullstack-tutorial.herokuapp.com/graphql" - case .production: "https://apollo-fullstack-tutorial.herokuapp.com/graphql" - } - } - - public static var googleMapsAPIKey: String { - switch Environment.api { - case .alpha: "AIzaSyDj2cQ3ASrD9GCG7O3UIihcovCNihIXjDs" - case .production: "AIzaSyDj2cQ3ASrD9GCG7O3UIihcovCNihIXjDs" - } - } -} diff --git a/ios/MateeStarter.xcodeproj/project.pbxproj b/ios/MateeStarter.xcodeproj/project.pbxproj index 8db85cf4..81622897 100644 --- a/ios/MateeStarter.xcodeproj/project.pbxproj +++ b/ios/MateeStarter.xcodeproj/project.pbxproj @@ -19,13 +19,9 @@ 45BCC8802AD057F000672401 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = 45BCC87F2AD057F000672401 /* Factory */; }; 7E277A062D8C65FA0035B990 /* GoogleService-Info-Prod.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7E277A052D8C65FA0035B990 /* GoogleService-Info-Prod.plist */; }; 7E277A082D8C663C0035B990 /* GoogleService-Info-Alpha.plist in Resources */ = {isa = PBXBuildFile; fileRef = 7E277A072D8C663C0035B990 /* GoogleService-Info-Alpha.plist */; }; - A3009B982C5A6BA600806BA3 /* AnalyticsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3009B972C5A6BA600806BA3 /* AnalyticsProvider */; }; - A353123F2C5A71C6009E1A3D /* Sample in Frameworks */ = {isa = PBXBuildFile; productRef = A353123E2C5A71C6009E1A3D /* Sample */; }; - A37BB4DE2C5CF770006C491D /* SampleSharedViewModel in Frameworks */ = {isa = PBXBuildFile; productRef = A37BB4DD2C5CF770006C491D /* SampleSharedViewModel */; }; - A37BB4E12C5D1129006C491D /* SampleComposeMultiplatform in Frameworks */ = {isa = PBXBuildFile; productRef = A37BB4E02C5D1129006C491D /* SampleComposeMultiplatform */; }; - A38D916A2CC670DF00859170 /* SampleComposeNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = A38D91692CC670DF00859170 /* SampleComposeNavigation */; }; + A38F7A572F0EBC5700A03379 /* AnalyticsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A38F7A562F0EBC5700A03379 /* AnalyticsProvider */; }; + A3C5389F2F0EAA27008A995E /* SampleFeature in Frameworks */ = {isa = PBXBuildFile; productRef = A3C5389E2F0EAA27008A995E /* SampleFeature */; }; E21B465C2EAAD052003ADB5E /* MateeStarterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21B465B2EAAD04F003ADB5E /* MateeStarterApp.swift */; }; - E2D834AA2EA930BD00DA3A9F /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D834A92EA930BB00DA3A9F /* AppRootView.swift */; }; E2D834AD2EA9310600DA3A9F /* NavigatorUI in Frameworks */ = {isa = PBXBuildFile; productRef = E2D834AC2EA9310600DA3A9F /* NavigatorUI */; }; /* End PBXBuildFile section */ @@ -56,7 +52,6 @@ 4558D30A20FCC8FF005BC325 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 456E73DD25F817FA007D9A6D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4575E65525F6FA7600CCC003 /* AppIcon.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppIcon.xcassets; sourceTree = ""; }; - 45825BC32842961C003926B4 /* AnalyticsProvider */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AnalyticsProvider; sourceTree = ""; }; 4597626E20FDF0810034DE29 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 459896B026129CB300C852E0 /* Info+Proxyman.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info+Proxyman.plist"; sourceTree = ""; }; 459B777827737AFB00EAA694 /* KMPShared.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = KMPShared.xcframework; path = DomainLayer/KMPShared.xcframework; sourceTree = ""; }; @@ -68,15 +63,9 @@ 45FF99D92842B51800E32D9A /* Utilities */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Utilities; sourceTree = ""; }; 7E277A052D8C65FA0035B990 /* GoogleService-Info-Prod.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Prod.plist"; sourceTree = ""; }; 7E277A072D8C663C0035B990 /* GoogleService-Info-Alpha.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info-Alpha.plist"; sourceTree = ""; }; - A3009BAA2C5A6E5800806BA3 /* UserDefaultsProvider */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = UserDefaultsProvider; sourceTree = ""; }; - A3221E882CD8A44200A4BAE1 /* SampleComposeNavigation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SampleComposeNavigation; sourceTree = ""; }; - A37BB4DC2C5CD658006C491D /* SampleSharedViewModel */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SampleSharedViewModel; sourceTree = ""; }; - A37BB4DF2C5D0CE5006C491D /* SampleComposeMultiplatform */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SampleComposeMultiplatform; sourceTree = ""; }; - A3F048832C5A43E100B9A3F3 /* Sample */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Sample; sourceTree = ""; }; - A3F048842C5A587700B9A3F3 /* KeychainProvider */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = KeychainProvider; sourceTree = ""; }; - A3F048852C5A587700B9A3F3 /* NetworkProvider */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = NetworkProvider; sourceTree = ""; }; + A38F7A552F0EBBE400A03379 /* AnalyticsProvider */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AnalyticsProvider; sourceTree = ""; }; + A3C5389B2F0EA941008A995E /* SampleFeature */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SampleFeature; sourceTree = ""; }; E21B465B2EAAD04F003ADB5E /* MateeStarterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MateeStarterApp.swift; sourceTree = ""; }; - E2D834A92EA930BB00DA3A9F /* AppRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRootView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -84,17 +73,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - A3009B982C5A6BA600806BA3 /* AnalyticsProvider in Frameworks */, 4508333728437BB700BC9460 /* UIToolkit in Frameworks */, - A37BB4DE2C5CF770006C491D /* SampleSharedViewModel in Frameworks */, - A38D916A2CC670DF00859170 /* SampleComposeNavigation in Frameworks */, 459C3707274B001B00536110 /* Atlantis in Frameworks */, + A3C5389F2F0EAA27008A995E /* SampleFeature in Frameworks */, 4508333028435E7900BC9460 /* Utilities in Frameworks */, + A38F7A572F0EBC5700A03379 /* AnalyticsProvider in Frameworks */, 4508332A28435E7800BC9460 /* SharedDomain in Frameworks */, E2D834AD2EA9310600DA3A9F /* NavigatorUI in Frameworks */, 45BCC8802AD057F000672401 /* Factory in Frameworks */, - A37BB4E12C5D1129006C491D /* SampleComposeMultiplatform in Frameworks */, - A353123F2C5A71C6009E1A3D /* Sample in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -104,10 +90,7 @@ 453C8801283FA9CA0027BDFB /* PresentationLayer */ = { isa = PBXGroup; children = ( - A3F048832C5A43E100B9A3F3 /* Sample */, - A37BB4DC2C5CD658006C491D /* SampleSharedViewModel */, - A37BB4DF2C5D0CE5006C491D /* SampleComposeMultiplatform */, - A3221E882CD8A44200A4BAE1 /* SampleComposeNavigation */, + A3C5389B2F0EA941008A995E /* SampleFeature */, 4508333128436EFB00BC9460 /* UIToolkit */, 453580EE284806FE007AEC9E /* strings.txt */, ); @@ -123,22 +106,13 @@ path = DomainLayer; sourceTree = ""; }; - 453C8803283FA9D70027BDFB /* DataLayer */ = { - isa = PBXGroup; - children = ( - 45FF99D62842B05700E32D9A /* Toolkits */, - 45702E5928421D7F008D5487 /* Providers */, - ); - path = DataLayer; - sourceTree = ""; - }; 4558D2FE20FCC8FF005BC325 = { isa = PBXGroup; children = ( 4558D30920FCC8FF005BC325 /* Application */, 453C8801283FA9CA0027BDFB /* PresentationLayer */, 453C8802283FA9D10027BDFB /* DomainLayer */, - 453C8803283FA9D70027BDFB /* DataLayer */, + A38F7A522F0EBBB000A03379 /* DataLayer */, 4558D30820FCC8FF005BC325 /* Products */, 4576CEDF25ACF54F00218229 /* Frameworks */, ); @@ -161,7 +135,6 @@ 4575E61325F6F83600CCC003 /* GoogleService */, E21B465B2EAAD04F003ADB5E /* MateeStarterApp.swift */, 4558D30A20FCC8FF005BC325 /* AppDelegate.swift */, - E2D834A92EA930BB00DA3A9F /* AppRootView.swift */, 4575E65525F6FA7600CCC003 /* AppIcon.xcassets */, 4597626E20FDF0810034DE29 /* LaunchScreen.storyboard */, ); @@ -179,17 +152,6 @@ path = Info; sourceTree = ""; }; - 45702E5928421D7F008D5487 /* Providers */ = { - isa = PBXGroup; - children = ( - A3009BAA2C5A6E5800806BA3 /* UserDefaultsProvider */, - A3F048842C5A587700B9A3F3 /* KeychainProvider */, - A3F048852C5A587700B9A3F3 /* NetworkProvider */, - 45825BC32842961C003926B4 /* AnalyticsProvider */, - ); - path = Providers; - sourceTree = ""; - }; 4575E61225F6F82C00CCC003 /* Entitlements */ = { isa = PBXGroup; children = ( @@ -219,11 +181,20 @@ name = Frameworks; sourceTree = ""; }; - 45FF99D62842B05700E32D9A /* Toolkits */ = { + A38F7A402F0EBBB000A03379 /* Providers */ = { isa = PBXGroup; children = ( + A38F7A552F0EBBE400A03379 /* AnalyticsProvider */, ); - path = Toolkits; + path = Providers; + sourceTree = ""; + }; + A38F7A522F0EBBB000A03379 /* DataLayer */ = { + isa = PBXGroup; + children = ( + A38F7A402F0EBBB000A03379 /* Providers */, + ); + path = DataLayer; sourceTree = ""; }; /* End PBXGroup section */ @@ -252,12 +223,9 @@ 4508332F28435E7900BC9460 /* Utilities */, 4508333628437BB700BC9460 /* UIToolkit */, 45BCC87F2AD057F000672401 /* Factory */, - A3009B972C5A6BA600806BA3 /* AnalyticsProvider */, - A353123E2C5A71C6009E1A3D /* Sample */, - A37BB4DD2C5CF770006C491D /* SampleSharedViewModel */, - A37BB4E02C5D1129006C491D /* SampleComposeMultiplatform */, - A38D91692CC670DF00859170 /* SampleComposeNavigation */, E2D834AC2EA9310600DA3A9F /* NavigatorUI */, + A3C5389E2F0EAA27008A995E /* SampleFeature */, + A38F7A562F0EBC5700A03379 /* AnalyticsProvider */, ); productName = MateeStarter; productReference = 4558D30720FCC8FF005BC325 /* MateeStarter.app */; @@ -387,7 +355,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - E2D834AA2EA930BD00DA3A9F /* AppRootView.swift in Sources */, 4558D30B20FCC8FF005BC325 /* AppDelegate.swift in Sources */, E21B465C2EAAD052003ADB5E /* MateeStarterApp.swift in Sources */, ); @@ -869,25 +836,13 @@ package = 45BCC87E2AD057F000672401 /* XCRemoteSwiftPackageReference "Factory" */; productName = Factory; }; - A3009B972C5A6BA600806BA3 /* AnalyticsProvider */ = { + A38F7A562F0EBC5700A03379 /* AnalyticsProvider */ = { isa = XCSwiftPackageProductDependency; productName = AnalyticsProvider; }; - A353123E2C5A71C6009E1A3D /* Sample */ = { - isa = XCSwiftPackageProductDependency; - productName = Sample; - }; - A37BB4DD2C5CF770006C491D /* SampleSharedViewModel */ = { - isa = XCSwiftPackageProductDependency; - productName = SampleSharedViewModel; - }; - A37BB4E02C5D1129006C491D /* SampleComposeMultiplatform */ = { - isa = XCSwiftPackageProductDependency; - productName = SampleComposeMultiplatform; - }; - A38D91692CC670DF00859170 /* SampleComposeNavigation */ = { + A3C5389E2F0EAA27008A995E /* SampleFeature */ = { isa = XCSwiftPackageProductDependency; - productName = SampleComposeNavigation; + productName = SampleFeature; }; E2D834AC2EA9310600DA3A9F /* NavigatorUI */ = { isa = XCSwiftPackageProductDependency; diff --git a/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter.xcscheme b/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter.xcscheme index 6e843177..2cb30d29 100644 --- a/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter.xcscheme +++ b/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter.xcscheme @@ -64,147 +64,13 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + skipped = "NO"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + BlueprintIdentifier = "SampleFeatureTests" + BuildableName = "SampleFeatureTests" + BlueprintName = "SampleFeatureTests" + ReferencedContainer = "container:PresentationLayer/SampleFeature"> diff --git a/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme b/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme index 29d6bef9..68a44573 100644 --- a/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme +++ b/ios/MateeStarter.xcodeproj/xcshareddata/xcschemes/MateeStarter_Alpha.xcscheme @@ -77,147 +77,13 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + skipped = "NO"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + BlueprintIdentifier = "SampleFeatureTests" + BuildableName = "SampleFeatureTests" + BlueprintName = "SampleFeatureTests" + ReferencedContainer = "container:PresentationLayer/SampleFeature"> diff --git a/ios/MateeStarter.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/MateeStarter.xcworkspace/xcshareddata/swiftpm/Package.resolved index f731f2ac..4ee7725c 100644 --- a/ios/MateeStarter.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/MateeStarter.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,15 +99,6 @@ "version" : "100.0.0" } }, - { - "identity" : "keychainaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kishikawakatsumi/KeychainAccess.git", - "state" : { - "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", - "version" : "4.2.2" - } - }, { "identity" : "leveldb", "kind" : "remoteSourceControl", @@ -158,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", - "version" : "1.29.0" + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" } }, { diff --git a/ios/PresentationLayer/Sample/Sources/Sample/Main/SampleView.swift b/ios/PresentationLayer/Sample/Sources/Sample/Main/SampleView.swift deleted file mode 100644 index 4f665762..00000000 --- a/ios/PresentationLayer/Sample/Sources/Sample/Main/SampleView.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// Created by Petr Chmelar on 20.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import KMPShared -import NavigatorUI -import SwiftUI -import UIToolkit - -public struct SampleView: View { - - @StateObject private var viewModel = SampleViewModel() - - public init() {} - - public var body: some View { - ManagedNavigationStack { navigator in - ZStack { - switch viewModel.state.sampleText { - case let .data(text), let .loading(text): - contentView(sampleText: text) { - viewModel.onIntent(.onButtonTapped) - } - .skeleton(viewModel.state.sampleText.isLoading) - case let .error(error): - ErrorView(error: error) - case .empty: - EmptyView() - } - } - .navigationTitle(MR.strings().bottom_bar_item_1.toLocalized()) - .navigationBarTitleDisplayMode(.inline) - .toastView(Binding( - get: { viewModel.state.toast }, - set: { toast in viewModel.onIntent(.onToastChanged(data: toast)) } - )) - .task { await bindEvents(navigator: navigator) } - .lifecycle(viewModel) - } - } - - private func bindEvents(navigator: Navigator) async { - for await event in viewModel.events { - switch event { - case .showNextScreen: navigator.navigate(to: SampleDestination.next) - } - } - } - - private func contentView( - sampleText: SampleText, - onButtonTapped: @escaping () -> Void - ) -> some View { - VStack(spacing: AppTheme.Dimens.spaceMedium) { - Text("This is a sample with SwiftUI and iOS VM") - - Text(sampleText.value) - - Button("Click me!", action: onButtonTapped) - - Button("Show next") { - viewModel.onIntent(.onNextTapped) - } - } - } -} - -#if DEBUG -import DependencyInjectionMocks -import Factory - - #Preview { - let _ = fixMokoResourcesForPreviews() - let _ = Container.shared.registerUseCaseMocks() - - SampleView() - } -#endif diff --git a/ios/PresentationLayer/Sample/Sources/Sample/Main/SampleViewModel.swift b/ios/PresentationLayer/Sample/Sources/Sample/Main/SampleViewModel.swift deleted file mode 100644 index d44f1515..00000000 --- a/ios/PresentationLayer/Sample/Sources/Sample/Main/SampleViewModel.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// Created by Petr Chmelar on 20.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import DependencyInjection -import Factory -import KMPShared -import SharedDomain -import SharedDomainMocks -import SwiftUI -import UIToolkit - -public final class SampleViewModel: UIToolkit.BaseViewModel, ViewModel, ObservableObject { - - @Injected(\.getSampleTextUseCase) private(set) var getSampleTextUseCase - @Injected(\.trackAnalyticsEventUseCase) private(set) var trackAnalyticsEventUseCase - - // MARK: Lifecycle - - override public func onAppear() { - super.onAppear() - executeTask(Task { await loadSampleText() }) - } - - // MARK: State - - @Published public private(set) var state: State = State() - - public struct State { - var sampleText: ViewData = .loading(mock: .stub) - var toast: ToastData? - } - - // MARK: Events - - public enum Event: ViewModelEvent { - case showNextScreen - } - - public var events: AsyncStream { - AsyncStream { continuation in - self._events = continuation - } - } - - private var _events: AsyncStream.Continuation? - - // MARK: Intent - - public enum Intent { - case onButtonTapped - case onToastChanged(data: ToastData?) - case onNextTapped - } - - public func onIntent(_ intent: Intent) { - executeTask(Task { - switch intent { - case .onButtonTapped: showToast(message: "Button was tapped") - case .onToastChanged(let data): state.toast = data - case .onNextTapped: _events?.yield(.showNextScreen) - } - }) - } - - // MARK: Private - - private func loadSampleText() async { - await execute { - // Do the business logic - state.sampleText = .data(try await getSampleTextUseCase.execute()) - } onError: { error in - // Handle error - state.sampleText = .error(error) - } onCancel: { _ in - // Custom cancel handling - } - } - - private func showToast(message: String) { - Task { - try? await trackAnalyticsEventUseCase.invoke( - params: TrackAnalyticsEventUseCaseParams(event: ToastAnalytics.ToastPresentedEvent(viewType: .native)) - ) - } - - state.toast = ToastData(message, hideAfter: 2) - } -} diff --git a/ios/PresentationLayer/Sample/Sources/Sample/Next/NextView.swift b/ios/PresentationLayer/Sample/Sources/Sample/Next/NextView.swift deleted file mode 100644 index 4ee2259b..00000000 --- a/ios/PresentationLayer/Sample/Sources/Sample/Next/NextView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Created by Tomáš Batěk on 21.11.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import KMPShared -import NavigatorUI -import SwiftUI - -struct NextView: View { - - @Environment(\.navigator) private var navigator - - var body: some View { - VStack { - Text("Hello there") - - Button(MR.strings().back.toLocalized()) { - navigator.dismiss() - } - } - .presentationDetents([.medium]) - } -} diff --git a/ios/PresentationLayer/Sample/Sources/Sample/SampleDestination.swift b/ios/PresentationLayer/Sample/Sources/Sample/SampleDestination.swift deleted file mode 100644 index 2874f177..00000000 --- a/ios/PresentationLayer/Sample/Sources/Sample/SampleDestination.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Created by Tomáš Batěk on 22.10.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import NavigatorUI -import SwiftUI - -enum SampleDestination { - case next -} - -extension SampleDestination: NavigationDestination { - var body: some View { - switch self { - case .next: NextView() - } - } - - var method: NavigationMethod { - switch self { - case .next: .sheet - } - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/.gitignore b/ios/PresentationLayer/SampleComposeMultiplatform/.gitignore deleted file mode 100644 index 0023a534..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Package.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Package.swift deleted file mode 100644 index 2a99224b..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Package.swift +++ /dev/null @@ -1,52 +0,0 @@ -// swift-tools-version: 5.10 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "SampleComposeMultiplatform", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "SampleComposeMultiplatform", - targets: ["SampleComposeMultiplatform"] - ) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(name: "UIToolkit", path: "../UIToolkit"), - .package(name: "Utilities", path: "../../DomainLayer/Utilities"), - .package(name: "SharedDomain", path: "../../DomainLayer/SharedDomain"), - .package(name: "DependencyInjection", path: "../../Application/DependencyInjection"), - .package(url: "https://github.com/hmlongco/Factory.git", .upToNextMajor(from: "2.3.0")), - .package(url: "https://github.com/hmlongco/Navigator", .upToNextMajor(from: "1.3.1")) - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "SampleComposeMultiplatform", - dependencies: [ - .product(name: "UIToolkit", package: "UIToolkit"), - .product(name: "Utilities", package: "Utilities"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "DependencyInjectionMocks", package: "DependencyInjection"), - .product(name: "Factory", package: "Factory"), - .product(name: "NavigatorUI", package: "Navigator") - ] - ), - .testTarget( - name: "SampleComposeMultiplatformTests", - dependencies: [ - "SampleComposeMultiplatform", - .product(name: "UIToolkit", package: "UIToolkit"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "Factory", package: "Factory") - ] - ) - ] -) diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/CheckboxView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/CheckboxView.swift deleted file mode 100644 index f04a93da..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/CheckboxView.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Created by Julia Jakubcova on 25/11/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import KMPShared -import SwiftUI - -struct CheckboxView: View { - - @ObservedObject var observable: PlatformSpecificCheckboxViewObservable - - init(observable: PlatformSpecificCheckboxViewObservable) { - self.observable = observable - } - - var body: some View { - HStack { - Image(systemSymbol: observable.checked ? .checkmarkSquareFill : .square) - .foregroundColor(observable.checked ? Color(UIColor.systemBlue) : Color.secondary) - .onTapGesture { - observable.onCheckedChanged(KotlinBoolean(value: !observable.checked)) - } - - Text(observable.text) - } - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/PlatformSpecificCheckboxViewObservable.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/PlatformSpecificCheckboxViewObservable.swift deleted file mode 100644 index 09dac112..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/PlatformSpecificCheckboxViewObservable.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Created by Julia Jakubcova on 29/11/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import KMPShared -import SwiftUI - -class PlatformSpecificCheckboxViewObservable: ObservableObject, PlatformSpecificCheckboxViewDelegate { - - @Published var checked: Bool - @Published var onCheckedChanged: (KotlinBoolean) -> Void - @Published var text: String - - init(checked: Bool, onCheckedChanged: @escaping (KotlinBoolean) -> Void, text: String) { - self.checked = checked - self.onCheckedChanged = onCheckedChanged - self.text = text - } - - func updateChecked(checked: Bool) { - self.checked = checked - } - - func updateOnCheckedChanged(onCheckedChanged: @escaping (KotlinBoolean) -> Void) { - self.onCheckedChanged = onCheckedChanged - } - - func updateText(text: String) { - self.text = text - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift deleted file mode 100644 index 4d39128f..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/PlatformSpecificView/SwiftUIComposeMultiplatformViewFactory.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by Julia Jakubcova on 25/11/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import KMPShared -import SwiftUI - -public class SwiftUISampleComposeMultiplatformViewFactory: ComposeSampleComposeMultiplatformViewFactory { - - public init() {} - - public func createPlatformSpecificCheckboxView( - text: String, - checked: Bool, - onCheckedChanged: @escaping (KotlinBoolean) -> Void - ) -> KotlinPair { - let observable = PlatformSpecificCheckboxViewObservable(checked: checked, onCheckedChanged: onCheckedChanged, text: text) - let viewController: UIViewController = UIHostingController(rootView: CheckboxView(observable: observable)) - - return KotlinPair(first: viewController, second: observable) - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformDestination.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformDestination.swift deleted file mode 100644 index fef56816..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformDestination.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Tomáš Batěk on 22.10.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import NavigatorUI -import SwiftUI - -enum SampleComposeMultiplatformDestination { - case next -} - -extension SampleComposeMultiplatformDestination: NavigationDestination { - var body: some View { - switch self { - case .next: SampleNextView() - } - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift deleted file mode 100644 index b97ec7f1..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleComposeMultiplatformView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Created by Julia Jakubcova on 02/08/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import DependencyInjection -import Factory -import KMPShared -import NavigatorUI -import SwiftUI -import UIToolkit - -public struct SampleComposeMultiplatformView: View { - - @State private var toastData: ToastData? - - public init() {} - - public var body: some View { - ManagedNavigationStack { navigator in - ComposeViewController { - SampleComposeMultiplatformScreenViewController( - onEvent: { event in - switch onEnum(of: event) { - case .showMessage(let message): - toastData = ToastData(message.message, hideAfter: 2) - case .goToNext: - navigator.navigate(to: SampleComposeMultiplatformDestination.next) - } - }, - factory: SwiftUISampleComposeMultiplatformViewFactory() - ) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .toastView($toastData) - .navigationTitle(MR.strings().bottom_bar_item_3.toLocalized()) - .navigationBarTitleDisplayMode(.inline) - } - .tint(AppTheme.Colors.navBarTitle) // Back button color - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleNextView.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleNextView.swift deleted file mode 100644 index a6330983..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Sources/SampleComposeMultiplatform/SampleNextView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Created by Julia Jakubcova on 02/08/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import DependencyInjection -import Factory -import KMPShared -import NavigatorUI -import SwiftUI -import UIToolkit - -public struct SampleNextView: View { - - @State private var toastData: ToastData? - - @Environment(\.navigator) private var navigator - - public var body: some View { - // Wrap compose multiplatform view in ZStack for the swipe back to work reliably - ZStack { - Color.blue - .frame(maxWidth: .infinity, maxHeight: .infinity) - - ComposeViewController { - SampleNextScreenViewController( - onEvent: { event in - switch onEnum(of: event) { - case .showMessage(let message): - toastData = ToastData(message.message, hideAfter: 2) - case .navigateBack: navigator.pop() - } - } - ) - } - } - .navigationTitle(MR.strings().next_screen_title.toLocalized()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .toastView($toastData) - } -} diff --git a/ios/PresentationLayer/SampleComposeMultiplatform/Tests/SampleComposeMultiplatformTests/SampleComposeMultiplatformTests.swift b/ios/PresentationLayer/SampleComposeMultiplatform/Tests/SampleComposeMultiplatformTests/SampleComposeMultiplatformTests.swift deleted file mode 100644 index dace2c60..00000000 --- a/ios/PresentationLayer/SampleComposeMultiplatform/Tests/SampleComposeMultiplatformTests/SampleComposeMultiplatformTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -@testable import SampleComposeMultiplatform -import XCTest - -final class SampleComposeMultiplatformTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/ios/PresentationLayer/SampleComposeNavigation/.gitignore b/ios/PresentationLayer/SampleComposeNavigation/.gitignore deleted file mode 100644 index 0023a534..00000000 --- a/ios/PresentationLayer/SampleComposeNavigation/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/ios/PresentationLayer/SampleComposeNavigation/Package.swift b/ios/PresentationLayer/SampleComposeNavigation/Package.swift deleted file mode 100644 index 6ee01f8b..00000000 --- a/ios/PresentationLayer/SampleComposeNavigation/Package.swift +++ /dev/null @@ -1,55 +0,0 @@ -// swift-tools-version: 6.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "SampleComposeNavigation", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "SampleComposeNavigation", - targets: ["SampleComposeNavigation"] - ) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(name: "UIToolkit", path: "../UIToolkit"), - .package(name: "Utilities", path: "../../DomainLayer/Utilities"), - .package(name: "SharedDomain", path: "../../DomainLayer/SharedDomain"), - .package(name: "DependencyInjection", path: "../../Application/DependencyInjection"), - .package(name: "SampleComposeMultiplatform", path: "../SampleComposeMultiplatform"), - .package(url: "https://github.com/hmlongco/Factory.git", .upToNextMajor(from: "2.3.0")), - .package(url: "https://github.com/hmlongco/Navigator", .upToNextMajor(from: "1.3.1")) - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "SampleComposeNavigation", - dependencies: [ - .product(name: "UIToolkit", package: "UIToolkit"), - .product(name: "Utilities", package: "Utilities"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "DependencyInjectionMocks", package: "DependencyInjection"), - .product(name: "SampleComposeMultiplatform", package: "SampleComposeMultiplatform"), - .product(name: "Factory", package: "Factory"), - .product(name: "NavigatorUI", package: "Navigator") - ] - ), - .testTarget( - name: "SampleComposeNavigationTests", - dependencies: [ - "SampleComposeNavigation", - .product(name: "UIToolkit", package: "UIToolkit"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "SampleComposeMultiplatform", package: "SampleComposeMultiplatform"), - .product(name: "Factory", package: "Factory") - ] - ) - ] -) diff --git a/ios/PresentationLayer/SampleComposeNavigation/Sources/SampleComposeNavigation/SampleComposeNavigationDestination.swift b/ios/PresentationLayer/SampleComposeNavigation/Sources/SampleComposeNavigation/SampleComposeNavigationDestination.swift deleted file mode 100644 index f3a96182..00000000 --- a/ios/PresentationLayer/SampleComposeNavigation/Sources/SampleComposeNavigation/SampleComposeNavigationDestination.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Tomáš Batěk on 22.10.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import NavigatorUI -import SwiftUI - -enum SampleComposeNavigationDestination { - case sample -} - -extension SampleComposeNavigationDestination: NavigationDestination { - var body: some View { - switch self { - case .sample: EmptyView() - } - } -} diff --git a/ios/PresentationLayer/SampleComposeNavigation/Sources/SampleComposeNavigation/SampleComposeNavigationView.swift b/ios/PresentationLayer/SampleComposeNavigation/Sources/SampleComposeNavigation/SampleComposeNavigationView.swift deleted file mode 100644 index 703747d6..00000000 --- a/ios/PresentationLayer/SampleComposeNavigation/Sources/SampleComposeNavigation/SampleComposeNavigationView.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Created by Julia Jakubcova on 02/08/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import DependencyInjection -import Factory -import KMPShared -import NavigatorUI -import SampleComposeMultiplatform -import SwiftUI -import UIToolkit - -public struct SampleComposeNavigationView: View { - - @State private var toastData: ToastData? - - public init() {} - - public var body: some View { - ComposeViewController { - SampleWithComposeNavigationViewController( - showMessage: { message in - toastData = ToastData(message, hideAfter: 2) - }, - factory: SwiftUISampleComposeMultiplatformViewFactory() - ) - } - .toastView($toastData) - .navigationBarHidden(true) - .ignoresSafeArea(edges: [.top]) - } -} diff --git a/ios/PresentationLayer/SampleComposeNavigation/Tests/SampleComposeNavigationTests/SampleComposeNavigationTests.swift b/ios/PresentationLayer/SampleComposeNavigation/Tests/SampleComposeNavigationTests/SampleComposeNavigationTests.swift deleted file mode 100644 index 83152668..00000000 --- a/ios/PresentationLayer/SampleComposeNavigation/Tests/SampleComposeNavigationTests/SampleComposeNavigationTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -@testable import SampleComposeNavigation -import XCTest - -final class SampleComposeNavigationTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/ios/PresentationLayer/Sample/.gitignore b/ios/PresentationLayer/SampleFeature/.gitignore similarity index 100% rename from ios/PresentationLayer/Sample/.gitignore rename to ios/PresentationLayer/SampleFeature/.gitignore diff --git a/ios/PresentationLayer/Sample/Package.swift b/ios/PresentationLayer/SampleFeature/Package.swift similarity index 89% rename from ios/PresentationLayer/Sample/Package.swift rename to ios/PresentationLayer/SampleFeature/Package.swift index c23a3d81..0ef047fc 100644 --- a/ios/PresentationLayer/Sample/Package.swift +++ b/ios/PresentationLayer/SampleFeature/Package.swift @@ -4,13 +4,13 @@ import PackageDescription let package = Package( - name: "Sample", + name: "SampleFeature", platforms: [.iOS(.v16)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "Sample", - targets: ["Sample"] + name: "SampleFeature", + targets: ["SampleFeature"] ) ], dependencies: [ @@ -27,21 +27,20 @@ let package = Package( // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "Sample", + name: "SampleFeature", dependencies: [ .product(name: "UIToolkit", package: "UIToolkit"), .product(name: "Utilities", package: "Utilities"), .product(name: "SharedDomain", package: "SharedDomain"), .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "DependencyInjectionMocks", package: "DependencyInjection"), .product(name: "Factory", package: "Factory"), .product(name: "NavigatorUI", package: "Navigator") ] ), .testTarget( - name: "SampleTests", + name: "SampleFeatureTests", dependencies: [ - "Sample", + "SampleFeature", .product(name: "UIToolkit", package: "UIToolkit"), .product(name: "SharedDomain", package: "SharedDomain"), .product(name: "DependencyInjection", package: "DependencyInjection"), diff --git a/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift b/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift new file mode 100644 index 00000000..4e112710 --- /dev/null +++ b/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift @@ -0,0 +1,38 @@ +// +// Created by Julia Jakubcova on 02/08/2024 +// Copyright © 2024 Matee. All rights reserved. +// + +import DependencyInjection +import Factory +import KMPShared +import NavigatorUI +import SwiftUI +import UIToolkit + +public struct SampleFeatureView: View { + + @State private var toastData: ToastData? + @Injected(\.sampleFeatureViewModel) private var viewModel: SampleFeatureViewModel + + public init() {} + + public var body: some View { + ManagedNavigationStack { _ in + ComposeViewController { + SampleFeatureMainScreenViewController(viewModel: viewModel) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .tint(AppTheme.Colors.navBarTitle) // Back button color + .toastView($toastData) + .bindViewModel(viewModel, onEvent: onEvent) + } + + private func onEvent(_ event: SampleFeatureEvent) { + switch onEnum(of: event) { + case .showMessage(let data): + toastData = ToastData(data.message, hideAfter: 2) + } + } +} diff --git a/ios/PresentationLayer/Sample/Tests/SampleTests/SampleViewModelTests.swift b/ios/PresentationLayer/SampleFeature/Tests/SampleFeatureTests/SampleFeatureTests.swift similarity index 79% rename from ios/PresentationLayer/Sample/Tests/SampleTests/SampleViewModelTests.swift rename to ios/PresentationLayer/SampleFeature/Tests/SampleFeatureTests/SampleFeatureTests.swift index c49e30ed..cd67880f 100644 --- a/ios/PresentationLayer/Sample/Tests/SampleTests/SampleViewModelTests.swift +++ b/ios/PresentationLayer/SampleFeature/Tests/SampleFeatureTests/SampleFeatureTests.swift @@ -1,7 +1,7 @@ -@testable import Sample +@testable import SampleFeature import XCTest -final class SampleTests: XCTestCase { +final class SampleFeatureTests: XCTestCase { func testExample() throws { // XCTest Documentation // https://developer.apple.com/documentation/xctest diff --git a/ios/PresentationLayer/SampleSharedViewModel/.gitignore b/ios/PresentationLayer/SampleSharedViewModel/.gitignore deleted file mode 100644 index 0023a534..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/ios/PresentationLayer/SampleSharedViewModel/Package.swift b/ios/PresentationLayer/SampleSharedViewModel/Package.swift deleted file mode 100644 index fa4b8803..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/Package.swift +++ /dev/null @@ -1,52 +0,0 @@ -// swift-tools-version: 5.10 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "SampleSharedViewModel", - platforms: [.iOS(.v16)], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "SampleSharedViewModel", - targets: ["SampleSharedViewModel"] - ) - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - .package(name: "UIToolkit", path: "../UIToolkit"), - .package(name: "Utilities", path: "../../DomainLayer/Utilities"), - .package(name: "SharedDomain", path: "../../DomainLayer/SharedDomain"), - .package(name: "DependencyInjection", path: "../../Application/DependencyInjection"), - .package(url: "https://github.com/hmlongco/Factory.git", .upToNextMajor(from: "2.3.0")), - .package(url: "https://github.com/hmlongco/Navigator", .upToNextMajor(from: "1.3.1")) - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "SampleSharedViewModel", - dependencies: [ - .product(name: "UIToolkit", package: "UIToolkit"), - .product(name: "Utilities", package: "Utilities"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "DependencyInjectionMocks", package: "DependencyInjection"), - .product(name: "Factory", package: "Factory"), - .product(name: "NavigatorUI", package: "Navigator") - ] - ), - .testTarget( - name: "SampleSharedViewModelTests", - dependencies: [ - "SampleSharedViewModel", - .product(name: "UIToolkit", package: "UIToolkit"), - .product(name: "SharedDomain", package: "SharedDomain"), - .product(name: "DependencyInjection", package: "DependencyInjection"), - .product(name: "Factory", package: "Factory") - ] - ) - ] -) diff --git a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/Main/SampleSharedViewModelView.swift b/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/Main/SampleSharedViewModelView.swift deleted file mode 100644 index 216106dd..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/Main/SampleSharedViewModelView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// Created by Julia Jakubcova on 02/08/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import DependencyInjection -import Factory -import KMPShared -import NavigatorUI -import SwiftUI -import UIToolkit - -public struct SampleSharedViewModelView: View { - - @Injected(\.sampleSharedViewModel) private var viewModel: KMPShared.SampleSharedViewModel - @State private var state = SampleSharedState() - - @State private var toastData: ToastData? - - public init() {} - - public var body: some View { - ZStack(alignment: .center) { - if state.loading { - PrimaryProgressView() - } else { - VStack(spacing: AppTheme.Dimens.spaceMedium) { - Text("This is a sample with SwiftUI and shared VM") - - Text(state.sampleText?.value ?? "") - - Button("Click me!") { - viewModel.onIntent(.OnButtonTapped()) - } - } - } - } - .bindViewModel( - viewModel, - state: $state, - onEvent: { event in - switch onEnum(of: event) { - case .showMessage(let message): - toastData = ToastData(message.message, hideAfter: 2) - case .goToNext: - print("Should navigate to next screen") - } - } - ) - .toastView($toastData) - } -} - -#if DEBUG -import DependencyInjectionMocks -import Factory - - #Preview { - let _ = fixMokoResourcesForPreviews() - let _ = Container.shared.registerUseCaseMocks() - let _ = Container.shared.registerViewModelMocks() - - SampleSharedViewModelView() - } -#endif diff --git a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/SampleSharedViewModelDestination.swift b/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/SampleSharedViewModelDestination.swift deleted file mode 100644 index 3e9a5a09..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/SampleSharedViewModelDestination.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Created by Tomáš Batěk on 22.10.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import NavigatorUI -import SwiftUI - -enum SampleSharedViewModelDestination { - case sample -} - -extension SampleSharedViewModelDestination: NavigationDestination { - var body: some View { - switch self { - case .sample: SampleSharedViewModelView() - } - } -} diff --git a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/SampleSharedViewModelRootView.swift b/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/SampleSharedViewModelRootView.swift deleted file mode 100644 index 12310bdf..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/SampleSharedViewModelRootView.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Created by Tomáš Batěk on 21.11.2025 -// Copyright © 2025 Matee. All rights reserved. -// - -import KMPShared -import NavigatorUI -import SwiftUI -import UIToolkit - -public struct SampleSharedViewModelRootView: View { - - public init() {} - - public var body: some View { - ManagedNavigationStack { navigator in - VStack { - Spacer() - - Button(MR.strings().next.toLocalized()) { - navigator.navigate(to: SampleSharedViewModelDestination.sample) - } - .buttonStyle(.primary(isLarge: false)) - - Spacer() - } - .navigationTitle(MR.strings().bottom_bar_item_2.toLocalized()) - .navigationBarTitleDisplayMode(.inline) - } - } -} diff --git a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/Toolkit/View+Extension.swift b/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/Toolkit/View+Extension.swift deleted file mode 100644 index 5b38f9f1..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/Sources/SampleSharedViewModel/Toolkit/View+Extension.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Created by Julia Jakubcova on 02/08/2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import KMPShared -import SwiftUI -import UIToolkit - -@MainActor -public extension View { - @inlinable func bindViewModel( - _ viewModel: BaseScopedViewModel, - state: Binding, - onEvent: @escaping (E) -> Void - ) -> some View { - self - .task { - for await value in viewModel.state { - state.wrappedValue = value - } - } - .task { - // Make sure that onViewAppeared will be called after event subcsription - Task { - viewModel.onViewAppeared() - } - for await event in viewModel.events { - onEvent(event) - } - } - .onDismiss { - viewModel.clearScope() - } - } -} diff --git a/ios/PresentationLayer/SampleSharedViewModel/Tests/SampleSharedViewModelTests/SampleSharedViewModelTests.swift b/ios/PresentationLayer/SampleSharedViewModel/Tests/SampleSharedViewModelTests/SampleSharedViewModelTests.swift deleted file mode 100644 index ae7ee666..00000000 --- a/ios/PresentationLayer/SampleSharedViewModel/Tests/SampleSharedViewModelTests/SampleSharedViewModelTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -@testable import SampleSharedViewModel -import XCTest - -final class SampleSharedViewModelTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/PrimaryButtonStyle.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/PrimaryButtonStyle.swift deleted file mode 100644 index e9c581fc..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/PrimaryButtonStyle.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// Created by Petr Chmelar on 28.02.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -public extension ButtonStyle where Self == PrimaryButtonStyle { - - static var primary: Self { - .primary() - } - - static func primary( - isLarge: Bool = true, - isLoading: Bool = false - ) -> Self { - self.init(isLarge: isLarge, isLoading: isLoading) - } -} - -public struct PrimaryButtonStyle: ButtonStyle { - - private let isLarge: Bool - private let isLoading: Bool - - public init( - isLarge: Bool = true, - isLoading: Bool = false - ) { - self.isLarge = isLarge - self.isLoading = isLoading - } - - public func makeBody(configuration: Configuration) -> some View { - VStack { - if isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - configuration.label - .font(AppTheme.Fonts.primaryButton) - .foregroundColor(AppTheme.Colors.primaryButtonTitle) - } - } - .frame(maxWidth: isLarge ? .infinity : nil, minHeight: 24) - .padding() - .background(AppTheme.Colors.primaryButtonBackground) - .cornerRadius(5) - } -} - -#if DEBUG -#Preview { - VStack { - Button("Lorem Ipsum") {} - .buttonStyle(PrimaryButtonStyle()) - Button("Lorem Ipsum") {} - .buttonStyle(PrimaryButtonStyle(isLoading: true)) - Button("Lorem Ipsum") {} - .buttonStyle(PrimaryButtonStyle(isLarge: false)) - Button("Lorem Ipsum") {} - .buttonStyle(PrimaryButtonStyle(isLarge: false, isLoading: true)) - } -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SecondaryButtonStyle.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SecondaryButtonStyle.swift deleted file mode 100644 index 3a33a4b8..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SecondaryButtonStyle.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Created by Petr Chmelar on 28.02.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -public extension ButtonStyle where Self == SecondaryButtonStyle { - static var secondary: Self { - self.init() - } -} - -public struct SecondaryButtonStyle: ButtonStyle { - - public init() {} - - public func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(AppTheme.Fonts.secondaryButton) - .foregroundColor(AppTheme.Colors.secondaryButtonTitle) - .padding() - } -} - -#if DEBUG -#Preview { - Button("Lorem Ipsum") {} - .buttonStyle(SecondaryButtonStyle()) -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SlidingButton/DraggingComponent.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SlidingButton/DraggingComponent.swift deleted file mode 100644 index a06c73d2..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SlidingButton/DraggingComponent.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// Created by David Sobíšek on 23.11.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import SFSafeSymbols -import SwiftUI - -struct DraggingComponent: View { - - private let buttonIcon: Image - private let color: Color - @Binding private var isLoading: Bool - private let maxWidth: CGFloat - private let minWidth: CGFloat - @State private var width: CGFloat - private let action: () -> Void - private let threshold: CGFloat - - init( - buttonIcon: Image, - color: Color, - isLoading: Binding, - maxWidth: CGFloat, - minWidth: CGFloat = 50, - width: CGFloat = 50, - action: @escaping () -> Void = {} - ) { - self.buttonIcon = buttonIcon - self.color = color - self._isLoading = isLoading - self.maxWidth = maxWidth - self.minWidth = minWidth - self.width = width - self.action = action - self.threshold = 0.6 * maxWidth - } - - var body: some View { - ZStack(alignment: .trailing) { - color - .frame(width: width, height: 50) - .cornerRadius(25) - .gesture( - DragGesture() - .onChanged { value in - if value.translation.width > 0, - width != maxWidth, - value.translation.width + minWidth <= maxWidth { - width = value.translation.width + minWidth - } - } - .onEnded { _ in - if width < threshold || isLoading { - width = minWidth - } else { - width = maxWidth - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - isLoading = true - UIImpactFeedbackGenerator(style: .medium).impactOccurred() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - action() - width = minWidth - } - } - } - ) - - Group { - if !isLoading { - buttonIcon - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(color) - } else { - ProgressView() - .progressViewStyle(.circular) - .tint(color) - } - } - .frame(width: 24) - .padding(.horizontal, 9) - .padding(.vertical, 12) - .background(AppTheme.Colors.primaryButtonTitle) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.25), radius: 2.5, y: 2) - .allowsHitTesting(false) - .padding(.trailing, 3) - } - .animation(.spring(response: 0.3, dampingFraction: 1, blendDuration: 0), value: width) - } -} - -#if DEBUG -#Preview { - GeometryReader { geo in - DraggingComponent( - buttonIcon: Image(systemSymbol: .xmark), - color: AppTheme.Colors.primaryButtonBackground, - isLoading: .constant(false), - maxWidth: geo.size.width - ) - } -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SlidingButton/SlidingButton.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SlidingButton/SlidingButton.swift deleted file mode 100644 index 3c317bc3..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Button/SlidingButton/SlidingButton.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// Created by David Sobíšek on 23.11.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import SFSafeSymbols -import SwiftUI - -public struct SlidingButton: View { - - private let title: String - private let buttonIcon: Image - private let color: Color - @Binding private var isLoading: Bool - private let maxWidth: CGFloat - private let action: () -> Void - - public init( - title: String, - buttonIcon: Image, - color: Color, - isLoading: Binding, - maxWidth: CGFloat, - action: @escaping () -> Void = {} - ) { - self.title = title - self.buttonIcon = buttonIcon - self.color = color - self._isLoading = isLoading - self.maxWidth = maxWidth - self.action = action - } - - public var body: some View { - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 25) - .fill(color) - .frame(height: 50) - - Text(title) - .padding(.leading, maxWidth * 0.2) - .font(AppTheme.Fonts.primaryButton) - .foregroundColor(AppTheme.Colors.primaryButtonTitle) - - DraggingComponent( - buttonIcon: buttonIcon, - color: color, - isLoading: $isLoading, - maxWidth: maxWidth, - action: action - ) - } - } -} - -#if DEBUG -#Preview { - GeometryReader { geo in - SlidingButton( - title: "Zablokovat", - buttonIcon: Image(systemSymbol: .xmark), - color: AppTheme.Colors.primaryButtonBackground, - isLoading: .constant(false), - maxWidth: geo.size.width - ) - } -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/ErrorView/ErrorView.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/ErrorView/ErrorView.swift deleted file mode 100644 index bb92fa5d..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/ErrorView/ErrorView.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by Lukáš Matuška on 20.09.2024 -// Copyright © 2024 Matee. All rights reserved. -// - -import SwiftUI - -public struct ErrorView: View { - - let error: Error - - public init(error: Error) { - self.error = error - } - - public var body: some View { - Text(error.localizedDescription) - } -} - -#Preview { - ErrorView(error: NSError(domain: "com.example", code: 0, userInfo: nil)) -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Image/RemoteImage.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Image/RemoteImage.swift deleted file mode 100644 index e76d243a..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Image/RemoteImage.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// Created by David Kadlček on 12.05.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Foundation -import SwiftUI - -public struct RemoteImage: View { - - @State private var image: Image? - private let stringURL: String? - private let placeholder: Image - private let contentMode: ContentMode - - public init( - stringURL: String?, - placeholder: Image, - contentMode: ContentMode = .fit - ) { - self.stringURL = stringURL - self.placeholder = placeholder - self.contentMode = contentMode - } - - public var body: some View { - if let image { - image - .resizable() - .aspectRatio(contentMode: contentMode) - } else if let stringURL = stringURL, let url = URL(string: stringURL) { - placeholderContent() - .skeleton(true) - .onAppear(perform: { downloadRemoteImage(from: url) }) - } else { - failureContent() - } - } -} - -private extension RemoteImage { - - // MARK: - Subviews - - func placeholderContent() -> some View { - placeholder - .resizable() - .aspectRatio(contentMode: contentMode) - } - - func failureContent() -> some View { - placeholderContent() - } - - // MARK: - Remote image downloading - - func downloadRemoteImage(from url: URL) { - Task { - image = await downloadImage(from: url) - } - } - - func downloadImage(from url: URL) async -> Image? { - do { - let cache = URLCache.shared - let urlRequest = URLRequest(url: url) - - let (data, response) = try await URLSession.shared.data(for: urlRequest) - - if cache.cachedResponse(for: urlRequest) == nil { - cache.storeCachedResponse(CachedURLResponse(response: response, data: data), for: urlRequest) - } - - guard let uiImage = UIImage(data: data) else { return nil } - return Image(uiImage: uiImage) - } catch { - return nil - } - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/ProgressView/PrimaryProgressView.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/ProgressView/PrimaryProgressView.swift deleted file mode 100644 index efe90f0b..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/ProgressView/PrimaryProgressView.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Created by Petr Chmelar on 23.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -public struct PrimaryProgressView: View { - - public init() {} - - public var body: some View { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: AppTheme.Colors.progressView)) - .scaleEffect(2) - } -} - -#if DEBUG -#Preview { - PrimaryProgressView() -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Shimmer/Shimmer.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Shimmer/Shimmer.swift deleted file mode 100644 index 40c7ac7c..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Shimmer/Shimmer.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Shimmer.swift -// -// Created by Vikram Kriplaney on 23.03.21. -// - -// Taken from: https://github.com/markiv/SwiftUI-Shimmer - -import SwiftUI - -/// A view modifier that applies an animated "shimmer" to any view, typically to show that -/// an operation is in progress. -public struct Shimmer: ViewModifier { - @State private var phase: CGFloat = 0 - var duration = 1.5 - var bounce = false - - public func body(content: Content) -> some View { - content - .modifier(AnimatedMask(phase: phase).animation( - Animation.linear(duration: duration) - .repeatForever(autoreverses: bounce) - )) - .onAppear { phase = 0.8 } - } - - /// An animatable modifier to interpolate between `phase` values. - struct AnimatedMask: AnimatableModifier { - var phase: CGFloat = 0 - - var animatableData: CGFloat { - get { phase } - set { phase = newValue } - } - - func body(content: Content) -> some View { - content - .mask(GradientMask(phase: phase).scaleEffect(3)) - } - } - - /// A slanted, animatable gradient between transparent and opaque to use as mask. - /// The `phase` parameter shifts the gradient, moving the opaque band. - struct GradientMask: View { - let phase: CGFloat - let centerColor = Color.black - let edgeColor = Color.black.opacity(0.5) - - var body: some View { - LinearGradient( - gradient: Gradient(stops: [ - .init(color: edgeColor, location: phase), - .init(color: centerColor, location: phase + 0.1), - .init(color: edgeColor, location: phase + 0.2) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - } -} - -public extension View { - /// Adds an animated shimmering effect to any view, typically to show that - /// an operation is in progress. - /// - Parameters: - /// - active: Convenience parameter to conditionally enable the effect. Defaults to `true`. - /// - duration: The duration of a shimmer cycle in seconds. Default: `1.5`. - /// - bounce: Whether to bounce (reverse) the animation back and forth. Defaults to `false`. - @ViewBuilder func shimmering( - active: Bool = true, duration: Double = 1.5, bounce: Bool = false - ) -> some View { - if active { - modifier(Shimmer(duration: duration, bounce: bounce)) - } else { - self - } - } -} - -#if DEBUG -#Preview { - Group { - Text("SwiftUI Shimmer") - Text("SwiftUI Shimmer").preferredColorScheme(.light) - Text("SwiftUI Shimmer").preferredColorScheme(.dark) - VStack(alignment: .leading) { - Text("Loading...").font(.title) - Text(String(repeating: "Shimmer", count: 12)) - .redacted(reason: .placeholder) - }.frame(maxWidth: 200) - } - .padding() - .shimmering() - .previewLayout(.sizeThatFits) -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Text/HeadlineText.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Text/HeadlineText.swift deleted file mode 100644 index 5ffb6996..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Text/HeadlineText.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Created by Petr Chmelar on 27.02.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -public struct HeadlineText: View { - - private let content: String - - public init(_ content: String) { - self.content = content - } - - public var body: some View { - Text(content) - .font(AppTheme.Fonts.headlineText) - .foregroundColor(AppTheme.Colors.headlineText) - } -} - -#if DEBUG -#Preview { - HeadlineText("Lorem Ipsum") -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/TextField/PrimaryTextField.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/TextField/PrimaryTextField.swift deleted file mode 100644 index 93a57842..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/TextField/PrimaryTextField.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Created by Petr Chmelar on 01.03.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -public struct PrimaryTextField: View { - - private let titleKey: String - private let text: Binding - private let secure: Bool - - public init( - _ titleKey: String, - text: Binding, - secure: Bool = false - ) { - self.titleKey = titleKey - self.text = text - self.secure = secure - } - - public var body: some View { - VStack(alignment: .leading) { - Text(titleKey) - .font(AppTheme.Fonts.textFieldTitle) - .foregroundColor(AppTheme.Colors.textFieldTitle) - if secure { - SecureField(titleKey, text: text) - .textFieldStyle(PrimaryTextFieldStyle()) - } else { - TextField(titleKey, text: text) - .textFieldStyle(PrimaryTextFieldStyle()) - } - } - } -} - -#if DEBUG -#Preview { - VStack { - PrimaryTextField("Lorem Ipsum", text: .constant("Lorem Ipsum")) - PrimaryTextField("Lorem Ipsum", text: .constant("Lorem Ipsum"), secure: true) - } -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/TextField/PrimaryTextFieldStyle.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/TextField/PrimaryTextFieldStyle.swift deleted file mode 100644 index 346e6dc9..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/TextField/PrimaryTextFieldStyle.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Created by Petr Chmelar on 01.03.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -struct PrimaryTextFieldStyle: TextFieldStyle { - func _body(configuration: TextField) -> some View { - configuration - .font(AppTheme.Fonts.textFieldText) - .accentColor(AppTheme.Colors.primaryColor) - .disableAutocorrection(true) - .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(AppTheme.Colors.textFieldBorder, lineWidth: 2) - ) - } -} - -#if DEBUG -#Preview { - TextField("Lorem Ipsum", text: .constant("Lorem Ipsum")) - .textFieldStyle(PrimaryTextFieldStyle()) -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/UIHostingController/BaseHostingController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/UIHostingController/BaseHostingController.swift deleted file mode 100644 index 9ffe9d07..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/UIHostingController/BaseHostingController.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// Created by Petr Chmelar on 23.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import KMPShared -import OSLog -import SwiftUI -import Utilities - -public class BaseHostingController: UIHostingController where Content: View { - - private var statusBarStyle: UIStatusBarStyle? - - override public var preferredStatusBarStyle: UIStatusBarStyle { - return statusBarStyle ?? navigationController?.preferredStatusBarStyle ?? .default - } - - public convenience init(rootView: Content, statusBarStyle: UIStatusBarStyle) { - self.init(rootView: rootView) - self.statusBarStyle = statusBarStyle - } - - override public init(rootView: Content) { - super.init(rootView: rootView) - Logger.lifecycle.info("\(type(of: self)) initialized") - setupUI() - } - - public required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - Logger.lifecycle.info("\(type(of: self)) initialized") - setupUI() - } - - deinit { - Logger.lifecycle.info("\(type(of: self)) deinitialized") - } - - private func setupUI() { - // Setup background color and back button title - view.backgroundColor = UIColor(AppTheme.Colors.background) - navigationItem.backBarButtonItem = UIBarButtonItem(title: MR.strings().back.toLocalized(), style: .plain, target: nil, action: nil) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/UserView/UserView.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/UserView/UserView.swift deleted file mode 100644 index f5e21b82..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/UserView/UserView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Created by Petr Chmelar on 23.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -public struct UserView: View { - - private let fullName: String - - public init(_ fullName: String) { - self.fullName = fullName - } - - public var body: some View { - VStack { - ZStack { - Circle() - .frame(width: 100, height: 100) - .foregroundColor(AppTheme.Colors.primaryColor) - Text(fullName.initials) - .font(.system(size: 28, weight: .bold)) - .foregroundColor(.white) - } - .padding(.top, 64) - Text(fullName) - .font(.system(size: 20)) - .padding(.top, 16) - } - - } -} - -#if DEBUG -#Preview { - UserView("Petr Chmelar") -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Whisper/Whisper.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Whisper/Whisper.swift deleted file mode 100644 index e13071db..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/SwiftUI/Whisper/Whisper.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Created by Petr Chmelar on 03.03.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -struct Whisper: View { - - private let data: WhisperData - - init(_ data: WhisperData) { - self.data = data - } - - var body: some View { - GeometryReader { geometry in - VStack { - Text(data.message) - .font(AppTheme.Fonts.whisperMessage) - .foregroundColor(AppTheme.Colors.whisperMessage) - .padding(.bottom, 5) - } - .frame( - maxWidth: .infinity, - maxHeight: geometry.safeAreaInsets.top + 25, - alignment: .bottom - ) - .background(data.style.color) - .ignoresSafeArea() - } - } -} - -#if DEBUG -#Preview { - Group { - Whisper(WhisperData("Lorem Ipsum")) - Whisper(WhisperData(error: "Error Ipsum")) - } -} -#endif diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseNavigationController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseNavigationController.swift deleted file mode 100644 index 925cbd28..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseNavigationController.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Created by Petr Chmelar on 06.10.2021 -// Copyright © 2021 Matee. All rights reserved. -// - -import UIKit - -public final class BaseNavigationController: UINavigationController { - - private var statusBarStyle: UIStatusBarStyle = .default - - override public var preferredStatusBarStyle: UIStatusBarStyle { - return statusBarStyle - } - - override public var childForStatusBarStyle: UIViewController? { - return visibleViewController - } - - public convenience init(statusBarStyle: UIStatusBarStyle) { - self.init(nibName: nil, bundle: nil) - self.statusBarStyle = statusBarStyle - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseViewController+Alerts.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseViewController+Alerts.swift deleted file mode 100644 index d06d1f44..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseViewController+Alerts.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Created by Petr Chmelar on 25/07/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import UIKit - -public extension BaseViewController { - - /// Handle alert on current top ViewController based on a given AlertAction. - func handleAlertAction(_ action: AlertAction) { - switch action { - case let .showWhisper(whisper): showWhisper(whisper) - case .hideWhisper: hideWhisper() - case let .showAlert(alert): showAlert(alert) - } - } - - /// Present UIAlertController on current top ViewController. - func showAlert(_ alert: AlertData, textFieldHandler: ((UITextField) -> Void)? = nil) { - let alertController = UIAlertController(title: alert.title, message: alert.message, preferredStyle: .alert) - alertController.addAction(.init(alert.primaryAction)) - - if let secondaryAction = alert.secondaryAction { - alertController.addAction(.init(secondaryAction)) - } - - if textFieldHandler != nil { - alertController.addTextField(configurationHandler: textFieldHandler) - } - - hideWhisper() - present(alertController, animated: true, completion: nil) - } - - /// Present WhisperView on current top ViewController. - func showWhisper(_ whisper: WhisperData) { - let whisperView = WhisperView(frame: CGRect( - x: 0, - y: -view.safeAreaInsets.top - 25, - width: view.bounds.width, - height: view.safeAreaInsets.top + 25 - )) - whisperView.message = whisper.message - whisperView.backgroundColor = UIColor(whisper.style.color) - - hideWhisper() - view.addSubview(whisperView) - UIView.animate( - withDuration: 0.2, - delay: 0, - options: .curveLinear, - animations: { whisperView.frame.origin.y = 0 }, - completion: nil - ) - - if whisper.hideAfter > 0 { - DispatchQueue.main.asyncAfter(deadline: .now() + whisper.hideAfter) { - self.hideWhisper() - } - } - } - - /// Hide and remove WhisperView from current top ViewController. - func hideWhisper() { - for view in view.subviews where view.isKind(of: WhisperView.self) { - UIView.animate( - withDuration: 0.2, - delay: 0, - options: .curveLinear, - animations: { view.frame.origin.y = -view.safeAreaInsets.top - 30 }, - completion: { _ in view.removeFromSuperview() } - ) - } - } - -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseViewController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseViewController.swift deleted file mode 100644 index 0c35b409..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/BaseViewController.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Created by Viktor Kaderabek on 22/06/2017. -// Copyright © 2017 Matee. All rights reserved. -// - -import KMPShared -import OSLog -import UIKit -import Utilities - -public class BaseViewController: UIViewController { - - // MARK: Inits - override public init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { - super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - Logger.lifecycle.info("\(type(of: self)) initialized") - } - - public required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - Logger.lifecycle.info("\(type(of: self)) initialized") - } - - deinit { - Logger.lifecycle.info("\(type(of: self)) deinitialized") - } - - // MARK: Lifecycle methods - override public func viewDidLoad() { - super.viewDidLoad() - setupUI() - } - - // MARK: Default methods - - /// Override this method in a subclass and setup the view appearance - open func setupUI() { - // Setup background color and back button title - view.backgroundColor = UIColor(AppTheme.Colors.background) - navigationItem.backBarButtonItem = UIBarButtonItem(title: MR.strings().back.toLocalized(), style: .plain, target: nil, action: nil) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/FullscreenImageViewController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/FullscreenImageViewController.swift deleted file mode 100644 index f71958a0..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/FullscreenImageViewController.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Created by Petr Chmelar on 16/10/2018. -// Copyright © 2019 Matee. All rights reserved. -// - -import UIKit - -public final class FullscreenImageViewController: BaseViewController { - - // MARK: UI components - private var scrollView = UIScrollView() - private var imageView = UIImageView() - private var activityIndicator = UIActivityIndicatorView() - - // MARK: Inits - public init(_ image: UIImage) { - imageView.image = image - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - public required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Lifecycle methods - override public func viewDidLoad() { - super.viewDidLoad() - scrollView.delegate = self - } - - // MARK: Default methods - override public func setupUI() { - super.setupUI() - - view.backgroundColor = UIColor(AppTheme.Colors.background) - activityIndicator.color = UIColor(AppTheme.Colors.progressView) - imageView.contentMode = .scaleAspectFit - scrollView.maximumZoomScale = 10.0 - - view.addSubview(scrollView) - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true - scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true - scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true - - scrollView.addSubview(imageView) - imageView.translatesAutoresizingMaskIntoConstraints = false - imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true - imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true - imageView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true - imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true - imageView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true - imageView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true - - view.addSubview(activityIndicator) - activityIndicator.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true - activityIndicator.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor).isActive = true - } -} - -extension FullscreenImageViewController: UIScrollViewDelegate { - public func viewForZooming(in scrollView: UIScrollView) -> UIView? { - imageView - } - - public func scrollViewDidZoom(_ scrollView: UIScrollView) { - // Center content of scrollView - // Idea taken from: https://stackoverflow.com/a/36170800 - let offsetX = max((scrollView.bounds.width - scrollView.contentSize.width) * 0.5, 0) - let offsetY = max((scrollView.bounds.height - scrollView.contentSize.height) * 0.5, 0) - scrollView.contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: 0, right: 0) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/ImagePickerViewController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/ImagePickerViewController.swift deleted file mode 100644 index 7e4dd681..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/ImagePickerViewController.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// Created by Petr Chmelar on 08/10/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import KMPShared -import UIKit - -@objc public protocol ImagePickerViewControllerDelegate: AnyObject { - @objc optional func photoSelected(image: UIImage?) -} - -public final class ImagePickerViewController: BaseViewController { - - // MARK: Stored properties - public var imagePickerTitle: String = MR.strings().image_picker_title.toLocalized() - public var imagePickerSubtitle: String = MR.strings().image_picker_subtitle.toLocalized() - - public weak var delegate: ImagePickerViewControllerDelegate? - - // MARK: Additional methods - // swiftlint:disable:next private_action - @IBAction func addPicture(_ sender: UIButton) { - view.endEditing(true) - - // Setup action sheet with camera/library options - let actionSheetController = UIAlertController(title: imagePickerTitle, message: imagePickerSubtitle, preferredStyle: .actionSheet) - - let photoLibrary = UIAlertAction(title: MR.strings().image_picker_library.toLocalized(), style: .default, handler: { _ in - self.selectPhoto(sourceType: .photoLibrary) - }) - actionSheetController.addAction(photoLibrary) - - let takePhotoByCamera = UIAlertAction(title: MR.strings().image_picker_camera.toLocalized(), style: .default, handler: { _ in - self.selectPhoto(sourceType: .camera) - }) - actionSheetController.addAction(takePhotoByCamera) - - let cancel = UIAlertAction(title: MR.strings().image_picker_cancel.toLocalized(), style: .cancel, handler: nil) - actionSheetController.addAction(cancel) - - // Required for iPad - actionSheetController.popoverPresentationController?.sourceView = view - let sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0) - actionSheetController.popoverPresentationController?.sourceRect = sourceRect - actionSheetController.popoverPresentationController?.permittedArrowDirections = [] - - present(actionSheetController, animated: true, completion: nil) - } - - private func selectPhoto(sourceType: UIImagePickerController.SourceType) { - guard UIImagePickerController.isSourceTypeAvailable(sourceType) else { return } - let imagePicker = UIImagePickerController() - imagePicker.delegate = self - imagePicker.allowsEditing = false - imagePicker.sourceType = sourceType - present(imagePicker, animated: true) - } -} - -extension ImagePickerViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { - public func imagePickerController( - _ picker: UIImagePickerController, - didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] - ) { - guard let selectedImage = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } - - // Save image to photo library if taken by camera - if picker.sourceType == .camera { - UIImageWriteToSavedPhotosAlbum(selectedImage, nil, nil, nil) - } - - delegate?.photoSelected?(image: selectedImage) - dismiss(animated: true, completion: nil) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/SafariViewController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/SafariViewController.swift deleted file mode 100644 index 48081f37..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/SafariViewController.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Created by Petr Chmelar on 13/04/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import SafariServices - -public final class SafariViewController: SFSafariViewController { - - // MARK: Stored properties - private let url: URL - - // MARK: Inits - override public init(url URL: URL, configuration: SFSafariViewController.Configuration = SFSafariViewController.Configuration()) { - self.url = URL - super.init(url: URL, configuration: configuration) - } - - // MARK: Lifecycle methods - override public func viewDidLoad() { - super.viewDidLoad() - preferredControlTintColor = UIColor(AppTheme.Colors.primaryColor) - } - - /// - /// Try to open universal link - /// - /// - parameter completionHandler: Returns false for non universal link or when app is not installed - /// - public func openUniversalLink(completionHandler completion: ((Bool) -> Void)? = nil) { - UIApplication.shared.open(url, options: [.universalLinksOnly: true], completionHandler: completion) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/WebViewController.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/WebViewController.swift deleted file mode 100644 index d157b07d..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/UIViewController/WebViewController.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Created by Petr Chmelar on 05/02/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import UIKit -import WebKit - -public final class WebViewController: BaseViewController { - - // MARK: Stored properties - private var url: URL - private var shouldAddCookies: Bool = false - - // MARK: Inits - public init(url: URL, shouldAddCookies: Bool = false) { - self.url = url - self.shouldAddCookies = shouldAddCookies - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - public required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Lifecycle methods - override public func viewDidLoad() { - super.viewDidLoad() - - // Setup web view - let webView = WKWebView(frame: view.frame) - webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - webView.navigationDelegate = self - view.addSubview(webView) - - // Add cookies - if shouldAddCookies, let cookies = HTTPCookieStorage.shared.cookies { - for cookie in cookies { - webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie, completionHandler: nil) - } - } - - webView.load(URLRequest(url: url)) - } -} - -extension WebViewController: WKNavigationDelegate { -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/Whisper/WhisperView.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/Whisper/WhisperView.swift deleted file mode 100644 index e73bb2bb..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/BaseViews/UIKit/Whisper/WhisperView.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// Created by Petr Chmelar on 19/04/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import UIKit - -public final class WhisperView: UIView { - - // MARK: UI components - private let messageLabel = UILabel() - - // MARK: Stored properties - public var message: String = "" { - didSet { - messageLabel.text = message - } - } - - // MARK: Inits - override public init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - // MARK: Default methods - private func setup() { - setupMessageLabel() - } - - // MARK: Additional methods - private func setupMessageLabel() { - messageLabel.textAlignment = .center - messageLabel.textColor = UIColor(AppTheme.Colors.whisperMessage) - messageLabel.font = AppTheme.Fonts.whisperMessageUIKit - - addSubview(messageLabel) - messageLabel.translatesAutoresizingMaskIntoConstraints = false - messageLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 16).isActive = true - messageLabel.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -16).isActive = true - messageLabel.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor).isActive = true - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Environment/LoadingKey.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Environment/LoadingKey.swift deleted file mode 100644 index 3951891c..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Environment/LoadingKey.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Created by David Kadlček on 06.03.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Foundation -import SwiftUI - -struct LoadingKey: EnvironmentKey { - static let defaultValue: Bool = false -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Double+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Double+Extensions.swift deleted file mode 100644 index 4ea09408..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Double+Extensions.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Created by Petr Chmelar on 10/02/2020. -// Copyright © 2020 Matee. All rights reserved. -// - -import Foundation - -public extension Double { - - /// Rounds the double to decimal places value - func rounded(toPlaces places: Int) -> Double { - let divisor = pow(10.0, Double(places)) - return (self * divisor).rounded() / divisor - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/EnvironmentValues+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/EnvironmentValues+Extensions.swift deleted file mode 100644 index 7e4c2ae1..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/EnvironmentValues+Extensions.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Created by David Kadlček on 06.03.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Foundation -import SwiftUI - -public extension EnvironmentValues { - var isLoading: Bool { - get { self[LoadingKey.self] } - set { self[LoadingKey.self] = newValue } - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Int+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Int+Extensions.swift deleted file mode 100644 index 336380b1..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Int+Extensions.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Created by Viktor Kaderabek on 10/09/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation -import Utilities - -public extension Int { - - /// Conversion from Int to String using a given formatter. - func toString(formatter: NumberFormatter = Formatter.Number.default) -> String { - formatter.string(from: NSNumber(value: self)) ?? "" - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Measurement+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Measurement+Extensions.swift deleted file mode 100644 index 8ce345be..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Measurement+Extensions.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Created by Petr Chmelar on 10/04/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import Foundation - -public extension Measurement where UnitType == Dimension { - - /// Predefined default unit for selected dimensions - var defaultUnit: Measurement { - switch unit { - case is UnitLength: converted(to: UnitLength.kilometers) - case is UnitMass: converted(to: UnitMass.kilograms) - case is UnitSpeed: converted(to: UnitSpeed.kilometersPerHour) - case is UnitTemperature: converted(to: UnitTemperature.celsius) - case is UnitVolume: converted(to: UnitVolume.liters) - default: self - } - } - - /// Predefined default minimum/maximum fraction digits for selected dimensions - private var defaultFractionDigits: (minimum: Int, maximum: Int) { - switch unit { - case is UnitLength: (0, 0) - case is UnitMass: (0, 0) - case is UnitSpeed: (0, 0) - case is UnitTemperature: (0, 0) - case is UnitVolume: (0, 0) - default: (0, 0) - } - } - - /// - /// Dimension formatting based on user's locale/preferences - /// - /// - parameter minimumFractionDigits: Specify minimum number of fraction digits - /// - parameter maximumFractionDigits: Specify maximum number of fraction digits - /// - returns: Formatted dimension - /// - func formatted(minimumFractionDigits: Int? = nil, maximumFractionDigits: Int? = nil) -> String { - let formatter = MeasurementFormatter() - formatter.numberFormatter.minimumFractionDigits = minimumFractionDigits ?? defaultFractionDigits.minimum - formatter.numberFormatter.maximumFractionDigits = maximumFractionDigits ?? defaultFractionDigits.maximum - return formatter.string(from: self) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/NSObject+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/NSObject+Extensions.swift deleted file mode 100644 index cbb98b7e..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/NSObject+Extensions.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Created by Viktor Kaderabek on 25/07/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation - -public extension NSObject { - - /// Class name literal - class var nameOfClass: String { - guard let className = NSStringFromClass(self).components(separatedBy: ".").last else { return "N/A" } - return className - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/NavigationLink+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/NavigationLink+Extensions.swift deleted file mode 100644 index 21769976..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/NavigationLink+Extensions.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Created by Petr Chmelar on 29.05.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import SwiftUI - -// Taken from: https://stackoverflow.com/a/66891173 - -public extension NavigationLink where Label == EmptyView, Destination == EmptyView { - - /// Useful in cases where a `NavigationLink` is needed but there should not be a destination. e.g. for programmatic navigation. - static var empty: NavigationLink { - self.init(destination: EmptyView(), label: { EmptyView() }) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/String+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/String+Extensions.swift deleted file mode 100644 index 63fc93b1..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/String+Extensions.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Created by Petr Chmelar on 04/02/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import Foundation - -public extension String { - - var secured: String { - String(map { _ in "*" }) - } - - var initials: String { - let words: [Substring] = split(separator: " ") - let initials = words.map { String($0.first ?? Character("")) } - let userInitials = initials.joined() - return userInitials - } - - static func placeholder(length: Int) -> String { - String(Array(repeating: "X", count: length)) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/UIImage+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/UIImage+Extensions.swift deleted file mode 100644 index 4eb71fd3..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/UIImage+Extensions.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// Created by Petr Chmelar on 08/10/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import UIKit - -public extension UIImage { - - /// - /// Scale the image to fit inside a given CGRect. Useful when you need to combine .scaleAspectFit with .left/.right/.top/.bottom - /// - Idea taken from [Alignment UIImageView with Aspect Fit](https://stackoverflow.com/a/45793949) - /// - /// - parameter rect: CGRect to fit image into. - /// - returns: Scaled UIImage. - /// - func scaleAspectToFitRect(_ rect: CGRect) -> UIImage? { - let width = size.width - let height = size.height - let aspectWidth = rect.width / width - let aspectHeight = rect.height / height - let scaleFactor = aspectWidth > aspectHeight ? rect.size.height / height : rect.size.width / width - - UIGraphicsBeginImageContextWithOptions(CGSize(width: width * scaleFactor, height: height * scaleFactor), false, 0.0) - draw(in: CGRect(x: 0.0, y: 0.0, width: width * scaleFactor, height: height * scaleFactor)) - - defer { - UIGraphicsEndImageContext() - } - - return UIGraphicsGetImageFromCurrentImageContext() - } - - /// Fix orientation of an UIImage without EXIF - func fixOrientation() -> UIImage { // swiftlint:disable:this cyclomatic_complexity - - guard let cgImage else { return self } - - if imageOrientation == .up { - return self - } - - var transform = CGAffineTransform.identity - - switch imageOrientation { - case .down, .downMirrored: - transform = transform.translatedBy(x: size.width, y: size.height) - transform = transform.rotated(by: CGFloat(Double.pi)) - case .left, .leftMirrored: - transform = transform.translatedBy(x: size.width, y: 0) - transform = transform.rotated(by: CGFloat(Double.pi / 2)) - case .right, .rightMirrored: - transform = transform.translatedBy(x: 0, y: size.height) - transform = transform.rotated(by: CGFloat(-Double.pi / 2)) - default: - break - } - - switch imageOrientation { - case .upMirrored, .downMirrored: - transform = transform.translatedBy(x: size.width, y: 0) - transform = transform.scaledBy(x: -1, y: 1) - case .leftMirrored, .rightMirrored: - transform = transform.translatedBy(x: size.height, y: 0) - transform = transform.scaledBy(x: -1, y: 1) - default: - break - } - - if let ctx = CGContext( - data: nil, - width: Int(size.width), - height: Int(size.height), - bitsPerComponent: cgImage.bitsPerComponent, - bytesPerRow: 0, - space: cgImage.colorSpace!, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) { - ctx.concatenate(transform) - - switch imageOrientation { - case .left, .leftMirrored, .right, .rightMirrored: - ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width)) - default: - ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) - } - - if let finalImage = ctx.makeImage() { - return (UIImage(cgImage: finalImage)) - } - } - - // Return original if something go wrong - return self - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/UIViewController+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/UIViewController+Extensions.swift deleted file mode 100644 index 1ef5404d..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/UIViewController+Extensions.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Created by Petr Chmelar on 28.01.2021. -// Copyright © 2021 Matee. All rights reserved. -// - -import UIKit - -public extension UIViewController { - - func add(_ child: UIViewController) { - addChild(child) - view.addSubview(child.view) - child.didMove(toParent: self) - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift index a65b6e62..d8ba51b5 100644 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift @@ -3,35 +3,11 @@ // Copyright © 2022 Matee. All rights reserved. // +import KMPShared import NavigatorUI import SwiftUI -@MainActor public extension View { - @inlinable func lifecycle(_ viewModel: BaseViewModel) -> some View { - self - .onAppear { - viewModel.onAppear() - } - .onDisappear { - viewModel.onDisappear() - } - } -} - -public extension View { - /// Redact a view with a shimmering effect aka show a skeleton - /// - Inspiration taken from [Redacted View Modifier](https://www.avanderlee.com/swiftui/redacted-view-modifier/) - @ViewBuilder - func skeleton( - _ condition: @autoclosure () -> Bool, - duration: Double = 1.5, - bounce: Bool = false - ) -> some View { - redacted(reason: condition() ? .placeholder : []) - .shimmering(active: condition(), duration: duration, bounce: bounce) - } - /// onDismiss modifier. Provided action is called when the View is removed from the hierarchy func onDismiss(perform handler: (() -> Void)? = nil) -> some View { background { @@ -60,3 +36,25 @@ public extension View { ) } } + +@MainActor +public extension View { + @inlinable func bindViewModel( + _ viewModel: BaseScopedViewModel, + onEvent: @escaping (E) -> Void + ) -> some View { + self + .task { + // Make sure that onViewAppeared will be called after event subcsription + Task { + viewModel.onViewAppeared() + } + for await event in viewModel.events { + onEvent(event) + } + } + .onDismiss { + viewModel.clearScope() + } + } +} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/AppTheme.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/AppTheme.swift index 763d9445..0de15c45 100644 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/AppTheme.swift +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/AppTheme.swift @@ -51,104 +51,4 @@ public enum AppTheme { public static let toastErrorColor = Asset.Colors.error.color public static let toastInfoColor = Asset.Colors.info.color } - - /// Defines all the fonts used in the app in a semantic way - public enum Fonts { - - // Text - public static let headlineText = Font.system(size: 28.0, weight: .medium) - - // Text fields - public static let textFieldText = Font.system(size: 17.0, weight: .medium) - public static let textFieldTitle = Font.system(size: 14.0, weight: .regular) - - // Buttons - public static let primaryButton = Font.system(size: 20.0, weight: .regular) - public static let secondaryButton = Font.system(size: 20.0, weight: .regular) - - // Whisper - public static let whisperMessage = Font.system(size: 13.0, weight: .medium) - public static let whisperMessageUIKit = UIFont.systemFont(ofSize: 13.0, weight: .medium) - } - - /// Defines dimens - public enum Dimens { - /** - * Space 0 - */ - public static let spaceNone: CGFloat = 0 - /** - * Space 4 - */ - public static let spaceXSmall: CGFloat = 4 - /** - * Space 8 - */ - public static let spaceSmall: CGFloat = 8 - /** - * Space 10 - */ - public static let spaceSemiMedium: CGFloat = 10 - /** - * Space 12 - */ - public static let spaceMedium: CGFloat = 12 - /** - * Space 16 - */ - public static let spaceLarge: CGFloat = 16 - /** - * Space 18 - */ - public static let textFieldButtonSpace: CGFloat = 18 - /** - * Spacing of 24 - */ - public static let spaceXLarge: CGFloat = 24 - /** - * Spacing of 40 - */ - public static let spaceXXLarge: CGFloat = 32 - /** - * Spacing of 64 - */ - public static let spaceXXXLarge: CGFloat = 64 - - /** - * Radius of 6 - */ - public static let radiusXXSmall: CGFloat = 6 - /** - * Radius of 8 - */ - public static let radiusXSmall: CGFloat = 8 - /** - * Radius of 10 - */ - public static let radiusTextFields: CGFloat = 10 - /** - * Radius of 12 - */ - public static let radiusSmall: CGFloat = 12 - /** - * Radius of 16 - */ - public static let radiusMedium: CGFloat = 16 - /** - * Radius of 18 - */ - public static let radiusLarge: CGFloat = 18 - /** - * Radius of 24 - */ - public static let radiusXLarge: CGFloat = 24 - - } - - public enum Images { - public static let person = UIImage(systemSymbol: .personFill) - public static let personCirle = UIImage(systemSymbol: .personCircleFill) - public static let personSquare = UIImage(systemSymbol: .personCropSquareFill) - public static let personTwo = UIImage(systemSymbol: .person2) - } } diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/BaseViewModel.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/BaseViewModel.swift deleted file mode 100644 index 311e76e1..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/BaseViewModel.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Created by Petr Chmelar on 06/02/2019. -// Copyright © 2019 Matee. All rights reserved. -// - -import Foundation -import OSLog -import Utilities - -@MainActor -open class BaseViewModel { - - /// All tasks that are currently executed - public private(set) var tasks: [Task] = [] - - public init() { - Logger.lifecycle.info("\(type(of: self)) initialized") - } - - deinit { - Logger.lifecycle.info("\(type(of: self)) deinitialized") - } - - /// Override this method in a subclass for custom behavior when a view appears - open func onAppear() {} - - /// Override this method in a subclass for custom behavior when a view disappears - open func onDisappear() { - // Cancel all tasks when we are going away - tasks.forEach { $0.cancel() } - } - - public func executeTask(_ task: Task) { - tasks.append(task) - Task { - await task.value - - // Remove task when done - objc_sync_enter(tasks) - tasks = tasks.filter { $0 != task } - objc_sync_exit(tasks) - } - } - - public func awaitAllTasks() async { - for task in tasks { await task.value } - } - - public func execute( - _ block: () async throws -> Void, - /* logErrors: Bool = true, */ - onError: (Swift.Error) -> Void = { _ in }, - onCancel: (CancellationError) -> Void = { _ in } - ) async { - do { - try await block() - } catch let error as CancellationError { - onCancel(error) - } catch { - // Custom error logging if needed - onError(error) - } - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/Countries.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/Countries.swift deleted file mode 100644 index 697dbb8a..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/Countries.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Created by Viktor Kaderabek on 30/07/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation - -public struct Countries { - - /// - /// Countries and ISO codes from system - /// - /// - returns: Dictionary with names and ISO codes - /// - static func getCountriesAndCodes() -> [(name: String, code: String)] { - var countriesAndCodes: [(name: String, code: String)] = [] - - for code in NSLocale.isoCountryCodes as [String] { - let id = NSLocale.localeIdentifier(fromComponents: [NSLocale.Key.countryCode.rawValue: code]) - let name = NSLocale(localeIdentifier: NSLocale.current.languageCode ?? "en") - .displayName(forKey: NSLocale.Key.identifier, value: id) ?? code - countriesAndCodes.append((name: name, code: code)) - } - - return countriesAndCodes.sorted(by: { $0.name < $1.name }) - } - - /// - /// Convert ISO country code to full country name - /// - /// - parameter code: ISO code of a country - /// - returns: Full country name - /// - static func getCountryBy(code: String) -> String? { - let currentLocale = NSLocale(localeIdentifier: NSLocale.current.languageCode ?? "en") - let countryName = currentLocale.displayName(forKey: NSLocale.Key.countryCode, value: code) - return countryName - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/Plurals.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/Plurals.swift deleted file mode 100644 index 28e83260..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/Plurals.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Created by Petr Chmelar on 20/09/2018. -// Copyright © 2018 Matee. All rights reserved. -// - -import Foundation - -public enum Plurals: String { - - // swiftlint:disable identifier_name - case days - case days_before - case hours - case hours_before - case minutes - case minutes_before - case seconds - case seconds_before - case meters - case points - // swiftlint:enable identifier_name - - func stringForCount(_ count: Int) -> String { - if count == 0 { // swiftlint:disable:this empty_count - return String(format: NSLocalizedString("zero_\(rawValue)", bundle: .module, comment: ""), count) - } else if abs(count) == 1 { - return String(format: NSLocalizedString("one_\(rawValue)", bundle: .module, comment: ""), count) - } else if abs(count) > 1 && abs(count) < 5 { - return String(format: NSLocalizedString("few_\(rawValue)", bundle: .module, comment: ""), count) - } else if abs(count) >= 5 { - return String(format: NSLocalizedString("many_\(rawValue)", bundle: .module, comment: ""), count) - } else { - return String(format: NSLocalizedString("other_\(rawValue)", bundle: .module, comment: ""), count) - } - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/ViewData.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/ViewData.swift deleted file mode 100644 index ffb360b7..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/ViewData.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Created by Lukáš Matuška on 04.09.2024 -// Copyright © 2024 Matee. All rights reserved. -// - -public enum ViewData: Equatable { - - public enum EmptyReason: Equatable { - case noData - case search - } - - case data(Data) - case error(Error) - case loading(mock: Data) - case empty(EmptyReason) - - public var isLoading: Bool { - switch self { - case .loading: true - default: false - } - } - - public var isError: Bool { - switch self { - case .error: true - default: false - } - } - - public var hasData: Bool { - switch self { - case .data: true - default: false - } - } - - public var data: Data? { - switch self { - case let .data(data): data - default: nil - } - } - - public static func == (lhs: ViewData, rhs: ViewData) -> Bool { - switch (lhs, rhs) { - case let (.data(ldata), .data(rdata)): ldata == rdata - case let (.error(lerror), .error(rerror)): lerror.localizedDescription == rerror.localizedDescription - case let (.loading(ldata), .loading(rdata)): ldata == rdata - case let (.empty(lreason), .empty(rreason)): lreason == rreason - default: false - } - } -} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/ViewModel.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/ViewModel.swift deleted file mode 100644 index 01422226..00000000 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Utilities/ViewModel.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Created by Petr Chmelar on 21.02.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -@MainActor -public protocol ViewModel { - // Lifecycle - func onAppear() - func onDisappear() - - // State - associatedtype State - var state: State { get } // swiftlint:disable:this let_var_whitespace - - // Intent - associatedtype Intent - func onIntent(_ intent: Intent) -} - -public protocol ViewModelEvent: Sendable, Equatable {} diff --git a/ios/scripts/build-kmp.sh b/ios/scripts/build-kmp.sh index 35c8da10..5d4fb30b 100755 --- a/ios/scripts/build-kmp.sh +++ b/ios/scripts/build-kmp.sh @@ -1,7 +1,7 @@ -./gradlew :shared:core:embedAndSignAppleFrameworkForXcode < /dev/null | ./ios/scripts/kmp-beautify.sh +./gradlew :shared:umbrella:embedAndSignAppleFrameworkForXcode < /dev/null | ./ios/scripts/kmp-beautify.sh # Copy the framework to indexer directory to support Xcode hinting/autocomplete DERIVED_DATA_DIR="$(echo "${TARGET_BUILD_DIR}" | awk -F'/Build/' '{print $1}')" INDEXER_DATA_DIR="${DERIVED_DATA_DIR}/Index.noindex/Build/Products/Debug-${PLATFORM_NAME}" mkdir -p "$INDEXER_DATA_DIR" -cp -R "shared/core/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/"* "${INDEXER_DATA_DIR}" \ No newline at end of file +cp -R "shared/umbrella/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/"* "${INDEXER_DATA_DIR}" \ No newline at end of file diff --git a/scripts/rename-project.sh b/scripts/rename-project.sh new file mode 100755 index 00000000..35981904 --- /dev/null +++ b/scripts/rename-project.sh @@ -0,0 +1,220 @@ +#!/bin/bash + +# Script to rename the project from MateeStarter to a new name +# This handles Android and shared modules. For iOS, use ios/scripts/rename.sh + +set -e + +# Get the directory where the script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +old_name="MateeStarter" +old_name_lowercase=$(echo "${old_name}" | tr '[:upper:]' '[:lower:]') +old_name_uppercase=$(echo "${old_name}" | tr '[:lower:]' '[:upper:]') + +echo -n "Enter new project name (e.g., MyApp): " +read new_name +if [ -z "$new_name" ]; then + echo "Error: Project name cannot be empty" + exit 1 +fi + +new_name_lowercase=$(echo "${new_name}" | tr '[:upper:]' '[:lower:]') +new_name_uppercase=$(echo "${new_name}" | tr '[:lower:]' '[:upper:]') + +# Convert to valid package name (lowercase, no spaces, no special chars except dots) +package_name=$(echo "${new_name_lowercase}" | sed 's/[^a-z0-9]//g') + +echo "" +echo "Renaming project from '${old_name}' to '${new_name}'" +echo "Package name will be: ${package_name}" +echo "" +read -p "Continue? (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 +fi + +cd "$PROJECT_ROOT" + +echo "1. Updating settings.gradle.kts..." +sed -i '' "s/rootProject.name = \"${old_name}\"/rootProject.name = \"${new_name}\"/g" settings.gradle.kts + +echo "2. Updating Application.kt..." +sed -i '' "s/const val id = \"cz.matee.starter.kmp.android\"/const val id = \"cz.matee.${package_name}.kmp.android\"/g" build-logic/convention/src/main/kotlin/constants/Application.kt +sed -i '' "s/const val appName = \"${old_name}\"/const val appName = \"${new_name}\"/g" build-logic/convention/src/main/kotlin/constants/Application.kt + +echo "3. Updating libs.versions.toml plugin names..." +sed -i '' "s/mateeStarter-android-application-compose/${new_name_lowercase}-android-application-compose/g" gradle/libs.versions.toml +sed -i '' "s/mateeStarter-android-application-core/${new_name_lowercase}-android-application-core/g" gradle/libs.versions.toml +sed -i '' "s/mateeStarter-android-library-compose/${new_name_lowercase}-android-library-compose/g" gradle/libs.versions.toml +sed -i '' "s/mateeStarter-android-library-core/${new_name_lowercase}-android-library-core/g" gradle/libs.versions.toml +sed -i '' "s/mateeStarter-kmp-library-core/${new_name_lowercase}-kmp-library-core/g" gradle/libs.versions.toml +sed -i '' "s/mateeStarter-kmp-library-compose/${new_name_lowercase}-kmp-library-compose/g" gradle/libs.versions.toml +sed -i '' "s/mateeStarter-kmp-framework-library/${new_name_lowercase}-kmp-framework-library/g" gradle/libs.versions.toml + +echo "4. Updating build-logic/convention/build.gradle.kts..." +sed -i '' "s/mateeStarter.android.application.compose/${new_name_lowercase}.android.application.compose/g" build-logic/convention/build.gradle.kts +sed -i '' "s/mateeStarter.android.application.core/${new_name_lowercase}.android.application.core/g" build-logic/convention/build.gradle.kts +sed -i '' "s/mateeStarter.android.library.compose/${new_name_lowercase}.android.library.compose/g" build-logic/convention/build.gradle.kts +sed -i '' "s/mateeStarter.android.library.core/${new_name_lowercase}.android.library.core/g" build-logic/convention/build.gradle.kts +sed -i '' "s/mateeStarter.kmp.library.core/${new_name_lowercase}.kmp.library.core/g" build-logic/convention/build.gradle.kts +sed -i '' "s/mateeStarter.kmp.library.compose/${new_name_lowercase}.kmp.library.compose/g" build-logic/convention/build.gradle.kts +sed -i '' "s/mateeStarter.kmp.framework.library/${new_name_lowercase}.kmp.framework.library/g" build-logic/convention/build.gradle.kts + +echo "5. Updating build.gradle.kts files in modules..." +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.android.application.compose/${new_name_lowercase}.android.application.compose/g" {} + +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.android.application.core/${new_name_lowercase}.android.application.core/g" {} + +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.android.library.compose/${new_name_lowercase}.android.library.compose/g" {} + +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.android.library.core/${new_name_lowercase}.android.library.core/g" {} + +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.kmp.library.core/${new_name_lowercase}.kmp.library.core/g" {} + +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.kmp.library.compose/${new_name_lowercase}.kmp.library.compose/g" {} + +find . -name "build.gradle.kts" -type f ! -path "*/build/*" ! -path "*/build-logic/*" -exec sed -i '' "s/mateeStarter.kmp.framework.library/${new_name_lowercase}.kmp.framework.library/g" {} + + +echo "6. Creating new README.md..." +cat > README.md << EOF +# ${new_name} + +## Description + +${new_name} is a Kotlin Multiplatform mobile application for Android and iOS. + +## Architecture + +The project uses Clean Architecture with shared business logic across platforms: +- **Shared**: Data layer, domain layer, view models, and Compose Multiplatform UI +- **Platform-specific**: Navigation only + +## Getting Started + +### Prerequisites + +- Android Studio or IntelliJ IDEA +- Xcode (for iOS development) +- JDK 17 or higher + +### Setup + +1. Clone the repository +2. Open the project in Android Studio +3. Sync Gradle files +4. For iOS: Open \`ios/${new_name}.xcworkspace\` in Xcode +5. **⚠️ Important**: Replace \`MockTokenRefresher\` in \`shared/auth/src/commonMain/kotlin/kmp/shared/auth/di/AuthModule.kt\` with a real implementation using your authentication service (FirebaseAuth, Auth0, etc.) + +### Building + +#### Android + +The project uses Android build variants with two dimensions: + +1. **Build Type** (debug/release): + - \`debug\` - Development builds with debug signing + - \`release\` - Release builds with release signing + +2. **API Variant** (alpha/production): + - \`alpha\` - Connected to alpha/staging data sources (app name prefixed with "[A]") + - \`production\` - Connected to production data sources + +Available build variants: +- \`alphaDebug\` - Alpha API with debug build +- \`alphaRelease\` - Alpha API with release build +- \`productionDebug\` - Production API with debug build +- \`productionRelease\` - Production API with release build + +Build specific variants: +\`\`\`bash +# Build alpha debug variant +./gradlew assembleAlphaDebug + +# Build production release variant +./gradlew assembleProductionRelease +\`\`\` + +#### iOS +Open the workspace in Xcode and build from there. + +## Project Structure + +- \`shared/\` - Shared Kotlin Multiplatform modules + - \`base/\` - Base classes and utilities + - \`auth/\` - Authentication module + - \`analytics/\` - Analytics tracking module + - \`umbrella/\` - Main shared module combining all features + - \`samplefeature/\` - Example feature module +- \`android/\` - Android-specific modules + - \`app/\` - Main Android application + - \`samplefeature/\` - Example feature module + - \`shared/\` - Shared Android code +- \`ios/\` - iOS project + +## Convention Plugins + +The project uses Gradle convention plugins (located in \`build-logic/convention\`) to standardize build configuration across modules. These plugins automatically apply common configurations, dependencies, and settings. + +### Available Convention Plugins + +#### Android Modules +- **\`android-application-compose\`** - For Android application modules with Compose support + - Applies Android application plugin, Compose compiler, and Compose dependencies + - Configures build variants (alpha/production), signing, and Twine string generation +- **\`android-application-core\`** - For Android application modules without Compose + - Same as above but without Compose configuration +- **\`android-library-compose\`** - For Android library modules with Compose support + - Applies Android library plugin and Compose dependencies +- **\`android-library-core\`** - For Android library modules without Compose + - Applies Android library plugin with standard Android configuration + +#### Kotlin Multiplatform Modules +- **\`kmp-library-core\`** - For KMP library modules + - Configures Kotlin Multiplatform with Android and iOS targets + - Applies Moko Resources for shared string resources + - Sets up common dependencies and test configuration +- **\`kmp-library-compose\`** - For KMP library modules with Compose Multiplatform + - Extends \`kmp-library-core\` and adds Compose Multiplatform support + - Configures Compose compiler and dependencies +- **\`kmp-framework-library\`** - For KMP modules that generate iOS frameworks + - Extends \`kmp-library-core\` and configures iOS framework generation + - Used by \`:shared:umbrella\` module to generate the XCFramework for iOS + +### Usage + +Simply apply the convention plugin in your module's \`build.gradle.kts\`: + +\`\`\`kotlin +plugins { + alias(libs.plugins.${new_name_lowercase}.android.application.compose) + // or + alias(libs.plugins.${new_name_lowercase}.kmp.library.compose) +} +\`\`\` + +The plugin IDs are defined in \`gradle/libs.versions.toml\` and can be customized after renaming the project. + +## Technologies + +- Kotlin Multiplatform +- Compose Multiplatform (Shared UI) +- Ktor (Networking) +- Koin (Dependency Injection for Android/Shared) +- Factory (Dependency Injection for iOS) + +## License + +[Add your license here] +EOF + +echo "" +echo "✅ Renaming successful!" +echo "" +echo "⚠️ IMPORTANT: Manual steps required:" +echo " 1. Update google-services.json files:" +echo " - android/app/src/alpha/google-services.json" +echo " - android/app/src/production/google-services.json" +echo " 2. Update iOS project using: ios/scripts/rename.sh" +echo " 3. Review and update any Firebase/Google Services configuration" +echo " 4. Update package names in AndroidManifest.xml if needed" +echo " 5. Sync Gradle files in your IDE" +echo "" + diff --git a/settings.gradle.kts b/settings.gradle.kts index 548f1582..ece3840e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,13 +21,10 @@ rootProject.name = "MateeStarter" include(":android:app") include(":android:shared") -include(":android:sample") -include(":android:samplesharedviewmodel") -include(":android:samplecomposemultiplatform") +include(":android:samplefeature") -include(":shared:core") +include(":shared:umbrella") include(":shared:base") -include(":shared:sample") -include(":shared:samplesharedviewmodel") -include(":shared:samplecomposemultiplatform") -include(":shared:samplecomposenavigation") +include(":shared:analytics") +include(":shared:samplefeature") +include(":shared:auth") diff --git a/shared/analytics/build.gradle.kts b/shared/analytics/build.gradle.kts new file mode 100644 index 00000000..2444c04b --- /dev/null +++ b/shared/analytics/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.mateeStarter.kmp.library.core) +} + +android { + namespace = "kmp.shared.analytics" +} + +dependencies { + commonMainImplementation(project(":shared:base")) + + androidMainImplementation(libs.firebase.analytics) +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/analytics/AndroidAnalyticsProviderImpl.kt b/shared/analytics/src/androidMain/kotlin/kmp/shared/analytics/data/provider/AndroidAnalyticsProviderImpl.kt similarity index 63% rename from shared/base/src/androidMain/kotlin/kmp/shared/base/analytics/AndroidAnalyticsProviderImpl.kt rename to shared/analytics/src/androidMain/kotlin/kmp/shared/analytics/data/provider/AndroidAnalyticsProviderImpl.kt index 899a3349..f7d28b84 100644 --- a/shared/base/src/androidMain/kotlin/kmp/shared/base/analytics/AndroidAnalyticsProviderImpl.kt +++ b/shared/analytics/src/androidMain/kotlin/kmp/shared/analytics/data/provider/AndroidAnalyticsProviderImpl.kt @@ -1,24 +1,26 @@ -package kmp.shared.base.analytics +package kmp.shared.analytics.data.provider import com.google.firebase.Firebase import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.analytics import com.google.firebase.analytics.logEvent -import kmp.shared.analytics.data.provider.AnalyticsProvider import kmp.shared.analytics.domain.model.AnalyticsEvent +import kmp.shared.base.domain.model.Result +import kmp.shared.base.domain.util.extension.success /** - * Android implementation of [AnalyticsProvider]. + * Android implementation of [kmp.shared.analytics.data.provider.AnalyticsProvider]. */ class AndroidAnalyticsProviderImpl : AnalyticsProvider { private var firebaseAnalytics: FirebaseAnalytics = Firebase.analytics - override fun logEvent(event: AnalyticsEvent) { + override fun logEvent(event: AnalyticsEvent): Result { firebaseAnalytics.logEvent(event.eventName) { event.parameters.forEach { (key, value) -> param(key, value) } } + return Unit.success() } } diff --git a/shared/analytics/src/androidMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.android.kt b/shared/analytics/src/androidMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.android.kt new file mode 100644 index 00000000..b809d1e7 --- /dev/null +++ b/shared/analytics/src/androidMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.android.kt @@ -0,0 +1,12 @@ +package kmp.shared.analytics.di + +import kmp.shared.analytics.data.provider.AnalyticsProvider +import kmp.shared.analytics.data.provider.AndroidAnalyticsProviderImpl +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +internal actual val analyticsPlatformModule: Module = module { + singleOf(::AndroidAnalyticsProviderImpl) bind AnalyticsProvider::class +} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/data/provider/AnalyticsProvider.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/data/provider/AnalyticsProvider.kt similarity index 64% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/data/provider/AnalyticsProvider.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/data/provider/AnalyticsProvider.kt index 6118ec82..eda23dbe 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/data/provider/AnalyticsProvider.kt +++ b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/data/provider/AnalyticsProvider.kt @@ -1,12 +1,13 @@ package kmp.shared.analytics.data.provider import kmp.shared.analytics.domain.model.AnalyticsEvent +import kmp.shared.base.domain.model.Result /** * Provider to log analytics events. * This interface is implemented by platform-specific sources. - * @see kmp.shared.base.analytics.AndroidAnalyticsProviderImpl + * @see AndroidAnalyticsProviderImpl */ interface AnalyticsProvider { - fun logEvent(event: AnalyticsEvent) + fun logEvent(event: AnalyticsEvent): Result } diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/data/repository/AnalyticsRepositoryImpl.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/data/repository/AnalyticsRepositoryImpl.kt similarity index 78% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/data/repository/AnalyticsRepositoryImpl.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/data/repository/AnalyticsRepositoryImpl.kt index 1200715c..ea6fdf49 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/data/repository/AnalyticsRepositoryImpl.kt +++ b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/data/repository/AnalyticsRepositoryImpl.kt @@ -3,11 +3,11 @@ package kmp.shared.analytics.data.repository import kmp.shared.analytics.data.provider.AnalyticsProvider import kmp.shared.analytics.domain.model.AnalyticsEvent import kmp.shared.analytics.domain.repository.AnalyticsRepository +import kmp.shared.base.domain.model.Result internal class AnalyticsRepositoryImpl( private val analyticsProvider: AnalyticsProvider, ) : AnalyticsRepository { - override fun logEvent(event: AnalyticsEvent) { + override fun logEvent(event: AnalyticsEvent): Result = analyticsProvider.logEvent(event) - } } diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/di/Module.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.kt similarity index 84% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/di/Module.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.kt index ff8878ae..fe3c0c0c 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/di/Module.kt +++ b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.kt @@ -4,15 +4,20 @@ import kmp.shared.analytics.data.repository.AnalyticsRepositoryImpl import kmp.shared.analytics.domain.repository.AnalyticsRepository import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCaseImpl +import org.koin.core.module.Module import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.dsl.bind import org.koin.dsl.module val analyticsModule = module { + includes(analyticsPlatformModule) + // Use cases factoryOf(::TrackAnalyticsEventUseCaseImpl) bind TrackAnalyticsEventUseCase::class // Repositories singleOf(::AnalyticsRepositoryImpl) bind AnalyticsRepository::class } + +internal expect val analyticsPlatformModule: Module diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/model/AnalyticsEvent.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/model/AnalyticsEvent.kt similarity index 100% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/model/AnalyticsEvent.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/model/AnalyticsEvent.kt diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/model/ToastAnalytics.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/model/ToastAnalytics.kt similarity index 100% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/model/ToastAnalytics.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/model/ToastAnalytics.kt diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/repository/AnalyticsRepository.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/repository/AnalyticsRepository.kt similarity index 66% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/repository/AnalyticsRepository.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/repository/AnalyticsRepository.kt index 4cfb702b..720194c7 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/repository/AnalyticsRepository.kt +++ b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/repository/AnalyticsRepository.kt @@ -1,10 +1,11 @@ package kmp.shared.analytics.domain.repository import kmp.shared.analytics.domain.model.AnalyticsEvent +import kmp.shared.base.domain.model.Result /** * Repository to log analytics events. */ internal interface AnalyticsRepository { - fun logEvent(event: AnalyticsEvent) + fun logEvent(event: AnalyticsEvent): Result } diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/usecase/TrackAnalyticsEventUseCase.kt b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/usecase/TrackAnalyticsEventUseCase.kt similarity index 77% rename from shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/usecase/TrackAnalyticsEventUseCase.kt rename to shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/usecase/TrackAnalyticsEventUseCase.kt index 05b9252e..2cc04ea6 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/analytics/domain/usecase/TrackAnalyticsEventUseCase.kt +++ b/shared/analytics/src/commonMain/kotlin/kmp/shared/analytics/domain/usecase/TrackAnalyticsEventUseCase.kt @@ -2,9 +2,8 @@ package kmp.shared.analytics.domain.usecase import kmp.shared.analytics.domain.model.AnalyticsEvent import kmp.shared.analytics.domain.repository.AnalyticsRepository -import kmp.shared.base.Result -import kmp.shared.base.usecase.UseCaseResult -import kmp.shared.base.util.extension.success +import kmp.shared.base.domain.model.Result +import kmp.shared.base.domain.usecase.UseCaseResult /** * Use case to track an analytics event. @@ -21,7 +20,7 @@ interface TrackAnalyticsEventUseCase : UseCaseResult { - return repository.logEvent(params.event).success() - } + + override suspend fun invoke(params: TrackAnalyticsEventUseCase.Params): Result = + repository.logEvent(params.event) } diff --git a/shared/analytics/src/iosMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.ios.kt b/shared/analytics/src/iosMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.ios.kt new file mode 100644 index 00000000..3dde2d9e --- /dev/null +++ b/shared/analytics/src/iosMain/kotlin/kmp/shared/analytics/di/AnalyticsModule.ios.kt @@ -0,0 +1,6 @@ +package kmp.shared.analytics.di + +import org.koin.core.module.Module +import org.koin.dsl.module + +internal actual val analyticsPlatformModule: Module = module { } diff --git a/android/samplecomposemultiplatform/.gitignore b/shared/auth/.gitignore similarity index 100% rename from android/samplecomposemultiplatform/.gitignore rename to shared/auth/.gitignore diff --git a/shared/auth/build.gradle.kts b/shared/auth/build.gradle.kts new file mode 100644 index 00000000..abd40e8b --- /dev/null +++ b/shared/auth/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + alias(libs.plugins.mateeStarter.kmp.library.core) +} + +android { + namespace = "kmp.shared.auth" +} + +dependencies { + commonMainImplementation(project(":shared:base")) +} diff --git a/shared/auth/src/androidMain/kotlin/kmp/shared/auth/di/AuthModule.android.kt b/shared/auth/src/androidMain/kotlin/kmp/shared/auth/di/AuthModule.android.kt new file mode 100644 index 00000000..43ac06c1 --- /dev/null +++ b/shared/auth/src/androidMain/kotlin/kmp/shared/auth/di/AuthModule.android.kt @@ -0,0 +1,20 @@ +package kmp.shared.auth.di + +import kmp.shared.auth.data.provider.AuthProviderImpl +import kmp.shared.base.data.preferences.SharedPreferencesFactory +import kmp.shared.base.data.preferences.SharedPreferencesType +import kmp.shared.base.data.provider.AuthProvider +import org.koin.core.module.Module +import org.koin.dsl.module + +internal actual val authPlatformModule: Module = module { + single { + AuthProviderImpl( + settings = get().create( + fileName = "auth", + type = SharedPreferencesType.ENCRYPTED, + ), + tokenRefresher = get(), + ) + } +} diff --git a/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/provider/AuthProviderImpl.kt b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/provider/AuthProviderImpl.kt new file mode 100644 index 00000000..e121ae99 --- /dev/null +++ b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/provider/AuthProviderImpl.kt @@ -0,0 +1,52 @@ +package kmp.shared.auth.data.provider + +import com.russhwolf.settings.Settings +import com.russhwolf.settings.set +import kmp.shared.auth.data.remote.TokenRefresher +import kmp.shared.base.data.provider.AuthProvider +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class AuthProviderImpl( + private val settings: Settings, + private val tokenRefresher: TokenRefresher, +) : AuthProvider { + + override var token: String? + get() = settings.getStringOrNull(TOKEN_KEY) + set(value) = settings.set(TOKEN_KEY, value) + + private val mutex = Mutex() + private var inFlight: Deferred? = null + + override suspend fun refreshToken(): String? = coroutineScope { + mutex.withLock { + inFlight?.let { return@coroutineScope it.await() } + + val task = async { + val fresh = tokenRefresher.refresh() + token = fresh + fresh + } + inFlight = task + task + }.let { task -> + try { + task.await() + } finally { + mutex.withLock { + if (inFlight === task) { + inFlight = null + } + } + } + } + } + + private companion object { + const val TOKEN_KEY = "token" + } +} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/auth/infrastructure/remote/AuthService.kt b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/AuthService.kt similarity index 53% rename from shared/base/src/commonMain/kotlin/kmp/shared/auth/infrastructure/remote/AuthService.kt rename to shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/AuthService.kt index 68d0a846..d4e83cbf 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/auth/infrastructure/remote/AuthService.kt +++ b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/AuthService.kt @@ -1,9 +1,9 @@ -package kmp.shared.auth.infrastructure.remote +package kmp.shared.auth.data.remote import io.ktor.client.HttpClient -import kmp.shared.base.Result -import kmp.shared.base.infrastucture.remote.clearBearerTokens -import kmp.shared.base.util.extension.success +import kmp.shared.base.data.remote.clearBearerTokens +import kmp.shared.base.domain.model.Result +import kmp.shared.base.domain.util.extension.success internal class AuthService(private val client: HttpClient) { diff --git a/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/MockTokenRefresher.kt b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/MockTokenRefresher.kt new file mode 100644 index 00000000..18d1fbfe --- /dev/null +++ b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/MockTokenRefresher.kt @@ -0,0 +1,7 @@ +package kmp.shared.auth.data.remote + +internal class MockTokenRefresher : TokenRefresher { + override suspend fun refresh(): String? { + return "mockToken" + } +} diff --git a/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/TokenRefresher.kt b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/TokenRefresher.kt new file mode 100644 index 00000000..4deb3f20 --- /dev/null +++ b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/data/remote/TokenRefresher.kt @@ -0,0 +1,5 @@ +package kmp.shared.auth.data.remote + +internal interface TokenRefresher { + suspend fun refresh(): String? +} diff --git a/shared/auth/src/commonMain/kotlin/kmp/shared/auth/di/AuthModule.kt b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/di/AuthModule.kt new file mode 100644 index 00000000..1f427c63 --- /dev/null +++ b/shared/auth/src/commonMain/kotlin/kmp/shared/auth/di/AuthModule.kt @@ -0,0 +1,21 @@ +package kmp.shared.auth.di + +import kmp.shared.auth.data.remote.AuthService +import kmp.shared.auth.data.remote.MockTokenRefresher +import kmp.shared.auth.data.remote.TokenRefresher +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val authModule = module { + includes(authPlatformModule) + + // Services + singleOf(::AuthService) + + // Mock + singleOf(::MockTokenRefresher) bind TokenRefresher::class +} + +internal expect val authPlatformModule: Module diff --git a/shared/auth/src/iosMain/kotlin/kmp/shared/auth/di/AuthModule.ios.kt b/shared/auth/src/iosMain/kotlin/kmp/shared/auth/di/AuthModule.ios.kt new file mode 100644 index 00000000..de64e088 --- /dev/null +++ b/shared/auth/src/iosMain/kotlin/kmp/shared/auth/di/AuthModule.ios.kt @@ -0,0 +1,16 @@ +package kmp.shared.auth.di + +import kmp.shared.auth.data.provider.AuthProviderImpl +import kmp.shared.base.data.keychain.KeychainFactory +import kmp.shared.base.data.provider.AuthProvider +import org.koin.core.module.Module +import org.koin.dsl.module + +internal actual val authPlatformModule: Module = module { + single { + AuthProviderImpl( + settings = get().create(), + tokenRefresher = get(), + ) + } +} diff --git a/shared/base/build.gradle.kts b/shared/base/build.gradle.kts index 0c641c24..5068ae90 100644 --- a/shared/base/build.gradle.kts +++ b/shared/base/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - alias(libs.plugins.mateeStarter.kmm.library) + alias(libs.plugins.mateeStarter.kmp.library.compose) } android { @@ -12,12 +12,10 @@ multiplatformResources { ktlint { filter { - exclude { entry -> - entry.file.toString().contains("generated") - } + exclude("**/KeychainAccessibleAfterFirstUnlockSettings.kt") } } dependencies { - androidMainImplementation(libs.firebase.analytics) + commonMainImplementation(libs.molecule.runtime) } diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SecureSharedPreferences.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SecureSharedPreferences.kt new file mode 100644 index 00000000..b15cca1e --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SecureSharedPreferences.kt @@ -0,0 +1,109 @@ +package kmp.shared.base.data.preferences + +import android.content.Context +import android.content.SharedPreferences +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class SecureSharedPreferences( + context: Context, + fileName: String = "secure_prefs", +) : SharedPreferences { + + private val delegate = context.getSharedPreferences(fileName, Context.MODE_PRIVATE) + private val keyAlias = "secure_shared_prefs_key" + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + + private fun getOrCreateSecretKey(): SecretKey { + val existingKey = keyStore.getKey(keyAlias, null) as? SecretKey + if (existingKey != null) return existingKey + + val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + val paramSpec = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + + keyGen.init(paramSpec) + return keyGen.generateKey() + } + + private fun encrypt(value: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) + val iv = cipher.iv + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val combined = iv + encrypted + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + private fun decrypt(value: String): String { + val decoded = Base64.decode(value, Base64.NO_WRAP) + val iv = decoded.copyOfRange(0, 12) + val encrypted = decoded.copyOfRange(12, decoded.size) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(128, iv)) + return String(cipher.doFinal(encrypted), Charsets.UTF_8) + } + + override fun getAll(): MutableMap = + delegate.all.mapValues { (_, v) -> + if (v is String) runCatching { decrypt(v) }.getOrNull() ?: v else v + }.toMutableMap() + + override fun getString(key: String?, defValue: String?): String? = + delegate.getString(key, null)?.let { runCatching { decrypt(it) }.getOrNull() } ?: defValue + + override fun edit(): SharedPreferences.Editor = SecureEditor(delegate.edit()) + + private inner class SecureEditor(private val editor: SharedPreferences.Editor) : + SharedPreferences.Editor { + + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + if (key != null && value != null) { + editor.putString(key, encrypt(value)) + } + return this + } + + override fun clear(): SharedPreferences.Editor = apply { editor.clear() } + override fun apply() = editor.apply() + override fun commit() = editor.commit() + + override fun putStringSet(key: String?, values: MutableSet?) = + apply { editor.putStringSet(key, values) } + + override fun putInt(key: String?, value: Int) = apply { editor.putInt(key, value) } + override fun putLong(key: String?, value: Long) = apply { editor.putLong(key, value) } + override fun putFloat(key: String?, value: Float) = apply { editor.putFloat(key, value) } + override fun putBoolean(key: String?, value: Boolean) = + apply { editor.putBoolean(key, value) } + + override fun remove(key: String?) = apply { editor.remove(key) } + } + + override fun contains(key: String?): Boolean = delegate.contains(key) + override fun getBoolean(key: String?, defValue: Boolean) = delegate.getBoolean(key, defValue) + override fun getFloat(key: String?, defValue: Float) = delegate.getFloat(key, defValue) + override fun getInt(key: String?, defValue: Int) = delegate.getInt(key, defValue) + override fun getLong(key: String?, defValue: Long) = delegate.getLong(key, defValue) + override fun getStringSet(key: String?, defValues: MutableSet?) = + delegate.getStringSet(key, defValues) + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = + delegate.registerOnSharedPreferenceChangeListener(listener) + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = + delegate.unregisterOnSharedPreferenceChangeListener(listener) +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SharedPreferencesFactory.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SharedPreferencesFactory.kt new file mode 100644 index 00000000..e785a1eb --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SharedPreferencesFactory.kt @@ -0,0 +1,23 @@ +package kmp.shared.base.data.preferences + +import android.content.Context +import com.russhwolf.settings.SharedPreferencesSettings + +class SharedPreferencesFactory( + private val context: Context, +) { + + fun create(fileName: String, type: SharedPreferencesType): SharedPreferencesSettings { + return SharedPreferencesSettings( + when (type) { + SharedPreferencesType.PLAIN -> { + context.getSharedPreferences(fileName, Context.MODE_PRIVATE) + } + + SharedPreferencesType.ENCRYPTED -> { + SecureSharedPreferences(context = context, fileName = fileName) + } + }, + ) + } +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SharedPreferencesType.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SharedPreferencesType.kt new file mode 100644 index 00000000..a1fadf9e --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/data/preferences/SharedPreferencesType.kt @@ -0,0 +1,5 @@ +package kmp.shared.base.data.preferences + +enum class SharedPreferencesType { + PLAIN, ENCRYPTED +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/di/AndroidModule.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/di/AndroidModule.kt deleted file mode 100644 index 7d5e0150..00000000 --- a/shared/base/src/androidMain/kotlin/kmp/shared/base/di/AndroidModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package kmp.shared.base.di - -import io.ktor.client.engine.android.Android -import kmp.shared.analytics.data.provider.AnalyticsProvider -import kmp.shared.base.analytics.AndroidAnalyticsProviderImpl -import kmp.shared.base.system.Config -import kmp.shared.base.system.ConfigImpl -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.bind -import org.koin.dsl.module - -internal actual val platformModule = module { - singleOf(::ConfigImpl) bind Config::class - single { Android.create() } - singleOf(::AndroidAnalyticsProviderImpl) bind AnalyticsProvider::class -} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/di/BaseModule.android.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/di/BaseModule.android.kt new file mode 100644 index 00000000..82fe3d14 --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/di/BaseModule.android.kt @@ -0,0 +1,17 @@ +package kmp.shared.base.di + +import io.ktor.client.engine.android.Android +import kmp.shared.base.data.preferences.SharedPreferencesFactory +import kmp.shared.base.domain.system.Config +import kmp.shared.base.domain.system.ConfigImpl +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +internal actual val basePlatformModule = module { + singleOf(::ConfigImpl) bind Config::class + single { Android.create() } + + factoryOf(::SharedPreferencesFactory) +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/system/ConfigImpl.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/domain/system/ConfigImpl.kt similarity index 73% rename from shared/base/src/androidMain/kotlin/kmp/shared/base/system/ConfigImpl.kt rename to shared/base/src/androidMain/kotlin/kmp/shared/base/domain/system/ConfigImpl.kt index 052d696c..73adecc2 100644 --- a/shared/base/src/androidMain/kotlin/kmp/shared/base/system/ConfigImpl.kt +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/domain/system/ConfigImpl.kt @@ -1,4 +1,4 @@ -package kmp.shared.base.system +package kmp.shared.base.domain.system import kmp.shared.base.BuildConfig @@ -7,5 +7,6 @@ internal class ConfigImpl : Config { get() = BuildConfig.BUILD_TYPE == "release" override val apiVariant: ApiVariant - get() = ApiVariant.entries.firstOrNull { it.name.lowercase() == BuildConfig.FLAVOR } ?: ApiVariant.Alpha + get() = ApiVariant.entries.firstOrNull { it.name.lowercase() == BuildConfig.FLAVOR } + ?: ApiVariant.Alpha } diff --git a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.android.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.android.kt similarity index 93% rename from shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.android.kt rename to shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.android.kt index e309cbe5..7fcb0512 100644 --- a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.android.kt +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.android.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposemultiplatform.presentation.common +package kmp.shared.base.presentation.ui import androidx.compose.foundation.Indication import androidx.compose.foundation.LocalIndication diff --git a/shared/samplesharedviewmodel/src/androidMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt similarity index 96% rename from shared/samplesharedviewmodel/src/androidMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt rename to shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt index fd65ca82..0c09a6bb 100644 --- a/shared/samplesharedviewmodel/src/androidMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplesharedviewmodel.base.vm +package kmp.shared.base.presentation.vm import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/auth/di/Module.kt b/shared/base/src/commonMain/kotlin/kmp/shared/auth/di/Module.kt deleted file mode 100644 index 5b30b49c..00000000 --- a/shared/base/src/commonMain/kotlin/kmp/shared/auth/di/Module.kt +++ /dev/null @@ -1,10 +0,0 @@ -package kmp.shared.auth.di - -import kmp.shared.auth.infrastructure.remote.AuthService -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.module - -val authModule = module { - // Services - singleOf(::AuthService) -} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/data/provider/AuthProvider.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/data/provider/AuthProvider.kt new file mode 100644 index 00000000..4a9718cb --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/data/provider/AuthProvider.kt @@ -0,0 +1,6 @@ +package kmp.shared.base.data.provider + +interface AuthProvider { + var token: String? + suspend fun refreshToken(): String? +} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/data/remote/HttpClient.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/data/remote/HttpClient.kt new file mode 100644 index 00000000..262c85ce --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/data/remote/HttpClient.kt @@ -0,0 +1,102 @@ +package kmp.shared.base.data.remote + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.authProviders +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.http.URLProtocol +import io.ktor.http.contentType +import io.ktor.http.encodedPath +import io.ktor.serialization.kotlinx.json.json +import kmp.shared.base.data.provider.AuthProvider +import kmp.shared.base.domain.system.ApiVariant +import kmp.shared.base.domain.system.Config +import kotlin.native.concurrent.ThreadLocal +import co.touchlab.kermit.Logger as KermitLogger +import kotlinx.serialization.json.Json as JsonConfig + +internal object HttpClient { + private val unauthorizedEndpoints = listOf("/api/auth/login", "/api/auth/registration") + + fun init(config: Config, engine: HttpClientEngine, authProvider: AuthProvider) = + HttpClient(engine).config { + expectSuccess = true + followRedirects = false + + install(ContentNegotiation) { + json(globalJson) + } + + if (!config.isRelease) { + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + KermitLogger.d { message } + } + } + level = LogLevel.ALL + } + } + + install(Auth) { + // Use if your authentication method is a bearer token (other options are `basic` and `digest`) + bearer { + loadTokens { + authProvider.token?.let { token -> + // Use your access and refresh tokens here (you can use access token for both if you don't use refresh token) + BearerTokens(token, token) + } + } + + refreshTokens { + authProvider.refreshToken()?.let { token -> + // Use your access and refresh tokens here (you can use access token for both if you don't use refresh token) + BearerTokens(token, token) + } + } + + sendWithoutRequest { request -> + unauthorizedEndpoints.any(request.url.encodedPath::equals) + } + } + } + + defaultRequest { + url { + protocol = URLProtocol.HTTPS + // Set your host URLs + host = when (config.apiVariant) { + ApiVariant.Alpha -> "official-joke-api.appspot.com" + ApiVariant.Production -> "official-joke-api.appspot.com" + } + } + contentType(ContentType.Application.Json) + } + } +} + +/** + * Force the Auth plugin to invoke the `loadTokens` block again on the next client request. + */ +fun HttpClient.clearBearerTokens() { + authProviders + .filterIsInstance() + .firstOrNull() + ?.clearToken() +} + +@ThreadLocal +val globalJson = JsonConfig { + ignoreUnknownKeys = true + coerceInputValues = true + useAlternativeNames = false +} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/di/BaseModule.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/di/BaseModule.kt new file mode 100644 index 00000000..acc5905c --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/di/BaseModule.kt @@ -0,0 +1,18 @@ +package kmp.shared.base.di + +import com.russhwolf.settings.Settings +import kmp.shared.base.data.remote.HttpClient +import org.koin.core.module.Module +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val baseModule = module { + + includes(basePlatformModule) + + // General + singleOf(HttpClient::init) + singleOf(::Settings) +} + +internal expect val basePlatformModule: Module diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/di/Module.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/di/Module.kt deleted file mode 100644 index daacccde..00000000 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/di/Module.kt +++ /dev/null @@ -1,24 +0,0 @@ -package kmp.shared.base.di - -import com.russhwolf.settings.Settings -import kmp.shared.base.infrastucture.provider.AuthProvider -import kmp.shared.base.infrastucture.provider.AuthProviderImpl -import kmp.shared.base.infrastucture.remote.HttpClient -import org.koin.core.module.Module -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.bind -import org.koin.dsl.module - -val baseModule = module { - - includes(platformModule) - - // General - singleOf(HttpClient::init) - singleOf(::Settings) - - // Providers - singleOf(::AuthProviderImpl) bind AuthProvider::class -} - -internal expect val platformModule: Module diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/error/domain/BackendError.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/domain/BackendError.kt similarity index 89% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/error/domain/BackendError.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/domain/BackendError.kt index eea927d9..77941606 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/error/domain/BackendError.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/domain/BackendError.kt @@ -1,9 +1,9 @@ -package kmp.shared.base.error.domain +package kmp.shared.base.domain.error.domain import dev.icerock.moko.resources.desc.StringDesc import dev.icerock.moko.resources.desc.desc -import kmp.shared.base.ErrorResult import kmp.shared.base.MR +import kmp.shared.base.domain.model.ErrorResult /** * Error type used when handling responses from backend diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/error/domain/CommonError.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/domain/CommonError.kt similarity index 75% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/error/domain/CommonError.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/domain/CommonError.kt index 538f5b6a..eb59cee5 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/error/domain/CommonError.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/domain/CommonError.kt @@ -1,9 +1,9 @@ -package kmp.shared.base.error.domain +package kmp.shared.base.domain.error.domain import dev.icerock.moko.resources.desc.StringDesc import dev.icerock.moko.resources.desc.desc -import kmp.shared.base.ErrorResult import kmp.shared.base.MR +import kmp.shared.base.domain.model.ErrorResult /** * Error type used anywhere in the project. Contains subclasses for common exceptions that can happen anywhere @@ -13,6 +13,10 @@ sealed class CommonError(localizedMessage: StringDesc, throwable: Throwable? = n localizedMessage = localizedMessage, throwable = throwable, ) { - class NoNetworkConnection(throwable: Throwable?) : CommonError(localizedMessage = MR.strings.error_no_internet_connection.desc(), throwable = throwable) + class NoNetworkConnection(throwable: Throwable?) : CommonError( + localizedMessage = MR.strings.error_no_internet_connection.desc(), + throwable = throwable, + ) + data object Unknown : CommonError(localizedMessage = MR.strings.unknown_error.desc()) } diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/error/util/Network.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/util/Network.kt similarity index 79% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/error/util/Network.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/util/Network.kt index a0fe8f7a..480f148f 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/error/util/Network.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/error/util/Network.kt @@ -1,10 +1,10 @@ -package kmp.shared.base.error.util +package kmp.shared.base.domain.error.util import io.ktor.client.plugins.ClientRequestException import io.ktor.http.HttpStatusCode -import kmp.shared.base.Result -import kmp.shared.base.error.domain.BackendError -import kmp.shared.base.error.domain.CommonError +import kmp.shared.base.domain.error.domain.BackendError +import kmp.shared.base.domain.error.domain.CommonError +import kmp.shared.base.domain.model.Result inline fun runCatchingCommonNetworkExceptions(block: () -> R): Result = try { diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/Result.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/model/Result.kt similarity index 90% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/Result.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/model/Result.kt index 2cac2a76..8efd14a5 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/Result.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/model/Result.kt @@ -1,4 +1,4 @@ -package kmp.shared.base +package kmp.shared.base.domain.model import dev.icerock.moko.resources.desc.StringDesc diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/system/ApiVariant.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/system/ApiVariant.kt similarity index 56% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/system/ApiVariant.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/system/ApiVariant.kt index 628b6bc2..7f07be7d 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/system/ApiVariant.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/system/ApiVariant.kt @@ -1,4 +1,4 @@ -package kmp.shared.base.system +package kmp.shared.base.domain.system enum class ApiVariant { Alpha, Production diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/system/Config.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/system/Config.kt similarity index 67% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/system/Config.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/system/Config.kt index 4d4f63db..482babbf 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/system/Config.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/system/Config.kt @@ -1,4 +1,4 @@ -package kmp.shared.base.system +package kmp.shared.base.domain.system interface Config { val isRelease: Boolean diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/usecase/UseCaseFlow.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/usecase/UseCaseFlow.kt similarity index 85% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/usecase/UseCaseFlow.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/usecase/UseCaseFlow.kt index 00d18ceb..9de3d265 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/usecase/UseCaseFlow.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/usecase/UseCaseFlow.kt @@ -1,4 +1,4 @@ -package kmp.shared.base.usecase +package kmp.shared.base.domain.usecase import kotlinx.coroutines.flow.Flow diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/usecase/UseCaseResult.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/usecase/UseCaseResult.kt similarity index 93% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/usecase/UseCaseResult.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/usecase/UseCaseResult.kt index b4c203d4..158ec2e2 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/usecase/UseCaseResult.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/usecase/UseCaseResult.kt @@ -1,7 +1,7 @@ -package kmp.shared.base.usecase +package kmp.shared.base.domain.usecase -import kmp.shared.base.ErrorResult -import kmp.shared.base.Result +import kmp.shared.base.domain.model.ErrorResult +import kmp.shared.base.domain.model.Result import kotlinx.coroutines.flow.Flow /** @@ -64,4 +64,4 @@ internal inline fun runHandlingError( result } } - } \ No newline at end of file + } diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/util/extension/Result.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/util/extension/Result.kt similarity index 95% rename from shared/base/src/commonMain/kotlin/kmp/shared/base/util/extension/Result.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/domain/util/extension/Result.kt index 4c6756f2..28d6e3d8 100644 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/util/extension/Result.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/domain/util/extension/Result.kt @@ -1,7 +1,7 @@ -package kmp.shared.base.util.extension +package kmp.shared.base.domain.util.extension -import kmp.shared.base.ErrorResult -import kmp.shared.base.Result +import kmp.shared.base.domain.model.ErrorResult +import kmp.shared.base.domain.model.Result import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/provider/AuthProvider.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/provider/AuthProvider.kt deleted file mode 100644 index 1aef64b2..00000000 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/provider/AuthProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kmp.shared.base.infrastucture.provider - -interface AuthProvider { - var token: String? -} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/provider/AuthProviderImpl.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/provider/AuthProviderImpl.kt deleted file mode 100644 index 29889db4..00000000 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/provider/AuthProviderImpl.kt +++ /dev/null @@ -1,15 +0,0 @@ -package kmp.shared.base.infrastucture.provider - -import com.russhwolf.settings.Settings -import com.russhwolf.settings.set - -internal class AuthProviderImpl(private val settings: Settings) : AuthProvider { - - override var token: String? - get() = settings.getStringOrNull(TOKEN_KEY) - set(value) = settings.set(TOKEN_KEY, value) - - private companion object { - const val TOKEN_KEY = "token" - } -} diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/remote/HttpClient.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/remote/HttpClient.kt deleted file mode 100644 index 5e5110e7..00000000 --- a/shared/base/src/commonMain/kotlin/kmp/shared/base/infrastucture/remote/HttpClient.kt +++ /dev/null @@ -1,101 +0,0 @@ -package kmp.shared.base.infrastucture.remote - -import io.ktor.client.HttpClient -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.plugins.auth.Auth -import io.ktor.client.plugins.auth.authProviders -import io.ktor.client.plugins.auth.providers.BearerAuthProvider -import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.bearer -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.plugins.logging.Logging -import io.ktor.http.ContentType -import io.ktor.http.URLProtocol -import io.ktor.http.contentType -import io.ktor.http.encodedPath -import io.ktor.serialization.kotlinx.json.json -import kmp.shared.base.infrastucture.provider.AuthProvider -import kmp.shared.base.system.ApiVariant -import kmp.shared.base.system.Config -import kotlin.native.concurrent.ThreadLocal -import co.touchlab.kermit.Logger as KermitLogger -import kotlinx.serialization.json.Json as JsonConfig - -internal object HttpClient { - private val unauthorizedEndpoints = listOf("/api/auth/login", "/api/auth/registration") - - fun init(config: Config, engine: HttpClientEngine, authProvider: AuthProvider) = HttpClient(engine).config { - expectSuccess = true - followRedirects = false - - install(ContentNegotiation) { - json(globalJson) - } - - if (!config.isRelease) { - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - KermitLogger.d { message } - } - } - level = LogLevel.ALL - } - } - - install(Auth) { - // Use if your authentication method is a bearer token (other options are `basic` and `digest`) - bearer { - loadTokens { - authProvider.token?.let { token -> - // Use your access and refresh tokens here (you can use access token for both if you don't use refresh token) - BearerTokens(token, token) - } - } - - refreshTokens { - authProvider.token?.let { token -> - // Use your access and refresh tokens here (you can use access token for both if you don't use refresh token) - BearerTokens(token, token) - } - } - - sendWithoutRequest { request -> - unauthorizedEndpoints.any(request.url.encodedPath::equals) - } - } - } - - defaultRequest { - url { - protocol = URLProtocol.HTTPS - // Set your host URLs - host = when (config.apiVariant) { - ApiVariant.Alpha -> "devstack-server-production.up.railway.app" - ApiVariant.Production -> "devstack-server-production.up.railway.app" - } - } - contentType(ContentType.Application.Json) - } - } -} - -/** - * Force the Auth plugin to invoke the `loadTokens` block again on the next client request. - */ -fun HttpClient.clearBearerTokens() { - authProviders - .filterIsInstance() - .firstOrNull() - ?.clearToken() -} - -@ThreadLocal -val globalJson = JsonConfig { - ignoreUnknownKeys = true - coerceInputValues = true - useAlternativeNames = false -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.kt similarity index 94% rename from shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.kt index 0c52a92f..6fba60cd 100644 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposemultiplatform.presentation.common +package kmp.shared.base.presentation.ui import androidx.compose.foundation.Indication import androidx.compose.foundation.clickable diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/test/TestTag.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/TestTag.kt similarity index 77% rename from shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/test/TestTag.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/TestTag.kt index 82905ee7..2a75209a 100644 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/test/TestTag.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/TestTag.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposenavigation.presentation.ui.test +package kmp.shared.base.presentation.ui import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/Theme.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Theme.kt similarity index 97% rename from shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/Theme.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Theme.kt index 043170b6..e67f32b8 100644 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/Theme.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Theme.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposemultiplatform.presentation.common +package kmp.shared.base.presentation.ui import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.isSystemInDarkTheme diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/common/Values.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Values.kt similarity index 91% rename from shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/common/Values.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Values.kt index 871a4a81..0ee8376d 100644 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/common/Values.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Values.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposenavigation.presentation.common +package kmp.shared.base.presentation.ui import androidx.compose.ui.unit.dp diff --git a/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.kt similarity index 95% rename from shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt rename to shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.kt index 8d6df5bf..96c48085 100644 --- a/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplesharedviewmodel.base.vm +package kmp.shared.base.presentation.vm import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable diff --git a/shared/base/src/commonMain/moko-resources/base/strings.xml b/shared/base/src/commonMain/moko-resources/base/strings.xml index c6e181f9..0ca2741c 100644 --- a/shared/base/src/commonMain/moko-resources/base/strings.xml +++ b/shared/base/src/commonMain/moko-resources/base/strings.xml @@ -11,14 +11,8 @@ EN English - - Classic - Shared VMs - Compose Multiplatform - Compose Navigation - - Next + Sample feature Done diff --git a/shared/base/src/commonMain/moko-resources/cs/strings.xml b/shared/base/src/commonMain/moko-resources/cs/strings.xml index 23e4679a..c0fa3e62 100644 --- a/shared/base/src/commonMain/moko-resources/cs/strings.xml +++ b/shared/base/src/commonMain/moko-resources/cs/strings.xml @@ -11,14 +11,8 @@ EN Angličtina - - Classic - Shared VMs - Compose Multiplatform - Compose Navigation - - Next + Sample feature Hotovo diff --git a/shared/base/src/commonMain/moko-resources/sk/strings.xml b/shared/base/src/commonMain/moko-resources/sk/strings.xml index 9f4d29bd..93f187d3 100644 --- a/shared/base/src/commonMain/moko-resources/sk/strings.xml +++ b/shared/base/src/commonMain/moko-resources/sk/strings.xml @@ -11,14 +11,8 @@ EN Angličtina - - Classic - Shared VMs - Compose Multiplatform - Compose Navigation - - Next + Sample feature Hotovo diff --git a/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/Book.sq b/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/Book.sq deleted file mode 100644 index c2394fdb..00000000 --- a/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/Book.sq +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE BookEntity ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - author TEXT, - pageCount INTEGER -); - -getBook: -SELECT * FROM BookEntity WHERE id = ?; - -getAllBooks: -SELECT * FROM BookEntity; - -insertOrReplace: -REPLACE INTO BookEntity VALUES ?; - -delete: -DELETE FROM BookEntity WHERE id = ?; - -deleteAllBooks: -DELETE FROM BookEntity; - diff --git a/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/User.sq b/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/User.sq deleted file mode 100644 index 79063603..00000000 --- a/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/User.sq +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE UserEntity ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - firstName TEXT, - lastName TEXT, - phone TEXT, - bio TEXT -); - -getUser: -SELECT * FROM UserEntity WHERE id = ?; - -getAllUsers: -SELECT * FROM UserEntity; - -insertOrReplace: -REPLACE INTO UserEntity VALUES ?; - -deleteUser: -DELETE FROM UserEntity WHERE id = ?; - -deleteAllUsers: -DELETE FROM UserEntity; - diff --git a/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/UserCache.sq b/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/UserCache.sq deleted file mode 100644 index 8aa73b07..00000000 --- a/shared/base/src/commonMain/sqldelight/kmp/shared/core/infrastructure/local/UserCache.sq +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE UserCache ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - firstName TEXT, - lastName TEXT -); - -getUsersPaginated: -SELECT * FROM UserCache -ORDER BY id -LIMIT :limit OFFSET :offset; - -getCache: -SELECT * FROM UserCache -ORDER BY id; - -getUserCount: -SELECT COUNT(*) FROM UserCache; - -insertOrReplace: -REPLACE INTO UserCache VALUES ?; - -delete: -DELETE FROM UserCache WHERE id = ?; - -deleteCache: -DELETE FROM UserCache; - diff --git a/shared/base/src/iosMain/kotlin/kmp/shared/base/data/keychain/KeychainAccessibleAfterFirstUnlockSettings.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/data/keychain/KeychainAccessibleAfterFirstUnlockSettings.kt new file mode 100644 index 00000000..3666f5f7 --- /dev/null +++ b/shared/base/src/iosMain/kotlin/kmp/shared/base/data/keychain/KeychainAccessibleAfterFirstUnlockSettings.kt @@ -0,0 +1,350 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package kmp.shared.base.data.keychain + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.Settings +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.MemScope +import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.value +import platform.CoreFoundation.CFArrayGetCount +import platform.CoreFoundation.CFArrayGetValueAtIndex +import platform.CoreFoundation.CFArrayRefVar +import platform.CoreFoundation.CFDictionaryCreate +import platform.CoreFoundation.CFDictionaryGetValue +import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFStringRef +import platform.CoreFoundation.CFTypeRef +import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFAllocatorDefault +import platform.CoreFoundation.kCFBooleanFalse +import platform.CoreFoundation.kCFBooleanTrue +import platform.Foundation.CFBridgingRelease +import platform.Foundation.CFBridgingRetain +import platform.Foundation.NSData +import platform.Foundation.NSKeyedArchiver +import platform.Foundation.NSKeyedUnarchiver +import platform.Foundation.NSNumber +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.create +import platform.Foundation.dataUsingEncoding +import platform.Foundation.numberWithBool +import platform.Foundation.numberWithDouble +import platform.Foundation.numberWithFloat +import platform.Foundation.numberWithInt +import platform.Foundation.numberWithLongLong +import platform.Security.SecCopyErrorMessageString +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecItemUpdate +import platform.Security.errSecDuplicateItem +import platform.Security.errSecItemNotFound +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlock +import platform.Security.kSecAttrAccount +import platform.Security.kSecAttrService +import platform.Security.kSecClass +import platform.Security.kSecClassGenericPassword +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitAll +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecReturnAttributes +import platform.Security.kSecReturnData +import platform.Security.kSecValueData +import platform.darwin.OSStatus +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ref.Cleaner +import kotlin.native.ref.createCleaner + +/** + * A collection of storage-backed key-value data + * + * This class allows storage of values with the [Int], [Long], [String], [Float], [Double], or [Boolean] types, using a + * [String] reference as a key. Values will be persisted across app launches. + * + * The specific persistence mechanism is defined using a platform-specific implementation, so certain behavior may vary + * across platforms. In general, updates will be reflected immediately in-memory, but will be persisted to disk + * asynchronously. + * + * Operator extensions are defined in order to simplify usage. In addition, property delegates are provided for cleaner + * syntax and better type-safety when interacting with values stored in a `Settings` instance. + * + * The KeychainAccessibleAfterFirstUnlockSettings implementation saves data to the Apple keychain. Data is saved using the generic password type, + * where keys are account names and values are treated as passwords. The value passed to the `String` constructor will + * be used as the service name. It's also possible to pass custom key-value pairs as attributes that will be added to + * every key, if the default behavior does not fit your needs. + * + * Every item added will have set [kSecAttrAccessible] to [kSecAttrAccessibleAfterFirstUnlock]. + */ +@ExperimentalSettingsImplementation +class KeychainAccessibleAfterFirstUnlockSettings : Settings { + + @OptIn(ExperimentalNativeApi::class) + private val cleaner: Cleaner? + + @ExperimentalSettingsApi + constructor(vararg defaultProperties: Pair) { + this.defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, *defaultProperties) + @OptIn(ExperimentalNativeApi::class) + cleaner = null + } + + constructor(service: String) { + val cfService = CFBridgingRetain(service) + defaultProperties = + mapOf(kSecClass to kSecClassGenericPassword, kSecAttrService to cfService) + @OptIn(ExperimentalNativeApi::class) + cleaner = createCleaner(cfService) { CFBridgingRelease(it) } + } + + @OptIn(ExperimentalSettingsApi::class) // IDE is wrong when it says this is redundant + constructor() : this(*emptyArray()) + + private val defaultProperties: Map + + /** + * A factory that can produce [Settings] instances. + * + * This class creates `Settings` objects backed by the Apple keychain. + */ + class Factory() : Settings.Factory { + override fun create(name: String?): KeychainAccessibleAfterFirstUnlockSettings = + if (name != null) KeychainAccessibleAfterFirstUnlockSettings(name) else KeychainAccessibleAfterFirstUnlockSettings() + } + + override val keys: Set + get() = memScoped { + val attributes = alloc() + val status = keyChainOperation( + kSecMatchLimit to kSecMatchLimitAll, + kSecReturnAttributes to kCFBooleanTrue, + ) { SecItemCopyMatching(it, attributes.ptr.reinterpret()) } + status.checkError(errSecItemNotFound) + if (status == errSecItemNotFound) { + return emptySet() + } + + return buildSet { + for (i in 0.. + val status = keyChainOperation( + kSecAttrAccount to cfKey, + kSecValueData to cfValue, + kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlock, + ) { SecItemAdd(it, null) } + status.checkError(errSecDuplicateItem) + + status != errSecDuplicateItem + } + + private fun removeKeychainItem(key: String): Unit = cfRetain(key) { cfKey -> + val status = keyChainOperation( + kSecAttrAccount to cfKey, + ) { SecItemDelete(it) } + status.checkError(errSecItemNotFound) + } + + private fun updateKeychainItem(key: String, value: NSData?): Unit = + cfRetain(key, value) { cfKey, cfValue -> + val status = keyChainOperation( + kSecAttrAccount to cfKey, + kSecReturnData to kCFBooleanFalse, + kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlock, + ) { + val attributes = cfDictionaryOf(kSecValueData to cfValue) + val output = SecItemUpdate(it, attributes) + CFBridgingRelease(attributes) + output + } + + if (status == errSecItemNotFound) { + removeKeychainItem(key) + addKeychainItem(key, value) + } else { + status.checkError() + } + } + + private fun getKeychainItem(key: String): NSData? = cfRetain(key) { cfKey -> + val cfValue = alloc() + val status = keyChainOperation( + kSecAttrAccount to cfKey, + kSecReturnData to kCFBooleanTrue, + kSecMatchLimit to kSecMatchLimitOne, + ) { SecItemCopyMatching(it, cfValue.ptr) } + status.checkError(errSecItemNotFound) + if (status == errSecItemNotFound) { + return@cfRetain null + } + CFBridgingRelease(cfValue.value) as? NSData + } + + private fun hasKeychainItem(key: String): Boolean = cfRetain(key) { cfKey -> + val status = keyChainOperation( + kSecAttrAccount to cfKey, + kSecMatchLimit to kSecMatchLimitOne, + ) { SecItemCopyMatching(it, null) } + + status != errSecItemNotFound + } + + private inline fun MemScope.keyChainOperation( + vararg input: Pair, + operation: (query: CFDictionaryRef?) -> OSStatus, + ): OSStatus { + val query = cfDictionaryOf(defaultProperties + mapOf(*input)) + val output = operation(query) + CFBridgingRelease(query) + return output + } + + private fun OSStatus.checkError(vararg expectedErrors: OSStatus) { + if (this != 0 && this !in expectedErrors) { + val cfMessage = SecCopyErrorMessageString(this, null) + val nsMessage = CFBridgingRelease(cfMessage) as? NSString + val message = nsMessage?.toKString() ?: "Unknown error" + error("Keychain error $this: $message") + } + } + +} + +internal fun MemScope.cfDictionaryOf(vararg items: Pair): CFDictionaryRef? = + cfDictionaryOf(mapOf(*items)) + +internal fun MemScope.cfDictionaryOf(map: Map): CFDictionaryRef? { + val size = map.size + val keys = allocArrayOf(*map.keys.toTypedArray()) + val values = allocArrayOf(*map.values.toTypedArray()) + return CFDictionaryCreate( + kCFAllocatorDefault, + keys.reinterpret(), + values.reinterpret(), + size.convert(), + null, + null, + ) +} + +// Turn casts into dot calls for better readability +@Suppress("CAST_NEVER_SUCCEEDS") +internal fun String.toNSString() = this as NSString + +@Suppress("CAST_NEVER_SUCCEEDS") +internal fun NSString.toKString() = this as String + +internal inline fun cfRetain(value: Any?, block: MemScope.(CFTypeRef?) -> T): T = memScoped { + val cfValue = CFBridgingRetain(value) + return try { + block(cfValue) + } finally { + CFBridgingRelease(cfValue) + } +} + +internal inline fun cfRetain( + value1: Any?, + value2: Any?, + block: MemScope.(CFTypeRef?, CFTypeRef?) -> T, +): T = + memScoped { + val cfValue1 = CFBridgingRetain(value1) + val cfValue2 = CFBridgingRetain(value2) + return try { + block(cfValue1, cfValue2) + } finally { + CFBridgingRelease(cfValue1) + CFBridgingRelease(cfValue2) + } + } diff --git a/shared/base/src/iosMain/kotlin/kmp/shared/base/data/keychain/KeychainFactory.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/data/keychain/KeychainFactory.kt new file mode 100644 index 00000000..150c4bee --- /dev/null +++ b/shared/base/src/iosMain/kotlin/kmp/shared/base/data/keychain/KeychainFactory.kt @@ -0,0 +1,27 @@ +package kmp.shared.base.data.keychain + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.KeychainSettings +import com.russhwolf.settings.Settings +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.CFBridgingRetain +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleAfterFirstUnlock + +class KeychainFactory { + + @OptIn( + ExperimentalSettingsImplementation::class, + ExperimentalForeignApi::class, + ExperimentalSettingsApi::class, + ) + fun create(): Settings { + // There is a crash happening in current version that will be fixed in multiplatform-settings 1.4 + // this property should fix it, but if Keychain error still happens, please, use KeychainAccessibleAfterFirstUnlockSettings() + // https://github.com/russhwolf/multiplatform-settings/issues/171 + return KeychainSettings( + kSecAttrAccessible to CFBridgingRetain(kSecAttrAccessibleAfterFirstUnlock), + ) + } +} diff --git a/shared/base/src/iosMain/kotlin/kmp/shared/base/data/userdefaults/UserDefaultsFactory.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/data/userdefaults/UserDefaultsFactory.kt new file mode 100644 index 00000000..ca253268 --- /dev/null +++ b/shared/base/src/iosMain/kotlin/kmp/shared/base/data/userdefaults/UserDefaultsFactory.kt @@ -0,0 +1,15 @@ +package kmp.shared.base.data.userdefaults + +import com.russhwolf.settings.ExperimentalSettingsImplementation +import com.russhwolf.settings.NSUserDefaultsSettings + +class UserDefaultsFactory { + + /** + * @param name specifies the name of the NSUserDefaultsSettings, if name is `null` default NSUserDefaultsSettings are returned + */ + @OptIn(ExperimentalSettingsImplementation::class) + fun create(name: String? = null): NSUserDefaultsSettings { + return NSUserDefaultsSettings.Factory().create(name) + } +} diff --git a/shared/base/src/iosMain/kotlin/kmp/shared/base/di/BaseModule.ios.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/di/BaseModule.ios.kt new file mode 100644 index 00000000..3ec875b0 --- /dev/null +++ b/shared/base/src/iosMain/kotlin/kmp/shared/base/di/BaseModule.ios.kt @@ -0,0 +1,13 @@ +package kmp.shared.base.di + +import io.ktor.client.engine.darwin.Darwin +import kmp.shared.base.data.keychain.KeychainFactory +import kmp.shared.base.data.userdefaults.UserDefaultsFactory +import org.koin.core.module.dsl.factoryOf +import org.koin.dsl.module + +actual val basePlatformModule = module { + single { Darwin.create() } + factoryOf(::KeychainFactory) + factoryOf(::UserDefaultsFactory) +} diff --git a/shared/base/src/iosMain/kotlin/kmp/shared/base/di/IosModule.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/di/IosModule.kt deleted file mode 100644 index af98baa1..00000000 --- a/shared/base/src/iosMain/kotlin/kmp/shared/base/di/IosModule.kt +++ /dev/null @@ -1,8 +0,0 @@ -package kmp.shared.base.di - -import io.ktor.client.engine.darwin.Darwin -import org.koin.dsl.module - -actual val platformModule = module { - single { Darwin.create() } -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.ios.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.ios.kt similarity index 66% rename from shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.ios.kt rename to shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.ios.kt index c5b6b336..ef8e3402 100644 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/PlatformSpecificButtonIndication.ios.kt +++ b/shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/PlatformSpecificButtonIndication.ios.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposemultiplatform.presentation.common +package kmp.shared.base.presentation.ui import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState @@ -31,19 +31,6 @@ actual fun getPlatformSpecificRippleConfigurationProvidedValue(rippleColor: Colo return LocalRippleConfiguration provides null } -// Use with compose version 1.6.11 -// @Composable -// actual fun getPlatformSpecificRippleConfigurationProvidedValue(rippleColor: Color): ProvidedValue<*> { -// val rippleTheme = object : RippleTheme { -// @Composable -// override fun defaultColor(): Color = Color.Transparent -// -// @Composable -// override fun rippleAlpha(): RippleAlpha = RippleAlpha(0f, 0f, 0f, 0f) -// } -// return LocalRippleTheme provides rippleTheme -// } - actual val platformSpecificClickIndication: Indication get() = OpacityIndicationNodeFactory @@ -97,32 +84,6 @@ private class OpacityIndicationNode( } } -// Use with compose version 1.6.11 -// actual val platformSpecificClickIndication: Indication -// get() = object : Indication { -// @Composable -// override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance { -// val isPressed by interactionSource.collectIsPressedAsState() -// return object : IndicationInstance { -// override fun ContentDrawScope.drawIndication() { -// with(drawContext.canvas.nativeCanvas) { -// val checkPoint = saveLayer(null, null) -// drawContent() -// -// if (isPressed) { -// drawRect( -// color = Color.White.copy(alpha = 0.75f), -// blendMode = BlendMode.DstIn, -// ) -// } -// -// restoreToCount(checkPoint) -// } -// } -// } -// } -// } - @Composable actual fun Modifier.platformSpecificClickEffect(interactionSource: InteractionSource): Modifier { val isPressed by interactionSource.collectIsPressedAsState() diff --git a/shared/samplesharedviewmodel/src/iosMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt b/shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.ios.kt similarity index 97% rename from shared/samplesharedviewmodel/src/iosMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt rename to shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.ios.kt index 09fe7874..d071008e 100644 --- a/shared/samplesharedviewmodel/src/iosMain/kotlin/kmp/shared/samplesharedviewmodel/base/vm/BaseScopedViewModel.kt +++ b/shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.ios.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplesharedviewmodel.base.vm +package kmp.shared.base.presentation.vm import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -56,4 +56,4 @@ actual interface BaseIntentViewModel { actual fun onViewAppeared() fun clearScope() -} \ No newline at end of file +} diff --git a/shared/core/build.gradle.kts b/shared/core/build.gradle.kts deleted file mode 100644 index 70cf1c74..00000000 --- a/shared/core/build.gradle.kts +++ /dev/null @@ -1,42 +0,0 @@ -import co.touchlab.skie.configuration.DefaultArgumentInterop - -plugins { - alias(libs.plugins.mateeStarter.kmm.xcframework.library) - alias(libs.plugins.skie) -} - -skie { - swiftBundling { - enabled = true - } - - features { - group { - DefaultArgumentInterop.Enabled(false) - } - } -} - -android { - namespace = "kmp.shared.core" -} - -multiplatformResources { - resourcesPackage.set("kmp.shared.core") -} - -ktlint { - filter { - exclude { entry -> - entry.file.toString().contains("generated") - } - } -} - -dependencies { - commonMainApi(project(":shared:base")) - commonMainApi(project(":shared:sample")) - commonMainApi(project(":shared:samplesharedviewmodel")) - commonMainApi(project(":shared:samplecomposemultiplatform")) - commonMainApi(project(":shared:samplecomposenavigation")) -} diff --git a/shared/core/src/iosMain/kotlin/kmp/shared/core/utils/FlowTestHelper.kt b/shared/core/src/iosMain/kotlin/kmp/shared/core/utils/FlowTestHelper.kt deleted file mode 100644 index 3398564e..00000000 --- a/shared/core/src/iosMain/kotlin/kmp/shared/core/utils/FlowTestHelper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package kmp.shared.core.utils - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlin.experimental.ExperimentalObjCName - -/** - * This object is needed because of Tests on iOS platform. - */ -object FlowTestHelper { - @OptIn(ExperimentalObjCName::class) - fun arrayToFlow(@ObjCName(swiftName = "_") array: List): Flow = flow { - array.forEach { - emit(it) - } - } -} diff --git a/shared/core/src/iosMain/swift/mocks/TestError.swift b/shared/core/src/iosMain/swift/mocks/TestError.swift deleted file mode 100644 index 407faf2e..00000000 --- a/shared/core/src/iosMain/swift/mocks/TestError.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// Created by David Kadlček on 05.01.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Foundation - -public final class TestError: ErrorResult {} diff --git a/shared/core/src/iosMain/swift/mocks/UseCaseFlowNoParamsMock.swift b/shared/core/src/iosMain/swift/mocks/UseCaseFlowNoParamsMock.swift deleted file mode 100644 index 6b70993f..00000000 --- a/shared/core/src/iosMain/swift/mocks/UseCaseFlowNoParamsMock.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// Created by David Kadlček on 27.02.2023 -// Copyright © 2023 Matee. All rights reserved. -// - -import Foundation - -open class UseCaseFlowNoParamsMock: UseCaseFlowNoParams { - - public var executeCallsCount = 0 - public var executeCalled: Bool { - return executeCallsCount > 0 - } - public var executeReturnValue: Out? - - public init() {} - - public init(executeReturnValue: Out?) { - self.executeReturnValue = executeReturnValue - } - - // MARK: - execute - public func __invoke() async throws -> SkieSwiftFlow { - executeCallsCount += 1 - - guard let executeReturnValue else { - return FlowTestHelper.shared.arrayToFlow([]) - } - - guard let executeReturnValue = executeReturnValue as? Array else { - return FlowTestHelper.shared.arrayToFlow([executeReturnValue] as! [Out]) // swiftlint:disable:this force_cast - - } - - return FlowTestHelper.shared.arrayToFlow(executeReturnValue) - } -} diff --git a/shared/core/src/iosMain/swift/mocks/UseCaseResultMock.swift b/shared/core/src/iosMain/swift/mocks/UseCaseResultMock.swift deleted file mode 100644 index f27876ff..00000000 --- a/shared/core/src/iosMain/swift/mocks/UseCaseResultMock.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Created by David Kadlček on 15.11.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -open class UseCaseResultMock: UseCaseResult { - - public var executeThrowableError: Swift.Error? - public var executeCallsCount = 0 - public var executeCalled: Bool { - return executeCallsCount > 0 - } - public var executeReturnValue: Result! // swiftlint:disable:this implicitly_unwrapped_optional - - public var receivedParams: Any? - - public var executeClosure: (() throws -> Result)? - - public init() {} - - public init(executeReturnValue: Result) { - self.executeReturnValue = executeReturnValue - } - - // MARK: - execute - public func __invoke(params: Any?) async throws -> Result { - executeCallsCount += 1 - receivedParams = params - - if let error = executeThrowableError { - throw error - } - - guard let executeClosure else { - return executeReturnValue - } - - return try executeClosure() - } -} diff --git a/shared/core/src/iosMain/swift/mocks/UseCaseResultNoParamsMock.swift b/shared/core/src/iosMain/swift/mocks/UseCaseResultNoParamsMock.swift deleted file mode 100644 index 4e82962e..00000000 --- a/shared/core/src/iosMain/swift/mocks/UseCaseResultNoParamsMock.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Created by David Kadlček on 15.11.2022 -// Copyright © 2022 Matee. All rights reserved. -// - -import Foundation - -open class UseCaseResultNoParamsMock: UseCaseResultNoParams { - - public var executeThrowableError: Swift.Error? - public var executeCallsCount = 0 - public var executeCalled: Bool { - return executeCallsCount > 0 - } - public var executeReturnValue: Result! // swiftlint:disable:this implicitly_unwrapped_optional - - public var executeClosure: (() throws -> Result)? - - public init() {} - - public init(executeReturnValue: Result) { - self.executeReturnValue = executeReturnValue - } - - // MARK: - execute - - public func __invoke() async throws -> Result { - executeCallsCount += 1 - - if let error = executeThrowableError { - throw error - } - - guard let executeClosure else { - return executeReturnValue - } - - return try executeClosure() - } -} diff --git a/shared/sample/build.gradle.kts b/shared/sample/build.gradle.kts deleted file mode 100644 index 5f3c3520..00000000 --- a/shared/sample/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - alias(libs.plugins.mateeStarter.kmm.library) -} - -android { - namespace = "kmp.shared.sample" -} - -ktlint { - filter { - exclude { entry -> - entry.file.toString().contains("generated") - } - } -} - -dependencies { - commonMainImplementation(project(":shared:base")) -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/data/repository/SampleRepositoryImpl.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/data/repository/SampleRepositoryImpl.kt deleted file mode 100644 index 37dd4e4f..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/data/repository/SampleRepositoryImpl.kt +++ /dev/null @@ -1,14 +0,0 @@ -package kmp.shared.sample.data.repository - -import kmp.shared.base.Result -import kmp.shared.sample.data.source.SampleSource -import kmp.shared.sample.domain.model.SampleText -import kmp.shared.sample.domain.repository.SampleRepository - -internal class SampleRepositoryImpl( - private val source: SampleSource, -) : SampleRepository { - - override suspend fun getSampleText(): Result = - source.getSampleText() -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/data/source/SampleSource.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/data/source/SampleSource.kt deleted file mode 100644 index afbde86c..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/data/source/SampleSource.kt +++ /dev/null @@ -1,8 +0,0 @@ -package kmp.shared.sample.data.source - -import kmp.shared.base.Result -import kmp.shared.sample.domain.model.SampleText - -internal interface SampleSource { - suspend fun getSampleText(): Result -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/di/Module.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/di/Module.kt deleted file mode 100644 index 89cecd1f..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/di/Module.kt +++ /dev/null @@ -1,27 +0,0 @@ -package kmp.shared.sample.di - -import kmp.shared.sample.data.repository.SampleRepositoryImpl -import kmp.shared.sample.data.source.SampleSource -import kmp.shared.sample.domain.repository.SampleRepository -import kmp.shared.sample.domain.usecase.GetSampleTextUseCase -import kmp.shared.sample.domain.usecase.GetSampleTextUseCaseImpl -import kmp.shared.sample.infrastructure.remote.SampleService -import kmp.shared.sample.infrastructure.source.SampleSourceImpl -import org.koin.core.module.dsl.factoryOf -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.bind -import org.koin.dsl.module - -val sampleModule = module { - // Use cases - factoryOf(::GetSampleTextUseCaseImpl) bind GetSampleTextUseCase::class - - // Repositories - singleOf(::SampleRepositoryImpl) bind SampleRepository::class - - // Sources - singleOf(::SampleSourceImpl) bind SampleSource::class - - // Remote services - singleOf(::SampleService) -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/model/SampleText.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/model/SampleText.kt deleted file mode 100644 index 300e0f8a..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/model/SampleText.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kmp.shared.sample.domain.model - -data class SampleText( - val value: String, -) diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/repository/SampleRepository.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/repository/SampleRepository.kt deleted file mode 100644 index 7af7bb49..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/repository/SampleRepository.kt +++ /dev/null @@ -1,8 +0,0 @@ -package kmp.shared.sample.domain.repository - -import kmp.shared.base.Result -import kmp.shared.sample.domain.model.SampleText - -internal interface SampleRepository { - suspend fun getSampleText(): Result -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/usecase/GetSampleTextUseCase.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/usecase/GetSampleTextUseCase.kt deleted file mode 100644 index 3c20e5a6..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/domain/usecase/GetSampleTextUseCase.kt +++ /dev/null @@ -1,16 +0,0 @@ -package kmp.shared.sample.domain.usecase - -import kmp.shared.base.Result -import kmp.shared.base.usecase.UseCaseResultNoParams -import kmp.shared.sample.domain.model.SampleText -import kmp.shared.sample.domain.repository.SampleRepository - -interface GetSampleTextUseCase : UseCaseResultNoParams - -internal class GetSampleTextUseCaseImpl( - private val repository: SampleRepository, -) : GetSampleTextUseCase { - - override suspend fun invoke(): Result = - repository.getSampleText() -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/model/SampleTextDto.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/model/SampleTextDto.kt deleted file mode 100644 index c3448864..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/model/SampleTextDto.kt +++ /dev/null @@ -1,14 +0,0 @@ -package kmp.shared.sample.infrastructure.model - -import kmp.shared.sample.domain.model.SampleText -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class SampleTextDto( - @SerialName("value") - val value: String, -) - -internal fun SampleTextDto.toDomain(): SampleText = - SampleText(value = value) diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/remote/SampleService.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/remote/SampleService.kt deleted file mode 100644 index f5f1518d..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/remote/SampleService.kt +++ /dev/null @@ -1,22 +0,0 @@ -package kmp.shared.sample.infrastructure.remote - -import io.ktor.client.HttpClient -import kmp.shared.base.Result -import kmp.shared.sample.infrastructure.model.SampleTextDto - -internal class SampleService(private val client: HttpClient) { - - suspend fun getSampleText(body: Any): Result = - Result.Success(SampleTextDto(value = "This is sample text")) - // TODO: Use real implementation below -// runCatchingCommonNetworkExceptions { -// client.post(SAMPLE_PATH) { -// setBody(body) -// }.body() -// } - - private companion object { - const val ROOT_PATH = "/api" - const val SAMPLE_PATH = "$ROOT_PATH/sample" - } -} diff --git a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/source/SampleSourceImpl.kt b/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/source/SampleSourceImpl.kt deleted file mode 100644 index 4037fda7..00000000 --- a/shared/sample/src/commonMain/kotlin/kmp/shared/sample/infrastructure/source/SampleSourceImpl.kt +++ /dev/null @@ -1,17 +0,0 @@ -package kmp.shared.sample.infrastructure.source - -import kmp.shared.base.Result -import kmp.shared.base.util.extension.map -import kmp.shared.sample.data.source.SampleSource -import kmp.shared.sample.domain.model.SampleText -import kmp.shared.sample.infrastructure.model.SampleTextDto -import kmp.shared.sample.infrastructure.model.toDomain -import kmp.shared.sample.infrastructure.remote.SampleService - -internal class SampleSourceImpl( - private val service: SampleService, -) : SampleSource { - - override suspend fun getSampleText(): Result = - service.getSampleText(Unit).map(SampleTextDto::toDomain) -} diff --git a/shared/samplecomposemultiplatform/build.gradle.kts b/shared/samplecomposemultiplatform/build.gradle.kts deleted file mode 100644 index 52abcff1..00000000 --- a/shared/samplecomposemultiplatform/build.gradle.kts +++ /dev/null @@ -1,31 +0,0 @@ -plugins { - alias(libs.plugins.mateeStarter.kmm.library) - alias(libs.plugins.jetbrains.compose) - alias(libs.plugins.jetbrains.compose.compiler) -} - -android { - namespace = "kmp.shared.samplecomposemultiplatform" -} - -ktlint { - filter { - exclude { entry -> - entry.file.toString().contains("generated") - } - } -} - -dependencies { - commonMainImplementation(project(":shared:base")) - commonMainImplementation(project(":shared:sample")) - commonMainImplementation(project(":shared:samplesharedviewmodel")) - - commonMainImplementation(compose.runtime) - commonMainImplementation(compose.foundation) - commonMainImplementation(compose.material) - @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) - commonMainImplementation(compose.components.resources) - commonMainImplementation(compose.components.uiToolingPreview) - ktlintRuleset(libs.ktlint.composeRules) -} diff --git a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt b/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt deleted file mode 100644 index cb139442..00000000 --- a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt +++ /dev/null @@ -1,4 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -// Originally generated by Touchlab's [compose-swift-bridge] -actual interface ComposeSampleComposeMultiplatformViewFactory diff --git a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt b/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt deleted file mode 100644 index 03482193..00000000 --- a/shared/samplecomposemultiplatform/src/androidMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt +++ /dev/null @@ -1,34 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.material.Checkbox -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Suppress("konsist.every internal or public compose function has a modifier") -@Composable -actual fun PlatformSpecificCheckboxView( - text: String, - checked: Boolean, - onCheckedChanged: (Boolean) -> Unit, - modifier: Modifier, -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChanged, - ) - - Text( - text = text, - style = MaterialTheme.typography.body1, - ) - } -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/di/Module.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/di/Module.kt deleted file mode 100644 index 5f46831e..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/di/Module.kt +++ /dev/null @@ -1,10 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.di - -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextViewModel -import org.koin.core.module.dsl.viewModelOf -import org.koin.dsl.module - -val sampleComposeMultiplatformModule = module { - // View models - viewModelOf(::SampleNextViewModel) -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/StarterButton.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/StarterButton.kt deleted file mode 100644 index aa67c315..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/StarterButton.kt +++ /dev/null @@ -1,42 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.common - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.Button -import androidx.compose.material.ButtonColors -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.ButtonElevation -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape - -@Composable -fun StarterButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - elevation: ButtonElevation? = ButtonDefaults.elevation(), - shape: Shape = MaterialTheme.shapes.small, - border: BorderStroke? = null, - colors: ButtonColors = ButtonDefaults.buttonColors(), - contentPadding: PaddingValues = ButtonDefaults.ContentPadding, - content: @Composable RowScope.() -> Unit, -) { - Button( - onClick = onClick, - modifier = modifier.platformSpecificClickEffect(interactionSource), - enabled = enabled, - interactionSource = interactionSource, - elevation = elevation, - shape = shape, - border = border, - colors = colors, - contentPadding = contentPadding, - content = content, - ) -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/Values.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/Values.kt deleted file mode 100644 index 8a7e26b0..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/common/Values.kt +++ /dev/null @@ -1,39 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.common - -import androidx.compose.ui.unit.dp - -object Values { - - object Space { - val xxsmall = 2.dp - val xsmall = 4.dp - val small = 8.dp - val mediumSmall = 12.dp - val medium = 16.dp - val mediumLarge = 20.dp - val large = 24.dp - val xlarge = 32.dp - val xxlarge = 44.dp - val xxxlarge = 64.dp - } - - object Elevation { - val small = 1.dp - val normal = 3.dp - val big = 6.dp - val huge = 8.dp - } - - object Border { - val thin = 1.dp - val medium = 2.dp - val mediumLarge = 3.dp - val thick = 4.dp - } - - object Radius { - val small = 3.dp - val medium = 6.dp - val large = 9.dp - } -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt deleted file mode 100644 index f8cca240..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt +++ /dev/null @@ -1,7 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -// Originally generated by Touchlab's [compose-swift-bridge] -typealias SampleComposeMultiplatformViewFactory = - ComposeSampleComposeMultiplatformViewFactory - -expect interface ComposeSampleComposeMultiplatformViewFactory diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/LocalSampleComposeMultiplatformViewFactory.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/LocalSampleComposeMultiplatformViewFactory.kt deleted file mode 100644 index 203841cf..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/LocalSampleComposeMultiplatformViewFactory.kt +++ /dev/null @@ -1,14 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.compositionLocalOf - -// Originally generated by Touchlab's [compose-swift-bridge] -val LocalSampleComposeMultiplatformViewFactory: ProvidableCompositionLocal = - compositionLocalOf( - defaultFactory = { - error( - """You have to provide LocalSampleComposeMultiplatformViewFactory""", - ) - }, - ) diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt deleted file mode 100644 index 2c8fc64c..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt +++ /dev/null @@ -1,12 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -@Composable -expect fun PlatformSpecificCheckboxView( - text: String, - checked: Boolean, - onCheckedChanged: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt deleted file mode 100644 index 279572b8..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleComposeMultiplatformScreen.kt +++ /dev/null @@ -1,83 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import androidx.compose.animation.AnimatedContent -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.padding -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import kmp.shared.samplecomposemultiplatform.presentation.common.AppTheme -import kmp.shared.samplecomposemultiplatform.presentation.common.StarterButton -import kmp.shared.samplecomposemultiplatform.presentation.ui.test.TestTags -import kmp.shared.samplecomposemultiplatform.presentation.ui.test.testTag -import kmp.shared.samplesharedviewmodel.vm.SampleSharedIntent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedState -import org.jetbrains.compose.ui.tooling.preview.Preview - -@Composable -fun SampleComposeMultiplatformScreen( - state: SampleSharedState, - onIntent: (SampleSharedIntent) -> Unit, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AnimatedContent(targetState = state.loading, label = "AnimatedLoading") { loading -> - if (loading) { - CircularProgressIndicator() - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp), - ) { - Text( - text = "This is a sample with compose multiplatform UI and shared VM", - textAlign = TextAlign.Center, - ) - - Text( - text = state.sampleText?.value ?: "", - modifier = Modifier.testTag(TestTags.SampleComposeMultiplatformScreen.SampleText), - ) - - var isChecked by remember { mutableStateOf(false) } - PlatformSpecificCheckboxView( - text = "This is a view implemented in Compose on Android and SwiftUI on iOS", - checked = isChecked, - onCheckedChanged = { isChecked = it }, - modifier = Modifier.fillMaxWidth().height(60.dp), - ) - - StarterButton(onClick = { onIntent(SampleSharedIntent.OnNextButtonTapped) }) { - Text(text = "Go to next screen") - } - } - } - } - } -} - -// Previews do not work for Fleet version 1.38.89 https://slack-chats.kotlinlang.org/t/22778734/are-there-specific-kotlin-ksp-version-requirements-for-getti -@Preview -@Composable -private fun SampleComposeMultiplatformScreen_Preview() { - AppTheme { - SampleComposeMultiplatformScreen( - state = SampleSharedState(), - onIntent = {}, - ) - } -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/test/TestTag.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/test/TestTag.kt deleted file mode 100644 index 7a9ca071..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/test/TestTag.kt +++ /dev/null @@ -1,10 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui.test - -import androidx.compose.runtime.Stable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag - -open class TestTag(val tag: String) - -@Stable -fun Modifier.testTag(tag: TestTag) = testTag(tag.tag) diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/test/TestTags.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/test/TestTags.kt deleted file mode 100644 index b7c35aae..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/test/TestTags.kt +++ /dev/null @@ -1,16 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui.test - -object TestTags { - - object SampleScreen { - object SampleText : TestTag("sample_sample_text") - } - - object SampleSharedViewModelScreen { - object SampleText : TestTag("shared_view_model_sample_text") - } - - object SampleComposeMultiplatformScreen { - object SampleText : TestTag("compose_multiplatform_sample_text") - } -} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/vm/SampleNextViewModel.kt b/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/vm/SampleNextViewModel.kt deleted file mode 100644 index 35b7fc7b..00000000 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/vm/SampleNextViewModel.kt +++ /dev/null @@ -1,85 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.vm - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot -import androidx.lifecycle.viewModelScope -import kmp.shared.base.ErrorResult -import kmp.shared.base.Result -import kmp.shared.sample.domain.model.SampleText -import kmp.shared.sample.domain.usecase.GetSampleTextUseCase -import kmp.shared.samplesharedviewmodel.base.vm.BaseScopedViewModel -import kmp.shared.samplesharedviewmodel.base.vm.VmEvent -import kmp.shared.samplesharedviewmodel.base.vm.VmIntent -import kmp.shared.samplesharedviewmodel.base.vm.VmState -import kotlinx.coroutines.launch - -class SampleNextViewModel( - private val getSampleText: GetSampleTextUseCase, -) : BaseScopedViewModel() { - - private var loading by mutableStateOf(false) - private var sampleText by mutableStateOf(null) - private var error by mutableStateOf(null) - - @Composable - override fun getState(): SampleNextState { - return SampleNextState( - loading = loading, - sampleText = sampleText, - error = error, - ) - } - - override fun onIntent(intent: SampleNextIntent) { - when (intent) { - SampleNextIntent.OnButtonTapped -> viewModelScope.launch { - _events.emit(SampleNextEvent.ShowMessage("Button was tapped")) - } - - SampleNextIntent.OnBackTapped -> viewModelScope.launch { - _events.emit(SampleNextEvent.NavigateBack) - } - } - } - - override fun onViewAppeared() { - viewModelScope.launch { - loadSampleText() - } - } - - private suspend fun loadSampleText() { - loading = true - when (val result = getSampleText()) { - is Result.Success -> Snapshot.withMutableSnapshot { - sampleText = result.data - loading = false - } - is Result.Error -> Snapshot.withMutableSnapshot { - error = result.error - loading = false - } - } - } -} - -data class SampleNextState( - val loading: Boolean = false, - val sampleText: SampleText? = null, - val error: ErrorResult? = null, -) : VmState { - constructor() : this(true, null, null) -} - -sealed class SampleNextIntent : VmIntent { - data object OnButtonTapped : SampleNextIntent() - data object OnBackTapped : SampleNextIntent() -} - -sealed class SampleNextEvent : VmEvent { - data class ShowMessage(val message: String) : SampleNextEvent() - data object NavigateBack : SampleNextEvent() -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt deleted file mode 100644 index e8b28f35..00000000 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleComposeMultiplatformScreenViewController.kt +++ /dev/null @@ -1,63 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.window.ComposeUIViewController -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kmp.shared.samplecomposemultiplatform.presentation.common.AppTheme -import kmp.shared.samplecomposemultiplatform.presentation.ui.LocalSampleComposeMultiplatformViewFactory -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleComposeMultiplatformScreen -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleComposeMultiplatformViewFactory -import kmp.shared.samplesharedviewmodel.vm.SampleSharedEvent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedIntent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedViewModel -import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.viewmodel.koinViewModel -import platform.UIKit.UIViewController - -@Suppress("Unused", "FunctionName") -fun SampleComposeMultiplatformScreenViewController( - onEvent: (SampleSharedEvent) -> Unit, - factory: SampleComposeMultiplatformViewFactory, -): UIViewController { - return ComposeUIViewController { - SampleComposeMultiplatformView( - onEvent = onEvent, - factory = factory, - ) - } -} - -@Composable -internal fun SampleComposeMultiplatformView( - onEvent: (SampleSharedEvent) -> Unit, - factory: SampleComposeMultiplatformViewFactory, - modifier: Modifier = Modifier, -) { - val viewModel: SampleSharedViewModel = koinViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(viewModel) { - viewModel.onViewAppeared() - } - - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { event -> - onEvent(event) - } - } - CompositionLocalProvider( - LocalSampleComposeMultiplatformViewFactory provides factory, - ) { - AppTheme { - SampleComposeMultiplatformScreen( - state = state, - onIntent = viewModel::onIntent, - modifier = modifier, - ) - } - } -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleNextScreenViewController.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleNextScreenViewController.kt deleted file mode 100644 index 934c7a71..00000000 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/SampleNextScreenViewController.kt +++ /dev/null @@ -1,52 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.window.ComposeUIViewController -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kmp.shared.samplecomposemultiplatform.presentation.common.AppTheme -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleNextScreen -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextEvent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextIntent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextViewModel -import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.viewmodel.koinViewModel -import platform.UIKit.UIViewController - -@Suppress("Unused", "FunctionName") -fun SampleNextScreenViewController( - onEvent: (SampleNextEvent) -> Unit, -): UIViewController { - return ComposeUIViewController { - SampleNextView(onEvent = onEvent) - } -} - -@Composable -internal fun SampleNextView( - onEvent: (SampleNextEvent) -> Unit, - modifier: Modifier = Modifier, -) { - val viewModel: SampleNextViewModel = koinViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(viewModel) { - viewModel.onViewAppeared() - } - - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { event -> - onEvent(event) - } - } - - AppTheme { - SampleNextScreen( - state = state, - onIntent = viewModel::onIntent, - modifier = modifier, - ) - } -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt deleted file mode 100644 index dcc30806..00000000 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/ComposeSampleComposeMultiplatformViewFactory.kt +++ /dev/null @@ -1,12 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import platform.UIKit.UIViewController - -// Originally generated by Touchlab's [compose-swift-bridge] -actual interface ComposeSampleComposeMultiplatformViewFactory { - fun createPlatformSpecificCheckboxView( - text: String, - checked: Boolean, - onCheckedChanged: (Boolean) -> Unit, - ): Pair -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/NativeViewHolderViewModel.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/NativeViewHolderViewModel.kt deleted file mode 100644 index 2134d07f..00000000 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/NativeViewHolderViewModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import androidx.lifecycle.ViewModel - -// From Touchlab's [compose-swift-bridge] -@Suppress("konsist.assertIsDefinedInKoinModule") -class NativeViewHolderViewModel( - val factory: () -> Pair, -) : ViewModel() { - private val keep by lazy { factory() } - - val view by lazy { keep.first } - val delegate by lazy { keep.second } -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt deleted file mode 100644 index 670b5f0d..00000000 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxView.kt +++ /dev/null @@ -1,51 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.UIKitInteropProperties -import androidx.compose.ui.viewinterop.UIKitViewController -import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.cinterop.ExperimentalForeignApi -import kotlin.random.Random - -// Originally generated by Touchlab's [compose-swift-bridge] -@Suppress("konsist.every internal or public compose function has a modifier") -@OptIn(ExperimentalForeignApi::class) -@Composable -actual fun PlatformSpecificCheckboxView( - text: String, - checked: Boolean, - onCheckedChanged: (Boolean) -> Unit, - modifier: Modifier, -) { - val factory = LocalSampleComposeMultiplatformViewFactory.current - val key = rememberSaveable { Random.nextInt().toString(16) } - - val viewModel = viewModel(key = key) { - NativeViewHolderViewModel { - factory.createPlatformSpecificCheckboxView( - text = text, - checked = checked, - onCheckedChanged = onCheckedChanged, - ) - } - } - val delegate = remember(viewModel) { viewModel.delegate } - val view = remember(viewModel) { viewModel.view } - - remember(text) { delegate.updateText(text) } - remember(checked) { delegate.updateChecked(checked) } - remember(onCheckedChanged) { delegate.updateOnCheckedChanged(onCheckedChanged) } - UIKitViewController( - factory = { view }, - modifier = modifier, - update = {}, - onRelease = {}, - properties = UIKitInteropProperties( - isInteractive = true, - isNativeAccessibilityEnabled = true, - ), - ) -} diff --git a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxViewDelegate.kt b/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxViewDelegate.kt deleted file mode 100644 index 217d53e7..00000000 --- a/shared/samplecomposemultiplatform/src/iosMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/PlatformSpecificCheckboxViewDelegate.kt +++ /dev/null @@ -1,8 +0,0 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui - -// Originally generated by Touchlab's [compose-swift-bridge] -interface PlatformSpecificCheckboxViewDelegate { - fun updateText(text: String) - fun updateChecked(checked: Boolean) - fun updateOnCheckedChanged(onCheckedChanged: (Boolean) -> Unit) -} diff --git a/shared/samplecomposenavigation/build.gradle.kts b/shared/samplecomposenavigation/build.gradle.kts deleted file mode 100644 index 15be7c08..00000000 --- a/shared/samplecomposenavigation/build.gradle.kts +++ /dev/null @@ -1,39 +0,0 @@ -plugins { - alias(libs.plugins.mateeStarter.kmm.library) - alias(libs.plugins.jetbrains.compose) - alias(libs.plugins.jetbrains.compose.compiler) -} - -android { - namespace = "kmp.shared.samplecomposenavigation" -} - -ktlint { - filter { - exclude { entry -> - entry.file.toString().contains("generated") - } - } -} - -dependencies { - commonMainImplementation(project(":shared:base")) - commonMainImplementation(project(":shared:sample")) - commonMainImplementation(project(":shared:samplesharedviewmodel")) - commonMainImplementation(project(":shared:samplecomposemultiplatform")) - - commonMainImplementation(compose.runtime) - commonMainImplementation(compose.foundation) - commonMainImplementation(compose.material) - @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) - commonMainImplementation(compose.components.resources) - commonMainImplementation(compose.components.uiToolingPreview) - - // Remove these two dependencies for the iOS swipe back navigation to work - commonMainImplementation(libs.androidX.navigation) - commonMainImplementation(libs.compose.materialIconsCore) - - commonMainImplementation(libs.mokoResources.compose) - - ktlintRuleset(libs.ktlint.composeRules) -} diff --git a/shared/samplecomposenavigation/src/androidMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/AppBarHeight.kt b/shared/samplecomposenavigation/src/androidMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/AppBarHeight.kt deleted file mode 100644 index e8c478f4..00000000 --- a/shared/samplecomposenavigation/src/androidMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/AppBarHeight.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.ui - -import androidx.compose.ui.unit.dp - -actual val AppBarHeight = 56.dp diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/di/Module.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/di/Module.kt deleted file mode 100644 index 242b1e47..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/di/Module.kt +++ /dev/null @@ -1,6 +0,0 @@ -package kmp.shared.samplecomposenavigation.di - -import org.koin.dsl.module - -val sampleComposeNavigationModule = module { -} diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/common/Theme.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/common/Theme.kt deleted file mode 100644 index 0edf311c..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/common/Theme.kt +++ /dev/null @@ -1,75 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.common - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Colors -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes -import androidx.compose.material.Typography -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -// https://coolors.co/f5ab00-b8a422-9aa133-7b9d44-d95700-e0e0e0-f0f0f0 -val lightColors = Colors( - primary = Color(0xFFF5AB00), - primaryVariant = Color(0xFFB8A422), - - secondary = Color(0xFF7B9D44), - secondaryVariant = Color(0xFF9AA133), - - background = Color(0xFFF0F0F0), - surface = Color(0xFFE0E0E0), - - error = Color(0xFFD95700), - - onPrimary = Color(0xFFFFFFFF), - onSecondary = Color(0xFF000000), - onBackground = Color(0xFF000000), - onSurface = Color(0xFF000000), - onError = Color(0xFF000000), - - isLight = true, -) - -// https://coolors.co/f5ab00-b8a422-9aa133-7b9d44-d95700-1f1f1f-141414 -val darkColors = Colors( - primary = Color(0xFFF5AB00), - primaryVariant = Color(0xFFB8A422), - - secondary = Color(0xFF7B9D44), - secondaryVariant = Color(0xFF9AA133), - - background = Color(0xFF141414), - surface = Color(0xFF1F1F1F), - - error = Color(0xFFD95700), - - onPrimary = Color(0xFFFFFFFF), - onSecondary = Color(0xFF000000), - onBackground = Color(0xFFFFFFFF), - onSurface = Color(0xFFFFFFFF), - onError = Color(0xFFFFFFFF), - - isLight = false, -) - -val typography = Typography( - // Define typohraphy -) - -val shapes = Shapes( - small = RoundedCornerShape(Values.Radius.large), - medium = RoundedCornerShape(Values.Radius.medium), - large = RoundedCornerShape(Values.Radius.small), -) - -@Composable -fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) darkColors else lightColors - MaterialTheme( - colors = colors, - typography = typography, - shapes = shapes, - content = content, - ) -} diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/Destination.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/Destination.kt deleted file mode 100644 index 385976f5..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/Destination.kt +++ /dev/null @@ -1,147 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.navigation - -import androidx.compose.runtime.Composable -import androidx.navigation.NamedNavArgument -import androidx.navigation.NavBackStackEntry -import androidx.navigation.NavDeepLink -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable - -/** - * Base class for navigation graph class. - */ -abstract class FeatureGraph(private val parent: FeatureGraph?) { - - abstract val path: String - - val rootPath: String - get() = parent?.rootPath?.let { parentPath -> - if (parentPath.endsWith('/')) { - "$parentPath$path" - } else { - "$parentPath/$path" - } - } ?: path -} - -/** - * Base class for any destination the app uses. - */ -abstract class Destination(parent: FeatureGraph?) { - /** - * The root id of the destination graph used in the route //... - * In case the parent is null, we do not need any prefix and use the destinationId by itself /... - */ - private val parentPath: String = parent?.rootPath?.let { parentRootPath -> - if (parentRootPath.endsWith('/')) { - parentRootPath - } else { - "$parentRootPath/" - } - } ?: "" - - /** - * The route of the destination, without the root path and nav arguments. - */ - protected abstract val routeDefinition: String - - open val arguments: List = emptyList() - - /** - * Route used to determine this destination from others. Do not use for navigating. - */ - val route - get() = constructRoute() - - /** - * Creates a navigate-able route with the [arguments] provided. - * @param arguments Arguments that should be used for this destination. The number and order of arguments - * must exactly match the number and order of arguments this destination requires. For a default value, use null. - * TODO: It would be nice to ensure correct arguments in correct order are user when constructing the Destination. - */ - operator fun invoke(vararg arguments: Any?) = constructRouteForNavigation(*arguments) - - /** - * Constructs a route with argument names in the following format: - * - e.g. Destination called `detail` with arguments named `id` and `name`: - * "detail?id={id}&name={name}" - * - e.g. Destination called `home` with no arguments: - * "home" - */ - private fun constructRoute() = createUri( - path = "$parentPath$routeDefinition", - argList = arguments, - key = { it.name }, - value = { "{${it.name}}" }, - ) - - /** - * Constructs a route with argument names in the following format: - * - e.g. Destination called `detail` with arguments named `id` = 5 and `name` = "test": - * "detail?id=5&name=test" - * - e.g. Destination called `home` with no arguments: - * "home" - */ - private fun constructRouteForNavigation( - vararg routeArguments: Any?, - ): String { - require(routeArguments.size == arguments.size) { - "The routeArgument count must match this destination argument count.\n" + - "If needed, pass null for default value of argument." - } - val args = routeArguments.zip(arguments).map { (routeArg, arg) -> - if (routeArg is Iterable<*>) { - routeArg.mapNotNull { iterableRouteArg -> - val value = iterableRouteArg?.toString() ?: return@mapNotNull null - Pair(arg.name, value) - } - } else { - // Provided arg or default arg - val value = (routeArg ?: arg.argument.defaultValue) - - require(value != null || arg.argument.isNullable) { - "The argument ${arg.name} is null as well as it's default value, but it was not marked as nullable." - } - - listOf(Pair(arg.name, value.toString())) - } - }.flatten() - - return createUri( - path = "$parentPath$routeDefinition", - argList = args, - key = { (name, _) -> name }, - value = { (_, value) -> value }, - ) - } - - private fun createUri( - path: String, - argList: List, - key: (T) -> String, - value: (T) -> String, - ): String { - return StringBuilder() - .apply { - append("$path/") - argList - .joinToString("&") { arg -> - append("${key(arg)}=${value(arg)}") - } - .let(::append) - }.toString() - } -} - -fun NavGraphBuilder.composableDestination( - destination: Destination, - deepLinks: List = emptyList(), - content: @Composable (NavBackStackEntry) -> Unit, -) = composable( - route = destination.route, - arguments = destination.arguments, - deepLinks = deepLinks, - content = { navBackstackEntry -> - content(navBackstackEntry) - }, -) diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/SampleComposeNavigation.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/SampleComposeNavigation.kt deleted file mode 100644 index b6f688eb..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/SampleComposeNavigation.kt +++ /dev/null @@ -1,28 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.navigation - -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.navigation -import kmp.shared.samplecomposenavigation.presentation.ui.navigateToComposeMultiplatformNext -import kmp.shared.samplecomposenavigation.presentation.ui.sampleComposeMultiplatformNextRoute -import kmp.shared.samplecomposenavigation.presentation.ui.sampleComposeNavigationMainRoute - -fun NavGraphBuilder.sampleComposeNavigationNavGraph( - navHostController: NavHostController, - onShowMessage: (String) -> Unit, -) { - navigation( - startDestination = SampleComposeNavigationGraph.Main.route, - route = SampleComposeNavigationGraph.rootPath, - ) { - sampleComposeNavigationMainRoute( - onShowMessage = onShowMessage, - navigateToNext = { navHostController.navigateToComposeMultiplatformNext() }, - ) - - sampleComposeMultiplatformNextRoute( - onShowMessage = onShowMessage, - navigateToBack = { navHostController.popBackStack() }, - ) - } -} diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/SampleComposeNavigationGraph.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/SampleComposeNavigationGraph.kt deleted file mode 100644 index e2abbb16..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/navigation/SampleComposeNavigationGraph.kt +++ /dev/null @@ -1,14 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.navigation - -object SampleComposeNavigationGraph : FeatureGraph(parent = null) { - - override val path = "sampleComposeNavigation" - - data object Main : Destination(this) { - override val routeDefinition: String = "main" - } - - data object Next : Destination(this) { - override val routeDefinition: String = "next" - } -} diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/CenterAlignedTopAppBar.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/CenterAlignedTopAppBar.kt deleted file mode 100644 index d5a6d7c6..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/CenterAlignedTopAppBar.kt +++ /dev/null @@ -1,72 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.displayCutoutPadding -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import dev.icerock.moko.resources.compose.stringResource -import kmp.shared.base.MR - -@Composable -fun CenterAlignedTopAppBar( - title: String, - modifier: Modifier = Modifier, - onBackClick: (() -> Unit)? = null, -) { - Box( - modifier = modifier - .fillMaxWidth() - .background(MaterialTheme.colors.primary) - .displayCutoutPadding() - .height(AppBarHeight), - ) { - Text( - title, - style = MaterialTheme.typography.h6, - modifier = Modifier.align(Alignment.Center), - color = MaterialTheme.colors.onPrimary, - ) - - onBackClick?.let { onBack -> - TextButton( - onClick = onBack, - modifier = Modifier - .wrapContentWidth() - .align(Alignment.CenterStart), - contentPadding = PaddingValues(0.dp), - ) { - Row( - modifier = Modifier.wrapContentWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, - contentDescription = stringResource(MR.strings.back), - tint = MaterialTheme.colors.onPrimary, - ) - Text( - text = stringResource(MR.strings.back), - color = MaterialTheme.colors.onPrimary, - ) - } - } - } - } -} - -expect val AppBarHeight: Dp diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/SampleComposeNavigationMainRoute.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/SampleComposeNavigationMainRoute.kt deleted file mode 100644 index 3ff752b0..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/SampleComposeNavigationMainRoute.kt +++ /dev/null @@ -1,67 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.ui - -import androidx.compose.material.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import dev.icerock.moko.resources.compose.stringResource -import kmp.shared.base.MR -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleComposeMultiplatformScreen -import kmp.shared.samplecomposenavigation.presentation.common.AppTheme -import kmp.shared.samplecomposenavigation.presentation.navigation.SampleComposeNavigationGraph -import kmp.shared.samplecomposenavigation.presentation.navigation.composableDestination -import kmp.shared.samplesharedviewmodel.vm.SampleSharedEvent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedIntent -import kmp.shared.samplesharedviewmodel.vm.SampleSharedViewModel -import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.viewmodel.koinViewModel - -internal fun NavGraphBuilder.sampleComposeNavigationMainRoute( - onShowMessage: (String) -> Unit, - navigateToNext: () -> Unit, -) { - composableDestination( - destination = SampleComposeNavigationGraph.Main, - ) { - SampleComposeNavigationMainRoute( - onShowMessage = onShowMessage, - navigateToNext = navigateToNext, - ) - } -} - -@Composable -internal fun SampleComposeNavigationMainRoute( - onShowMessage: (String) -> Unit, - navigateToNext: () -> Unit, -) { - val viewModel: SampleSharedViewModel = koinViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(viewModel) { - viewModel.onViewAppeared() - } - - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - SampleSharedEvent.GoToNext -> navigateToNext() - is SampleSharedEvent.ShowMessage -> onShowMessage(event.message) - } - } - } - - AppTheme { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = stringResource(MR.strings.bottom_bar_item_4), - ) - }, - ) { - SampleComposeMultiplatformScreen(state = state, onIntent = viewModel::onIntent) - } - } -} diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/SampleComposeNavigationNextRoute.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/SampleComposeNavigationNextRoute.kt deleted file mode 100644 index 4d82733c..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/SampleComposeNavigationNextRoute.kt +++ /dev/null @@ -1,73 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.ui - -import androidx.compose.material.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import dev.icerock.moko.resources.compose.stringResource -import kmp.shared.base.MR -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleNextScreen -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextEvent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextIntent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextViewModel -import kmp.shared.samplecomposenavigation.presentation.common.AppTheme -import kmp.shared.samplecomposenavigation.presentation.navigation.SampleComposeNavigationGraph -import kmp.shared.samplecomposenavigation.presentation.navigation.composableDestination -import kotlinx.coroutines.flow.collectLatest -import org.koin.compose.viewmodel.koinViewModel - -internal fun NavController.navigateToComposeMultiplatformNext() { - navigate(SampleComposeNavigationGraph.Next()) -} - -internal fun NavGraphBuilder.sampleComposeMultiplatformNextRoute( - onShowMessage: (String) -> Unit, - navigateToBack: () -> Unit, -) { - composableDestination( - destination = SampleComposeNavigationGraph.Next, - ) { - SampleComposeNavigationNextRoute( - onShowMessage = onShowMessage, - navigateToBack = navigateToBack, - ) - } -} - -@Composable -internal fun SampleComposeNavigationNextRoute( - onShowMessage: (String) -> Unit, - navigateToBack: () -> Unit, -) { - val viewModel: SampleNextViewModel = koinViewModel() - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(viewModel) { - viewModel.onViewAppeared() - } - - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - SampleNextEvent.NavigateBack -> navigateToBack() - is SampleNextEvent.ShowMessage -> onShowMessage(event.message) - } - } - } - - AppTheme { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = stringResource(MR.strings.next_screen_title), - onBackClick = { viewModel.onIntent(SampleNextIntent.OnBackTapped) }, - ) - }, - ) { - SampleNextScreen(state = state, onIntent = viewModel::onIntent) - } - } -} diff --git a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/test/TestTags.kt b/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/test/TestTags.kt deleted file mode 100644 index d223cc45..00000000 --- a/shared/samplecomposenavigation/src/commonMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/test/TestTags.kt +++ /dev/null @@ -1,16 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.ui.test - -object TestTags { - - object SampleScreen { - object SampleText : TestTag("sample_sample_text") - } - - object SampleSharedViewModelScreen { - object SampleText : TestTag("shared_view_model_sample_text") - } - - object SampleComposeMultiplatformScreen { - object SampleText : TestTag("compose_multiplatform_sample_text") - } -} diff --git a/shared/samplecomposenavigation/src/iosMain/kotlin/kmp/shared/samplecomposenavigation/presentation/SampleWithComposeNavigationViewController.kt b/shared/samplecomposenavigation/src/iosMain/kotlin/kmp/shared/samplecomposenavigation/presentation/SampleWithComposeNavigationViewController.kt deleted file mode 100644 index b5551441..00000000 --- a/shared/samplecomposenavigation/src/iosMain/kotlin/kmp/shared/samplecomposenavigation/presentation/SampleWithComposeNavigationViewController.kt +++ /dev/null @@ -1,54 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation - -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.window.ComposeUIViewController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController -import kmp.shared.samplecomposemultiplatform.presentation.ui.LocalSampleComposeMultiplatformViewFactory -import kmp.shared.samplecomposemultiplatform.presentation.ui.SampleComposeMultiplatformViewFactory -import kmp.shared.samplecomposenavigation.presentation.navigation.SampleComposeNavigationGraph -import kmp.shared.samplecomposenavigation.presentation.navigation.sampleComposeNavigationNavGraph -import platform.UIKit.UIViewController - -@Suppress("Unused", "FunctionName") -fun SampleWithComposeNavigationViewController( - showMessage: (String) -> Unit, - factory: SampleComposeMultiplatformViewFactory, -): UIViewController { - return ComposeUIViewController { - // Comment out this code if navigation is not available and uncomment the code below - CompositionLocalProvider( - LocalSampleComposeMultiplatformViewFactory provides factory, - ) { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = SampleComposeNavigationGraph.rootPath, - ) { - sampleComposeNavigationNavGraph( - navHostController = navController, - onShowMessage = showMessage, - ) - } - } - - // View to show if navigation is not available -// AppTheme { -// Scaffold( -// topBar = { -// CenterAlignedTopAppBar( -// title = "Compose Multiplatform", -// ) -// }, -// ) { -// Text( -// text = "Sorry, it seems the compose navigation is commented out. " + -// "If you want to try it out, please, uncomment code in SampleWithComposeNavigationViewController.kt, " + -// "then uncomment navigation imports in build.gradle.kts of :shared:samplecomposenavigation and change " + -// "jetbrains-composePlugin version in libs.versions.toml to 1.7.0 :)", -// modifier = Modifier.padding(Values.Space.medium), -// ) -// } -// } - } -} diff --git a/shared/samplecomposenavigation/src/iosMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/AppBarHeight.kt b/shared/samplecomposenavigation/src/iosMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/AppBarHeight.kt deleted file mode 100644 index 4434dd87..00000000 --- a/shared/samplecomposenavigation/src/iosMain/kotlin/kmp/shared/samplecomposenavigation/presentation/ui/AppBarHeight.kt +++ /dev/null @@ -1,5 +0,0 @@ -package kmp.shared.samplecomposenavigation.presentation.ui - -import androidx.compose.ui.unit.dp - -actual val AppBarHeight = 38.dp diff --git a/shared/samplefeature/build.gradle.kts b/shared/samplefeature/build.gradle.kts new file mode 100644 index 00000000..31764c44 --- /dev/null +++ b/shared/samplefeature/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.mateeStarter.kmp.library.compose) +} + +android { + namespace = "kmp.shared.samplefeature" +} + +dependencies { + commonMainImplementation(project(":shared:base")) + commonMainImplementation(project(":shared:analytics")) +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/model/JokeDto.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/model/JokeDto.kt new file mode 100644 index 00000000..f6250101 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/model/JokeDto.kt @@ -0,0 +1,20 @@ +package kmp.shared.samplefeature.data.model + +import kmp.shared.samplefeature.domain.model.Joke +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class JokeDto( + @SerialName("id") + val id: Long, + @SerialName("type") + val type: String, + @SerialName("setup") + val setup: String, + @SerialName("punchline") + val punchline: String, +) + +internal fun JokeDto.toDomain(): Joke = + Joke(id = id, type = type, setup = setup, punchline = punchline) diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/repository/JokeRepositoryImpl.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/repository/JokeRepositoryImpl.kt new file mode 100644 index 00000000..dc9804f0 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/repository/JokeRepositoryImpl.kt @@ -0,0 +1,16 @@ +package kmp.shared.samplefeature.data.repository + +import kmp.shared.base.domain.model.Result +import kmp.shared.base.domain.util.extension.map +import kmp.shared.samplefeature.data.model.JokeDto +import kmp.shared.samplefeature.data.model.toDomain +import kmp.shared.samplefeature.data.source.JokeSource +import kmp.shared.samplefeature.domain.model.Joke +import kmp.shared.samplefeature.domain.repository.JokeRepository + +internal class JokeRepositoryImpl(private val source: JokeSource) : JokeRepository { + + override suspend fun getRandomJoke(): Result = + source.getRandomJoke() + .map(JokeDto::toDomain) +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/service/JokeService.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/service/JokeService.kt new file mode 100644 index 00000000..f384372e --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/service/JokeService.kt @@ -0,0 +1,15 @@ +package kmp.shared.samplefeature.data.service + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import kmp.shared.base.domain.error.util.runCatchingCommonNetworkExceptions +import kmp.shared.base.domain.model.Result +import kmp.shared.samplefeature.data.model.JokeDto + +internal class JokeService(private val client: HttpClient) { + + suspend fun getRandomJoke(): Result = runCatchingCommonNetworkExceptions { + client.get("/random_joke").body() + } +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/source/JokeSource.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/source/JokeSource.kt new file mode 100644 index 00000000..2a6bd8c1 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/source/JokeSource.kt @@ -0,0 +1,8 @@ +package kmp.shared.samplefeature.data.source + +import kmp.shared.base.domain.model.Result +import kmp.shared.samplefeature.data.model.JokeDto + +internal interface JokeSource { + suspend fun getRandomJoke(): Result +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/source/impl/JokeSourceImpl.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/source/impl/JokeSourceImpl.kt new file mode 100644 index 00000000..7a78df39 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/data/source/impl/JokeSourceImpl.kt @@ -0,0 +1,12 @@ +package kmp.shared.samplefeature.data.source.impl + +import kmp.shared.base.domain.model.Result +import kmp.shared.samplefeature.data.model.JokeDto +import kmp.shared.samplefeature.data.service.JokeService +import kmp.shared.samplefeature.data.source.JokeSource + +internal class JokeSourceImpl(private val service: JokeService) : JokeSource { + + override suspend fun getRandomJoke(): Result = + service.getRandomJoke() +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/di/SampleFeatureModule.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/di/SampleFeatureModule.kt new file mode 100644 index 00000000..4ab31aef --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/di/SampleFeatureModule.kt @@ -0,0 +1,32 @@ +package kmp.shared.samplefeature.di + +import kmp.shared.samplefeature.data.repository.JokeRepositoryImpl +import kmp.shared.samplefeature.data.service.JokeService +import kmp.shared.samplefeature.data.source.JokeSource +import kmp.shared.samplefeature.data.source.impl.JokeSourceImpl +import kmp.shared.samplefeature.domain.repository.JokeRepository +import kmp.shared.samplefeature.domain.usecase.GetRandomJokeUseCase +import kmp.shared.samplefeature.domain.usecase.GetRandomJokeUseCaseImpl +import kmp.shared.samplefeature.presentation.vm.SampleFeatureViewModel +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val sampleFeatureModule = module { + // View Models + viewModelOf(::SampleFeatureViewModel) + + // Use Cases + factoryOf(::GetRandomJokeUseCaseImpl) bind GetRandomJokeUseCase::class + + // Repositories + singleOf(::JokeRepositoryImpl) bind JokeRepository::class + + // Sources + singleOf(::JokeSourceImpl) bind JokeSource::class + + // Services + singleOf(::JokeService) +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/model/Joke.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/model/Joke.kt new file mode 100644 index 00000000..950b5027 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/model/Joke.kt @@ -0,0 +1,8 @@ +package kmp.shared.samplefeature.domain.model + +data class Joke( + val id: Long, + val type: String, + val setup: String, + val punchline: String, +) diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/repository/JokeRepository.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/repository/JokeRepository.kt new file mode 100644 index 00000000..4a4ad2d1 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/repository/JokeRepository.kt @@ -0,0 +1,8 @@ +package kmp.shared.samplefeature.domain.repository + +import kmp.shared.base.domain.model.Result +import kmp.shared.samplefeature.domain.model.Joke + +internal interface JokeRepository { + suspend fun getRandomJoke(): Result +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/usecase/GetRandomJokeUseCase.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/usecase/GetRandomJokeUseCase.kt new file mode 100644 index 00000000..0383e73a --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/domain/usecase/GetRandomJokeUseCase.kt @@ -0,0 +1,16 @@ +package kmp.shared.samplefeature.domain.usecase + +import kmp.shared.base.domain.model.Result +import kmp.shared.base.domain.usecase.UseCaseResultNoParams +import kmp.shared.samplefeature.domain.model.Joke +import kmp.shared.samplefeature.domain.repository.JokeRepository + +interface GetRandomJokeUseCase : UseCaseResultNoParams + +internal class GetRandomJokeUseCaseImpl( + private val repository: JokeRepository, +) : GetRandomJokeUseCase { + + override suspend fun invoke(): Result = + repository.getRandomJoke() +} diff --git a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleNextScreen.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/SampleFeatureMainScreen.kt similarity index 55% rename from shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleNextScreen.kt rename to shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/SampleFeatureMainScreen.kt index 2671c3a0..0384fb07 100644 --- a/shared/samplecomposemultiplatform/src/commonMain/kotlin/kmp/shared/samplecomposemultiplatform/presentation/ui/SampleNextScreen.kt +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/SampleFeatureMainScreen.kt @@ -1,4 +1,4 @@ -package kmp.shared.samplecomposemultiplatform.presentation.ui +package kmp.shared.samplefeature.presentation.ui import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement @@ -13,18 +13,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import kmp.shared.samplecomposemultiplatform.presentation.common.AppTheme -import kmp.shared.samplecomposemultiplatform.presentation.common.StarterButton -import kmp.shared.samplecomposemultiplatform.presentation.ui.test.TestTags -import kmp.shared.samplecomposemultiplatform.presentation.ui.test.testTag -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextIntent -import kmp.shared.samplecomposemultiplatform.presentation.vm.SampleNextState +import kmp.shared.base.presentation.ui.AppTheme +import kmp.shared.base.presentation.ui.testTag +import kmp.shared.samplefeature.presentation.ui.test.TestTags +import kmp.shared.samplefeature.presentation.vm.SampleFeatureIntent +import kmp.shared.samplefeature.presentation.vm.SampleFeatureState import org.jetbrains.compose.ui.tooling.preview.Preview @Composable -fun SampleNextScreen( - state: SampleNextState, - onIntent: (SampleNextIntent) -> Unit, +fun SampleFeatureMainScreen( + state: SampleFeatureState, + onIntent: (SampleFeatureIntent) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -38,31 +37,27 @@ fun SampleNextScreen( modifier = Modifier.padding(16.dp), ) { Text( - text = "This is a SECOND screen of the sample with compose multiplatform UI and shared VM", + text = state.joke?.setup ?: "", textAlign = TextAlign.Center, + modifier = Modifier.testTag(TestTags.SampleFeatureMainScreen.JokeSetupText), ) Text( - text = state.sampleText?.value ?: "", - modifier = Modifier.testTag(TestTags.SampleComposeMultiplatformScreen.SampleText), + text = state.joke?.punchline ?: "", + modifier = Modifier.testTag(TestTags.SampleFeatureMainScreen.JokePunchlineText), ) - - StarterButton(onClick = { onIntent(SampleNextIntent.OnButtonTapped) }) { - Text(text = "Click me!") - } } } } } } -// Previews do not work for Fleet version 1.38.89 https://slack-chats.kotlinlang.org/t/22778734/are-there-specific-kotlin-ksp-version-requirements-for-getti @Preview @Composable -private fun SampleNextScreen_Preview() { +private fun SampleFeatureMainScreen_Preview() { AppTheme { - SampleNextScreen( - state = SampleNextState(), + SampleFeatureMainScreen( + state = SampleFeatureState(), onIntent = {}, ) } diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/test/TestTags.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/test/TestTags.kt new file mode 100644 index 00000000..48a253c5 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/test/TestTags.kt @@ -0,0 +1,11 @@ +package kmp.shared.samplefeature.presentation.ui.test + +import kmp.shared.base.presentation.ui.TestTag + +object TestTags { + + object SampleFeatureMainScreen { + object JokeSetupText : TestTag("sample_feature_main_joke_setup_text") + object JokePunchlineText : TestTag("sample_feature_main_joke_punchline_text") + } +} diff --git a/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/vm/SampleFeatureViewModel.kt b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/vm/SampleFeatureViewModel.kt new file mode 100644 index 00000000..b5949674 --- /dev/null +++ b/shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/vm/SampleFeatureViewModel.kt @@ -0,0 +1,86 @@ +package kmp.shared.samplefeature.presentation.vm + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope +import kmp.shared.analytics.domain.model.ToastAnalytics +import kmp.shared.analytics.domain.model.ToastAnalytics.ViewType +import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase +import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase.Params +import kmp.shared.base.domain.model.ErrorResult +import kmp.shared.base.domain.util.extension.alsoOnError +import kmp.shared.base.domain.util.extension.alsoOnSuccess +import kmp.shared.base.presentation.vm.BaseScopedViewModel +import kmp.shared.base.presentation.vm.VmEvent +import kmp.shared.base.presentation.vm.VmIntent +import kmp.shared.base.presentation.vm.VmState +import kmp.shared.samplefeature.domain.model.Joke +import kmp.shared.samplefeature.domain.usecase.GetRandomJokeUseCase +import kotlinx.coroutines.launch + +class SampleFeatureViewModel( + private val getRandomJoke: GetRandomJokeUseCase, + private val trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, +) : BaseScopedViewModel() { + + private var loading: Boolean by mutableStateOf(false) + private var joke: Joke? by mutableStateOf(null) + private var error: ErrorResult? by mutableStateOf(null) + + @Composable + override fun getState(): SampleFeatureState { + return SampleFeatureState( + loading = loading, + joke = joke, + error = error, + ) + } + + override fun onIntent(intent: SampleFeatureIntent) { + viewModelScope.launch { + when (intent) { + SampleFeatureIntent.OnButtonTapped -> showToast() + } + } + } + + override fun onViewAppeared() { + viewModelScope.launch { + loadRandomJoke() + } + } + + private suspend fun loadRandomJoke() { + loading = true + getRandomJoke() + .alsoOnSuccess { joke -> + this.joke = joke + loading = false + } + .alsoOnError { error -> + this.error = error + loading = false + } + } + + private suspend fun showToast() { + trackAnalyticsEventUseCase(Params(ToastAnalytics.ToastPresentedEvent(ViewType.SharedVM))) + _events.emit(SampleFeatureEvent.ShowMessage("Button was tapped")) + } +} + +data class SampleFeatureState( + val loading: Boolean = false, + val joke: Joke? = null, + val error: ErrorResult? = null, +) : VmState + +sealed interface SampleFeatureIntent : VmIntent { + data object OnButtonTapped : SampleFeatureIntent +} + +sealed interface SampleFeatureEvent : VmEvent { + data class ShowMessage(val message: String) : SampleFeatureEvent +} diff --git a/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/data/model/JokeConversionTest.kt b/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/data/model/JokeConversionTest.kt new file mode 100644 index 00000000..02db246d --- /dev/null +++ b/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/data/model/JokeConversionTest.kt @@ -0,0 +1,45 @@ +package kmp.shared.samplefeature.data.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +@Suppress( + "konsist.all DTOs are internal or private", + "konsist.all classes in 'data model' package are annotated with Serializable", +) +class JokeConversionTest { + + @Test + fun `toDomain converts JokeDto to Joke with correct data`() { + val jokeDto = JokeDto( + id = 456L, + type = "programming", + setup = "Why do programmers prefer dark mode?", + punchline = "Because light attracts bugs!", + ) + + val joke = jokeDto.toDomain() + + assertEquals(456L, joke.id) + assertEquals("programming", joke.type) + assertEquals("Why do programmers prefer dark mode?", joke.setup) + assertEquals("Because light attracts bugs!", joke.punchline) + } + + @Test + fun `toDomain preserves empty strings`() { + val jokeDto = JokeDto( + id = 789L, + type = "", + setup = "", + punchline = "", + ) + + val joke = jokeDto.toDomain() + + assertEquals(789L, joke.id) + assertEquals("", joke.type) + assertEquals("", joke.setup) + assertEquals("", joke.punchline) + } +} diff --git a/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/domain/usecase/GetRandomJokeUseCaseTest.kt b/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/domain/usecase/GetRandomJokeUseCaseTest.kt new file mode 100644 index 00000000..a78cae76 --- /dev/null +++ b/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/domain/usecase/GetRandomJokeUseCaseTest.kt @@ -0,0 +1,103 @@ +package kmp.shared.samplefeature.domain.usecase + +import kmp.shared.base.domain.error.domain.CommonError +import kmp.shared.base.domain.model.Result +import kmp.shared.samplefeature.domain.model.Joke +import kmp.shared.samplefeature.domain.repository.JokeRepository +import kotlinx.coroutines.runBlocking +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class GetRandomJokeUseCaseTest { + + private lateinit var mockRepository: JokeRepository + private lateinit var useCase: GetRandomJokeUseCase + + @BeforeTest + fun setUp() { + mockRepository = createDefaultMockRepository() + useCase = GetRandomJokeUseCaseImpl(mockRepository) + } + + @Test + fun `invoke returns success when repository returns success`() = runBlocking { + // Given + val expectedJoke = Joke( + id = 1L, + type = "programming", + setup = "Why do programmers prefer dark mode?", + punchline = "Because light attracts bugs!", + ) + mockRepository = object : JokeRepository { + override suspend fun getRandomJoke(): Result = Result.Success(expectedJoke) + } + useCase = GetRandomJokeUseCaseImpl(mockRepository) + + // When + val result = useCase() + + // Then + assertIs>(result) + assertEquals(expectedJoke, result.data) + } + + @Test + fun `invoke returns error when repository returns error`() = runBlocking { + // Given + val expectedError = CommonError.Unknown + mockRepository = object : JokeRepository { + override suspend fun getRandomJoke(): Result = Result.Error(expectedError) + } + useCase = GetRandomJokeUseCaseImpl(mockRepository) + + // When + val result = useCase() + + // Then + assertIs>(result) + assertEquals(expectedError, result.error) + assertNull(result.data) + } + + @Test + fun `invoke propagates repository result correctly`() = runBlocking { + // Given + val testJoke = Joke( + id = 42L, + type = "general", + setup = "Test setup", + punchline = "Test punchline", + ) + mockRepository = object : JokeRepository { + override suspend fun getRandomJoke(): Result = Result.Success(testJoke) + } + useCase = GetRandomJokeUseCaseImpl(mockRepository) + + // When + val result = useCase() + + // Then + assertIs>(result) + assertEquals(42L, result.data.id) + assertEquals("general", result.data.type) + assertEquals("Test setup", result.data.setup) + assertEquals("Test punchline", result.data.punchline) + } + + // Helper functions + private fun createDefaultMockRepository(): JokeRepository { + return object : JokeRepository { + override suspend fun getRandomJoke(): Result = Result.Success( + Joke( + id = 0L, + type = "default", + setup = "Default setup", + punchline = "Default punchline", + ), + ) + } + } +} diff --git a/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/presentation/vm/SampleFeatureViewModelTest.kt b/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/presentation/vm/SampleFeatureViewModelTest.kt new file mode 100644 index 00000000..3e7772e2 --- /dev/null +++ b/shared/samplefeature/src/commonTest/kotlin/kmp/shared/samplefeature/presentation/vm/SampleFeatureViewModelTest.kt @@ -0,0 +1,207 @@ +package kmp.shared.samplefeature.presentation.vm + +import kmp.shared.analytics.domain.model.ToastAnalytics +import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase +import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase.Params +import kmp.shared.base.domain.error.domain.CommonError +import kmp.shared.base.domain.model.Result +import kmp.shared.samplefeature.domain.model.Joke +import kmp.shared.samplefeature.domain.usecase.GetRandomJokeUseCase +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.BeforeTest +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@Ignore // Cannot use molecule in JVM tests +class SampleFeatureViewModelTest { + + private lateinit var mockGetRandomJoke: GetRandomJokeUseCase + private lateinit var mockTrackAnalytics: TrackAnalyticsEventUseCase + private lateinit var viewModel: SampleFeatureViewModel + + @BeforeTest + fun setUp() { + mockGetRandomJoke = createMockGetRandomJokeUseCase() + mockTrackAnalytics = createMockTrackAnalyticsUseCase() + viewModel = SampleFeatureViewModel(mockGetRandomJoke, mockTrackAnalytics) + } + + @Test + fun `initial state has loading true`() = runBlocking { + // When + val initialState = viewModel.state.first() + + // Then + assertTrue(initialState.loading) + assertNull(initialState.joke) + assertNull(initialState.error) + } + + @Test + fun `onViewAppeared loads joke successfully`() = runBlocking { + // Given + val expectedJoke = Joke( + id = 1L, + type = "programming", + setup = "Why do programmers prefer dark mode?", + punchline = "Because light attracts bugs!", + ) + mockGetRandomJoke = object : GetRandomJokeUseCase { + override suspend fun invoke(): Result = Result.Success(expectedJoke) + } + viewModel = SampleFeatureViewModel(mockGetRandomJoke, mockTrackAnalytics) + + // When + viewModel.onViewAppeared() + + // Then - wait for state to update (filter out initial loading state) + val state = withTimeout(5.seconds) { + viewModel.state.filter { !it.loading && it.joke != null }.first() + } + assertTrue(!state.loading) + assertNotNull(state.joke) + assertEquals(expectedJoke, state.joke) + assertNull(state.error) + } + + @Test + fun `onViewAppeared handles error correctly`() = runBlocking { + // Given + val expectedError = CommonError.Unknown + mockGetRandomJoke = object : GetRandomJokeUseCase { + override suspend fun invoke(): Result = Result.Error(expectedError) + } + viewModel = SampleFeatureViewModel(mockGetRandomJoke, mockTrackAnalytics) + + // When + viewModel.onViewAppeared() + + // Then - wait for state to update (filter for non-loading state with error) + val state = withTimeout(5.seconds) { + viewModel.state.filter { !it.loading && it.error != null }.first() + } + assertTrue(!state.loading) + assertNull(state.joke) + assertNotNull(state.error) + assertEquals(expectedError, state.error) + } + + @Test + fun `onViewAppeared sets loading to true then false`() = runBlocking { + // When + viewModel.onViewAppeared() + + // Then - wait for state to update (filter out initial loading state) + val finalState = withTimeout(5.seconds) { + viewModel.state.filter { !it.loading }.first() + } + assertTrue(!finalState.loading) + } + + @Test + fun `OnButtonTapped intent emits ShowMessage event`() = runBlocking { + // Given + var trackedEvent: ToastAnalytics.ToastPresentedEvent? = null + mockTrackAnalytics = object : TrackAnalyticsEventUseCase { + override suspend fun invoke(params: Params): Result { + trackedEvent = params.event as? ToastAnalytics.ToastPresentedEvent + return Result.Success(Unit) + } + } + viewModel = SampleFeatureViewModel(mockGetRandomJoke, mockTrackAnalytics) + + // Start collecting events before triggering the intent + val eventChannel = Channel(Channel.UNLIMITED) + val eventJob = launch { + viewModel.events.collect { event -> + eventChannel.trySend(event) + } + } + + // Small delay to ensure collection is active + kotlinx.coroutines.delay(50) + + // When + viewModel.onIntent(SampleFeatureIntent.OnButtonTapped) + + // Then - wait for the event to be collected + val event = withTimeout(5.seconds) { + eventChannel.receive() + } + eventJob.cancel() + eventChannel.close() + + assertIs(event) + assertEquals("Button was tapped", event.message) + assertNotNull(trackedEvent) + assertEquals("shared_vm", trackedEvent?.parameters?.get("presented_from")) + } + + @Test + fun `OnButtonTapped intent tracks analytics event`() = runBlocking { + // Given + var analyticsCalled = false + mockTrackAnalytics = object : TrackAnalyticsEventUseCase { + override suspend fun invoke(params: Params): Result { + analyticsCalled = true + assertIs(params.event) + return Result.Success(Unit) + } + } + viewModel = SampleFeatureViewModel(mockGetRandomJoke, mockTrackAnalytics) + + // Start collecting events before triggering the intent + val eventChannel = Channel(Channel.UNLIMITED) + val eventJob = launch { + viewModel.events.collect { event -> + eventChannel.trySend(event) + } + } + + // Small delay to ensure collection is active + kotlinx.coroutines.delay(50) + + // When + viewModel.onIntent(SampleFeatureIntent.OnButtonTapped) + + // Then - wait for the event to ensure analytics was called + withTimeout(5.seconds) { + eventChannel.receive() + } + eventJob.cancel() + eventChannel.close() + + assertTrue(analyticsCalled) + } + + // Helper functions + private fun createMockGetRandomJokeUseCase(): GetRandomJokeUseCase { + return object : GetRandomJokeUseCase { + override suspend fun invoke(): Result = Result.Success( + Joke( + id = 1L, + type = "test", + setup = "Test", + punchline = "Test", + ), + ) + } + } + + private fun createMockTrackAnalyticsUseCase(): TrackAnalyticsEventUseCase { + return object : TrackAnalyticsEventUseCase { + override suspend fun invoke(params: Params): Result = Result.Success(Unit) + } + } +} diff --git a/shared/samplefeature/src/iosMain/kotlin/kmp/shared/samplefeature/presentation/SampleFeatureMainScreenViewController.kt b/shared/samplefeature/src/iosMain/kotlin/kmp/shared/samplefeature/presentation/SampleFeatureMainScreenViewController.kt new file mode 100644 index 00000000..00c5b8e4 --- /dev/null +++ b/shared/samplefeature/src/iosMain/kotlin/kmp/shared/samplefeature/presentation/SampleFeatureMainScreenViewController.kt @@ -0,0 +1,25 @@ +package kmp.shared.samplefeature.presentation + +import androidx.compose.runtime.getValue +import androidx.compose.ui.window.ComposeUIViewController +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kmp.shared.base.presentation.ui.AppTheme +import kmp.shared.samplefeature.presentation.ui.SampleFeatureMainScreen +import kmp.shared.samplefeature.presentation.vm.SampleFeatureViewModel +import platform.UIKit.UIViewController + +@Suppress("Unused", "FunctionName") +fun SampleFeatureMainScreenViewController( + viewModel: SampleFeatureViewModel, +): UIViewController { + return ComposeUIViewController { + val state by viewModel.state.collectAsStateWithLifecycle() + + AppTheme { + SampleFeatureMainScreen( + state = state, + onIntent = viewModel::onIntent, + ) + } + } +} diff --git a/shared/samplesharedviewmodel/build.gradle.kts b/shared/samplesharedviewmodel/build.gradle.kts deleted file mode 100644 index 625ddf89..00000000 --- a/shared/samplesharedviewmodel/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -import extensions.libs - -plugins { - alias(libs.plugins.mateeStarter.kmm.library) - alias(libs.plugins.jetbrains.compose.compiler) -} - -android { - namespace = "kmp.shared.samplesharedviewmodel" -} - -ktlint { - filter { - exclude { entry -> - entry.file.toString().contains("generated") - } - } -} - -dependencies { - commonMainImplementation(project(":shared:base")) - commonMainImplementation(project(":shared:sample")) - commonMainImplementation(libs.molecule.runtime) -} diff --git a/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/di/Module.kt b/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/di/Module.kt deleted file mode 100644 index 59e5aca9..00000000 --- a/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/di/Module.kt +++ /dev/null @@ -1,10 +0,0 @@ -package kmp.shared.samplesharedviewmodel.di - -import kmp.shared.samplesharedviewmodel.vm.SampleSharedViewModel -import org.koin.core.module.dsl.viewModelOf -import org.koin.dsl.module - -val sampleSharedViewModelModule = module { - // View models - viewModelOf(::SampleSharedViewModel) -} diff --git a/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/vm/SampleSharedViewModel.kt b/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/vm/SampleSharedViewModel.kt deleted file mode 100644 index 38691c6d..00000000 --- a/shared/samplesharedviewmodel/src/commonMain/kotlin/kmp/shared/samplesharedviewmodel/vm/SampleSharedViewModel.kt +++ /dev/null @@ -1,98 +0,0 @@ -package kmp.shared.samplesharedviewmodel.vm - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot -import androidx.lifecycle.viewModelScope -import kmp.shared.analytics.domain.model.ToastAnalytics -import kmp.shared.analytics.domain.model.ToastAnalytics.ViewType -import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase -import kmp.shared.analytics.domain.usecase.TrackAnalyticsEventUseCase.Params -import kmp.shared.base.ErrorResult -import kmp.shared.base.Result -import kmp.shared.sample.domain.model.SampleText -import kmp.shared.sample.domain.usecase.GetSampleTextUseCase -import kmp.shared.samplesharedviewmodel.base.vm.BaseScopedViewModel -import kmp.shared.samplesharedviewmodel.base.vm.VmEvent -import kmp.shared.samplesharedviewmodel.base.vm.VmIntent -import kmp.shared.samplesharedviewmodel.base.vm.VmState -import kotlinx.coroutines.launch - -class SampleSharedViewModel( - private val getSampleText: GetSampleTextUseCase, - private val trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase, -) : BaseScopedViewModel() { - - private var sampleText by mutableStateOf(null) - private var loading by mutableStateOf(false) - private var error by mutableStateOf(null) - - @Composable - override fun getState(): SampleSharedState { - return SampleSharedState( - loading = loading, - error = error, - sampleText = sampleText, - ) - } - - override fun onIntent(intent: SampleSharedIntent) { - when (intent) { - SampleSharedIntent.OnButtonTapped -> { - viewModelScope.launch { - showToast() - } - } - SampleSharedIntent.OnNextButtonTapped -> { - viewModelScope.launch { - _events.emit(SampleSharedEvent.GoToNext) - } - } - } - } - - override fun onViewAppeared() { - viewModelScope.launch { - loadSampleText() - } - } - - private suspend fun loadSampleText() { - loading = true - when (val result = getSampleText()) { - is Result.Success -> Snapshot.withMutableSnapshot { - sampleText = result.data - loading = false - } - is Result.Error -> Snapshot.withMutableSnapshot { - error = result.error - loading = false - } - } - } - - private suspend fun showToast() { - trackAnalyticsEventUseCase(Params(ToastAnalytics.ToastPresentedEvent(ViewType.SharedVM))) - _events.emit(SampleSharedEvent.ShowMessage("Button was tapped")) - } -} - -data class SampleSharedState( - val loading: Boolean = false, - val sampleText: SampleText? = null, - val error: ErrorResult? = null, -) : VmState { - constructor() : this(true, null, null) -} - -sealed class SampleSharedIntent : VmIntent { - data object OnButtonTapped : SampleSharedIntent() - data object OnNextButtonTapped : SampleSharedIntent() -} - -sealed class SampleSharedEvent : VmEvent { - data class ShowMessage(val message: String) : SampleSharedEvent() - data object GoToNext : SampleSharedEvent() -} diff --git a/shared/core/.gitignore b/shared/umbrella/.gitignore similarity index 100% rename from shared/core/.gitignore rename to shared/umbrella/.gitignore diff --git a/shared/umbrella/build.gradle.kts b/shared/umbrella/build.gradle.kts new file mode 100644 index 00000000..44635283 --- /dev/null +++ b/shared/umbrella/build.gradle.kts @@ -0,0 +1,34 @@ +import co.touchlab.skie.configuration.DefaultArgumentInterop + +plugins { + alias(libs.plugins.mateeStarter.kmp.framework.library) + alias(libs.plugins.skie) +} + +skie { + swiftBundling { + enabled = true + } + + features { + group { + DefaultArgumentInterop.Enabled(false) + } + } +} + +android { + namespace = "kmp.shared.umbrella" +} + +multiplatformResources { + resourcesPackage.set("kmp.shared.umbrella") +} + +dependencies { + commonMainApi(project(":shared:base")) + commonMainApi(project(":shared:analytics")) + commonMainApi(project(":shared:samplefeature")) + + commonMainImplementation(project(":shared:auth")) +} diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt similarity index 100% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/android/compose/ComposeTest.kt diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/common/GeneralTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/common/GeneralTest.kt similarity index 100% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/common/GeneralTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/common/GeneralTest.kt diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/infrastructure/InfrastructureTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/data/DataTest.kt similarity index 74% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/infrastructure/InfrastructureTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/data/DataTest.kt index 43e1f663..a37d29b5 100644 --- a/shared/core/src/androidUnitTest/kotlin/konsistTest/infrastructure/InfrastructureTest.kt +++ b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/data/DataTest.kt @@ -1,4 +1,4 @@ -package konsistTest.infrastructure +package konsistTest.data import com.lemonappdev.konsist.api.Konsist import com.lemonappdev.konsist.api.ext.list.print @@ -10,35 +10,35 @@ import com.lemonappdev.konsist.api.verify.assertTrue import kotlinx.serialization.Serializable import org.junit.Test -internal class InfrastructureTest { +internal class DataTest { @Test fun `all DTOs are internal or private`() { Konsist .scopeFromProject() .classes() - .withPackage("..infrastructure.dto..") + .withPackage("..data.model..") .assertTrue { klass -> klass.hasInternalModifier || klass.hasPrivateModifier } } @Test - fun `all classes in 'infrastructure' package are annotated with Serializable`() { + fun `all classes in 'data model' package are annotated with Serializable`() { Konsist .scopeFromProject() .classes() - .withPackage("..infrastructure.dto..") + .withPackage("..data.model..") .assertTrue { klass -> klass.hasAnnotationOf(Serializable::class) } } @Test - fun `all classes in 'infrastructure' have immutable properties`() { + fun `all classes in 'data model' have immutable properties`() { Konsist .scopeFromProject() .classes() - .withPackage("..infrastructure.dto..") + .withPackage("..data.model..") .assertTrue { klass -> klass.properties() .all { prop -> prop.hasValModifier } @@ -50,20 +50,20 @@ internal class InfrastructureTest { Konsist .scopeFromProject() .functions() - .withPackage("..infrastructure..", "..data..") + .withPackage("..data..") .withName("toDomain") .print { it.fullyQualifiedName } .assertTrue { fn -> fn.hasInternalModifier || fn.hasPrivateModifier } } @Test - fun `all 'toDomain()' function are in the 'infrastructure' package`() { + fun `all 'toDomain()' function are in the 'data' package`() { Konsist .scopeFromProject() .functions() .filter { it.isTopLevel } .withoutReceiverType { it.name.contains("ViewObject") } .withNameContaining("toDomain") - .assertTrue { it.resideInPackage("..infrastructure..") } + .assertTrue { it.resideInPackage("..data..") } } } diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/di/KoinTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/di/KoinTest.kt similarity index 92% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/di/KoinTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/di/KoinTest.kt index 4a109ca7..562bf6f2 100644 --- a/shared/core/src/androidUnitTest/kotlin/konsistTest/di/KoinTest.kt +++ b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/di/KoinTest.kt @@ -37,19 +37,19 @@ internal class KoinTest { Konsist .scopeFromProject() .classes() - .withPackage("..infrastructure..") + .withPackage("..data..") .withParent { it.hasNameEndingWith("Source") } .withoutModifier(KoModifier.ABSTRACT) .assertIsDefinedInKoinModule() } @Test - fun `every 'Api' has a declaration in Koin's module`() { + fun `every 'Service' has a declaration in Koin's module`() { Konsist .scopeFromProject() .classes() - .withPackage("..infrastructure..") - .withParent { it.hasNameEndingWith("Api") } + .withPackage("..data..") + .withParent { it.hasNameEndingWith("Service") } .withoutModifier(KoModifier.ABSTRACT) .assertIsDefinedInKoinModule() } diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/domain/model/DomainModelsTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/domain/model/DomainModelsTest.kt similarity index 100% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/domain/model/DomainModelsTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/domain/model/DomainModelsTest.kt diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/domain/usecase/UseCaseTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/domain/usecase/UseCaseTest.kt similarity index 96% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/domain/usecase/UseCaseTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/domain/usecase/UseCaseTest.kt index f2d87fb1..a478ff96 100644 --- a/shared/core/src/androidUnitTest/kotlin/konsistTest/domain/usecase/UseCaseTest.kt +++ b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/domain/usecase/UseCaseTest.kt @@ -6,7 +6,6 @@ import com.lemonappdev.konsist.api.ext.list.withName import com.lemonappdev.konsist.api.ext.list.withParameters import com.lemonappdev.konsist.api.ext.list.withParent import com.lemonappdev.konsist.api.verify.assertTrue -import org.junit.Ignore import org.junit.Test internal class UseCaseTest { @@ -67,7 +66,6 @@ internal class UseCaseTest { } @Test - @Ignore("Once the project is prepared for this, enable it!") fun `interfaces extending 'UseCase' with params should have 'Params' data class that is used as param`() { Konsist .scopeFromProject() @@ -86,7 +84,7 @@ internal class UseCaseTest { val implementationHasParams = implementation .functions() - .withName("doWork") + .withName("invoke") .withParameters { params -> val param = params.first() param.type.fullyQualifiedName == paramDateClass.fullyQualifiedName diff --git a/shared/core/src/androidUnitTest/kotlin/konsistTest/repository/RepositoryTest.kt b/shared/umbrella/src/androidUnitTest/kotlin/konsistTest/repository/RepositoryTest.kt similarity index 100% rename from shared/core/src/androidUnitTest/kotlin/konsistTest/repository/RepositoryTest.kt rename to shared/umbrella/src/androidUnitTest/kotlin/konsistTest/repository/RepositoryTest.kt diff --git a/shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt b/shared/umbrella/src/commonMain/kotlin/kmp/shared/umbrella/di/UmbrellaModule.kt similarity index 64% rename from shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt rename to shared/umbrella/src/commonMain/kotlin/kmp/shared/umbrella/di/UmbrellaModule.kt index 7ac69604..eb8e442a 100644 --- a/shared/core/src/commonMain/kotlin/kmp/shared/core/di/Module.kt +++ b/shared/umbrella/src/commonMain/kotlin/kmp/shared/umbrella/di/UmbrellaModule.kt @@ -1,12 +1,9 @@ -package kmp.shared.core.di +package kmp.shared.umbrella.di import kmp.shared.analytics.di.analyticsModule import kmp.shared.auth.di.authModule import kmp.shared.base.di.baseModule -import kmp.shared.sample.di.sampleModule -import kmp.shared.samplecomposemultiplatform.di.sampleComposeMultiplatformModule -import kmp.shared.samplecomposenavigation.di.sampleComposeNavigationModule -import kmp.shared.samplesharedviewmodel.di.sampleSharedViewModelModule +import kmp.shared.samplefeature.di.sampleFeatureModule import org.koin.core.KoinApplication import org.koin.core.context.startKoin import org.koin.dsl.KoinAppDeclaration @@ -17,11 +14,8 @@ fun initKoin(appDeclaration: KoinAppDeclaration = {}): KoinApplication { modules( baseModule, authModule, - sampleModule, - sampleSharedViewModelModule, analyticsModule, - sampleComposeMultiplatformModule, - sampleComposeNavigationModule, + sampleFeatureModule, ) } diff --git a/shared/core/src/iosMain/kotlin/kmp/shared/core/KotlinDateTime.kt b/shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/KotlinDateTime.kt similarity index 97% rename from shared/core/src/iosMain/kotlin/kmp/shared/core/KotlinDateTime.kt rename to shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/KotlinDateTime.kt index 46fa0941..afe48363 100644 --- a/shared/core/src/iosMain/kotlin/kmp/shared/core/KotlinDateTime.kt +++ b/shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/KotlinDateTime.kt @@ -1,4 +1,4 @@ -package kmp.shared.core +package kmp.shared.umbrella import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate diff --git a/shared/core/src/iosMain/kotlin/kmp/shared/core/SwiftCoroutines.kt b/shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/SwiftCoroutines.kt similarity index 91% rename from shared/core/src/iosMain/kotlin/kmp/shared/core/SwiftCoroutines.kt rename to shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/SwiftCoroutines.kt index 562f4f07..ec98d9b0 100644 --- a/shared/core/src/iosMain/kotlin/kmp/shared/core/SwiftCoroutines.kt +++ b/shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/SwiftCoroutines.kt @@ -1,15 +1,15 @@ @file:Suppress("unused") // used in shared code @file:OptIn(ExperimentalObjCName::class) -package kmp.shared.core +package kmp.shared.umbrella -import kmp.shared.base.Result -import kmp.shared.base.usecase.UseCaseFlow -import kmp.shared.base.usecase.UseCaseFlowNoParams -import kmp.shared.base.usecase.UseCaseFlowResult -import kmp.shared.base.usecase.UseCaseFlowResultNoParams -import kmp.shared.base.usecase.UseCaseResult -import kmp.shared.base.usecase.UseCaseResultNoParams +import kmp.shared.base.domain.model.Result +import kmp.shared.base.domain.usecase.UseCaseFlow +import kmp.shared.base.domain.usecase.UseCaseFlowNoParams +import kmp.shared.base.domain.usecase.UseCaseFlowResult +import kmp.shared.base.domain.usecase.UseCaseFlowResultNoParams +import kmp.shared.base.domain.usecase.UseCaseResult +import kmp.shared.base.domain.usecase.UseCaseResultNoParams import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/shared/core/src/iosMain/kotlin/kmp/shared/core/di/KoinIOS.kt b/shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/di/KoinIOS.kt similarity index 83% rename from shared/core/src/iosMain/kotlin/kmp/shared/core/di/KoinIOS.kt rename to shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/di/KoinIOS.kt index 3ed93d17..697ba681 100644 --- a/shared/core/src/iosMain/kotlin/kmp/shared/core/di/KoinIOS.kt +++ b/shared/umbrella/src/iosMain/kotlin/kmp/shared/umbrella/di/KoinIOS.kt @@ -1,9 +1,9 @@ @file:OptIn(BetaInteropApi::class) -package kmp.shared.core.di +package kmp.shared.umbrella.di import kmp.shared.analytics.data.provider.AnalyticsProvider -import kmp.shared.base.system.Config +import kmp.shared.base.domain.system.Config import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ObjCClass import kotlinx.cinterop.ObjCProtocol @@ -13,16 +13,17 @@ import org.koin.core.parameter.parametersOf import org.koin.core.qualifier.Qualifier import org.koin.dsl.module +@Suppress("unused") fun initKoinIos( doOnStartup: () -> Unit, - analyticsProvider: AnalyticsProvider, - config: Config, + analyticsProvider: () -> AnalyticsProvider, + config: () -> Config, ) = initKoin { modules( module { single { doOnStartup } - single { analyticsProvider } - single { config } + single { analyticsProvider() } + single { config() } }, ) } diff --git a/shared/core/src/iosMain/swift/extensions/ErrorResult+Extensions.swift b/shared/umbrella/src/iosMain/swift/extensions/ErrorResult+Extensions.swift similarity index 100% rename from shared/core/src/iosMain/swift/extensions/ErrorResult+Extensions.swift rename to shared/umbrella/src/iosMain/swift/extensions/ErrorResult+Extensions.swift diff --git a/shared/core/src/iosMain/swift/extensions/Interoperability+Extensions.swift b/shared/umbrella/src/iosMain/swift/extensions/Interoperability+Extensions.swift similarity index 100% rename from shared/core/src/iosMain/swift/extensions/Interoperability+Extensions.swift rename to shared/umbrella/src/iosMain/swift/extensions/Interoperability+Extensions.swift diff --git a/shared/core/src/iosMain/swift/extensions/SharedUseCase+Extensions.swift b/shared/umbrella/src/iosMain/swift/extensions/SharedUseCase+Extensions.swift similarity index 100% rename from shared/core/src/iosMain/swift/extensions/SharedUseCase+Extensions.swift rename to shared/umbrella/src/iosMain/swift/extensions/SharedUseCase+Extensions.swift diff --git a/twine/strings.txt b/twine/strings.txt index b973f55d..93127953 100644 --- a/twine/strings.txt +++ b/twine/strings.txt @@ -24,28 +24,11 @@ en = English sk = Angličtina -[[Bottom bar view]] - [bottom_bar_item_1] - cs = Classic - en = Classic - sk = Classic - [bottom_bar_item_2] - cs = Shared VMs - en = Shared VMs - sk = Shared VMs - [bottom_bar_item_3] - cs = Compose Multiplatform - en = Compose Multiplatform - sk = Compose Multiplatform - [bottom_bar_item_4] - cs = Compose Navigation - en = Compose Navigation - sk = Compose Navigation [[Screens]] - [next_screen_title] - cs = Next - en = Next - sk = Next + [sample_feature_title] + cs = Sample feature + en = Sample feature + sk = Sample feature [[General navigation]] [done]