├── shared ├── src │ ├── commonMain │ │ ├── kotlin │ │ │ ├── trackEvent.kt │ │ │ └── org │ │ │ │ └── mixdrinks │ │ │ │ ├── ui │ │ │ │ ├── profile │ │ │ │ │ ├── root │ │ │ │ │ │ ├── ProfileRootNavigation.kt │ │ │ │ │ │ ├── DeleteAccountService.kt │ │ │ │ │ │ ├── ProfileRootComponent.kt │ │ │ │ │ │ └── ProfileRootContent.kt │ │ │ │ │ ├── ProfileContent.kt │ │ │ │ │ ├── ProfileNavigator.kt │ │ │ │ │ └── ProfileComponent.kt │ │ │ │ ├── visited │ │ │ │ │ ├── VisitedCocktail.kt │ │ │ │ │ ├── VisitedCocktailsNavigation.kt │ │ │ │ │ ├── AuthExecutor.kt │ │ │ │ │ ├── UserVisitedCocktailsService.kt │ │ │ │ │ ├── VisititedCocktailsContent.kt │ │ │ │ │ └── VisitedCocktailsComponent.kt │ │ │ │ ├── auth │ │ │ │ │ ├── AuthCallbacks.kt │ │ │ │ │ ├── AuthBus.kt │ │ │ │ │ ├── TokenStorage.kt │ │ │ │ │ └── AuthView.kt │ │ │ │ ├── items │ │ │ │ │ ├── ItemDetailsNavigation.kt │ │ │ │ │ ├── ItemGoodsRepository.kt │ │ │ │ │ ├── ItemDetailComponent.kt │ │ │ │ │ └── ItemDetailsView.kt │ │ │ │ ├── tag │ │ │ │ │ ├── CommonTagNavigation.kt │ │ │ │ │ ├── CommonTag.kt │ │ │ │ │ ├── CommonTagNameProvider.kt │ │ │ │ │ ├── TagCocktails.kt │ │ │ │ │ ├── Tag.kt │ │ │ │ │ └── CommonTagCocktailsComponent.kt │ │ │ │ ├── list │ │ │ │ │ ├── FilterObserver.kt │ │ │ │ │ ├── predefine │ │ │ │ │ │ ├── PreDefineFilterStorage.kt │ │ │ │ │ │ └── PreDefineCocktailsComponent.kt │ │ │ │ │ ├── CocktailsListState.kt │ │ │ │ │ ├── CocktailListMapper.kt │ │ │ │ │ ├── SelectedFilterProvider.kt │ │ │ │ │ ├── main │ │ │ │ │ │ ├── MutableFilterStorage.kt │ │ │ │ │ │ └── ListComponent.kt │ │ │ │ │ └── CocktailList.kt │ │ │ │ ├── filters │ │ │ │ │ ├── FilterValueChangeDelegate.kt │ │ │ │ │ ├── search │ │ │ │ │ │ ├── ItemRepository.kt │ │ │ │ │ │ └── SearchItemComponent.kt │ │ │ │ │ ├── FilterItem.kt │ │ │ │ │ └── main │ │ │ │ │ │ ├── FilterView.kt │ │ │ │ │ │ └── FilterComponent.kt │ │ │ │ ├── details │ │ │ │ │ ├── CocktailsDetailNavigation.kt │ │ │ │ │ ├── goods │ │ │ │ │ │ ├── GoodsRepository.kt │ │ │ │ │ │ ├── GoodsSubComponent.kt │ │ │ │ │ │ ├── Counter.kt │ │ │ │ │ │ └── GoodsView.kt │ │ │ │ │ ├── FullCocktailRepository.kt │ │ │ │ │ └── DetailsComponent.kt │ │ │ │ ├── widgets │ │ │ │ │ ├── Loader.kt │ │ │ │ │ ├── undomain │ │ │ │ │ │ ├── ComponentScope.kt │ │ │ │ │ │ ├── StateInWhileSubscribe.kt │ │ │ │ │ │ └── ContentHolder.kt │ │ │ │ │ ├── CustomButton.kt │ │ │ │ │ └── Header.kt │ │ │ │ ├── navigation │ │ │ │ │ ├── INavigator.kt │ │ │ │ │ ├── DeepLinkParser.kt │ │ │ │ │ └── MainTabNavigator.kt │ │ │ │ ├── main │ │ │ │ │ ├── MainContent.kt │ │ │ │ │ └── MainComponent.kt │ │ │ │ ├── RootContent.kt │ │ │ │ └── RootComponent.kt │ │ │ │ ├── data │ │ │ │ ├── Tracking.kt │ │ │ │ ├── TagsRepository.kt │ │ │ │ ├── MixDrinksService.kt │ │ │ │ ├── DetailGoods.kt │ │ │ │ ├── FullCocktail.kt │ │ │ │ ├── FutureCocktailSelector.kt │ │ │ │ ├── SnapshotRepository.kt │ │ │ │ └── CocktailsProvider.kt │ │ │ │ ├── app │ │ │ │ ├── utils │ │ │ │ │ ├── undomain │ │ │ │ │ │ └── LazySuspend.kt │ │ │ │ │ └── ResString.kt │ │ │ │ ├── styles │ │ │ │ │ ├── MixDrinksColors.kt │ │ │ │ │ └── MixDrinksTextStyles.kt │ │ │ │ └── MixDrinksApp.kt │ │ │ │ └── di │ │ │ │ └── Graph.kt │ │ └── resources │ │ │ ├── ic_arrow_back.xml │ │ │ ├── ic_minus.xml │ │ │ ├── ic_clear.xml │ │ │ ├── ic_home.xml │ │ │ ├── ic_more.xml │ │ │ ├── ic_plus.xml │ │ │ ├── ic_search.xml │ │ │ ├── ic_filter.xml │ │ │ ├── ic_apple.xml │ │ │ ├── ic_profile.xml │ │ │ └── ic_google.xml │ ├── androidMain │ │ └── kotlin │ │ │ ├── trackEvent.kt │ │ │ └── main.android.kt │ ├── commonTest │ │ └── kotlin │ │ │ └── org │ │ │ └── mixdrinks │ │ │ └── utils │ │ │ └── undomain │ │ │ └── LazySuspendTest.kt │ └── iosMain │ │ └── kotlin │ │ └── main.ios.kt └── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── iosApp ├── Configuration │ └── Config.xcconfig ├── iosApp │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── 29.png │ │ │ ├── 40.png │ │ │ ├── 57.png │ │ │ ├── 58.png │ │ │ ├── 60.png │ │ │ ├── 80.png │ │ │ ├── 87.png │ │ │ ├── 1024.png │ │ │ ├── 114.png │ │ │ ├── 120.png │ │ │ ├── 180.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── iosApp.entitlements │ ├── ContentView.swift │ ├── iOSApp.swift │ └── Info.plist ├── .idea │ ├── vcs.xml │ ├── .gitignore │ ├── xcode.xml │ ├── modules.xml │ └── misc.xml ├── Podfile ├── exportOptionsTestFly.plist ├── exportOptionsRelease.plist └── GoogleService-Info.plist ├── .github ├── renovate.json └── workflows │ ├── detekt.yml │ └── pr.yml ├── androidApp ├── src │ └── androidMain │ │ ├── res │ │ ├── mipmap-hdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-mdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-xhdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-xxhdpi │ │ │ └── ic_launcher.png │ │ ├── mipmap-xxxhdpi │ │ │ └── ic_launcher.png │ │ └── drawable │ │ │ ├── ic_clear.xml │ │ │ └── ic_search.xml │ │ └── AndroidManifest.xml ├── proguard-rules.pro ├── google-services.json └── build.gradle.kts ├── cleanup.sh ├── .gitignore ├── .editorconfig ├── gradle.properties ├── settings.gradle.kts ├── gradlew.bat └── README.md /shared/src/commonMain/kotlin/trackEvent.kt: -------------------------------------------------------------------------------- 1 | expect fun trackEvent(action: String, data: Map) 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /iosApp/Configuration/Config.xcconfig: -------------------------------------------------------------------------------- 1 | TEAM_ID=9LJW42P73L 2 | BUNDLE_ID=org.mixdrinks.app 3 | APP_NAME=MixDrinks 4 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "author": "xcode", 4 | "version": 1 5 | } 6 | } -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "author": "xcode", 4 | "version": 1 5 | } 6 | } -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/androidApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/androidApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/androidApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/androidApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MixDrinks/Mobile/HEAD/androidApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /iosApp/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /iosApp/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors": [ 3 | { 4 | "idiom": "universal" 5 | } 6 | ], 7 | "info": { 8 | "author": "xcode", 9 | "version": 1 10 | } 11 | } -------------------------------------------------------------------------------- /iosApp/.idea/xcode.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/root/ProfileRootNavigation.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile.root 2 | 3 | internal interface ProfileRootNavigation { 4 | 5 | fun navigateToVisitedCocktails() 6 | 7 | fun back() 8 | } 9 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/trackEvent.kt: -------------------------------------------------------------------------------- 1 | var trackAnalyticsCallback: (action: String, data: Map) -> Unit = { _, _ -> } 2 | actual fun trackEvent(action: String, data: Map) { 3 | trackAnalyticsCallback(action, data) 4 | } 5 | -------------------------------------------------------------------------------- /cleanup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf .idea 3 | ./gradlew clean 4 | rm -rf .gradle 5 | rm -rf build 6 | rm -rf */build 7 | rm -rf iosApp/iosApp.xcworkspace 8 | rm -rf iosApp/Pods 9 | rm -rf iosApp/iosApp.xcodeproj/project.xcworkspace 10 | rm -rf iosApp/iosApp.xcodeproj/xcuserdata 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /iosApp/.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/root/DeleteAccountService.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile.root 2 | 3 | import de.jensklingenberg.ktorfit.http.DELETE 4 | 5 | internal interface DeleteAccountService { 6 | 7 | @DELETE("/user-api/myself") 8 | suspend fun deleteAccount() 9 | 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | build/ 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | iosApp/Podfile.lock 11 | iosApp/Pods/* 12 | iosApp/iosApp.xcworkspace/* 13 | iosApp/iosApp.xcodeproj/* 14 | !iosApp/iosApp.xcodeproj/project.pbxproj 15 | shared/shared.podspec 16 | iosApp/.idea 17 | -------------------------------------------------------------------------------- /iosApp/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | 3 | platform :ios, '15.0' 4 | 5 | install! 'cocoapods', :deterministic_uuids => false 6 | 7 | target 'iosApp' do 8 | use_frameworks! 9 | platform :ios, '14.1' 10 | pod 'shared', :path => '../shared' 11 | end 12 | 13 | pod 'GoogleSignIn' 14 | pod 'FirebaseCore' 15 | pod 'FirebaseAuth' 16 | pod 'FirebaseAnalytics' 17 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/visited/VisitedCocktail.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.visited 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | import org.mixdrinks.dto.CocktailId 6 | 7 | @Serializable 8 | data class VisitedCocktail( 9 | @SerialName("id") 10 | val id: CocktailId, 11 | ) 12 | -------------------------------------------------------------------------------- /.github/workflows/detekt.yml: -------------------------------------------------------------------------------- 1 | name: Run Gradle on PRs 2 | on: pull_request 3 | 4 | jobs: 5 | detekt: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: "checkout" 10 | uses: actions/checkout@v3 11 | 12 | - name: "detekt" 13 | uses: natiginfo/action-detekt-all@1.23.0 14 | with: 15 | args: --config detekt.yml -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/auth/AuthCallbacks.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.auth 2 | 3 | object AuthCallbacks { 4 | 5 | var appleAuthStart: () -> Unit = {} 6 | 7 | var googleAuthStart: () -> Unit = {} 8 | 9 | var emailAuthStart: (email: String, password: String) -> Unit = { _, _ -> } 10 | 11 | var logout: () -> Unit = {} 12 | 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/items/ItemDetailsNavigation.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.items 2 | 3 | import org.mixdrinks.dto.CocktailId 4 | import org.mixdrinks.dto.TagId 5 | 6 | interface ItemDetailsNavigation { 7 | 8 | fun back() 9 | 10 | fun navigateToDetails(cocktailId: CocktailId) 11 | 12 | fun navigateToTagCocktails(tagId: TagId) 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/tag/CommonTagNavigation.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.tag 2 | 3 | import org.mixdrinks.dto.CocktailId 4 | import org.mixdrinks.dto.TagId 5 | 6 | interface CommonTagNavigation { 7 | 8 | fun navigateToDetails(cocktailId: CocktailId) 9 | 10 | fun navigateToTagCocktails(tagId: TagId) 11 | 12 | fun back() 13 | 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/tag/CommonTag.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.tag 2 | 3 | import org.mixdrinks.domain.FilterGroups 4 | 5 | internal data class CommonTag( 6 | val id: Int, 7 | val type: Type, 8 | ) { 9 | 10 | enum class Type(val filterGroups: FilterGroups) { 11 | TAG(FilterGroups.TAGS), TASTE(FilterGroups.TASTE) 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/FilterObserver.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list 2 | 3 | import kotlinx.coroutines.flow.StateFlow 4 | import org.mixdrinks.dto.FilterGroupId 5 | import org.mixdrinks.ui.list.main.MutableFilterStorage 6 | 7 | interface FilterObserver { 8 | 9 | val selected: StateFlow>> 10 | } 11 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/visited/VisitedCocktailsNavigation.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.visited 2 | 3 | import org.mixdrinks.dto.CocktailId 4 | import org.mixdrinks.dto.TagId 5 | 6 | interface VisitedCocktailsNavigation { 7 | 8 | fun navigateToDetails(cocktailId: CocktailId) 9 | 10 | fun navigateToTagCocktails(tagId: TagId) 11 | 12 | fun back() 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_arrow_back.xml: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_minus.xml: -------------------------------------------------------------------------------- 1 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_clear.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/drawable/ic_clear.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/Tracking.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import trackEvent 4 | 5 | object Tracking { 6 | 7 | fun track( 8 | action: String, 9 | screen: String, 10 | data: Map = emptyMap(), 11 | ) { 12 | val mapToSend = data.toMutableMap() 13 | mapToSend["screen"] = screen 14 | 15 | trackEvent(action, mapToSend) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/TagsRepository.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import org.mixdrinks.dto.TagDto 4 | import org.mixdrinks.dto.TagId 5 | 6 | internal class TagsRepository( 7 | private val snapshotRepository: SnapshotRepository, 8 | ) { 9 | 10 | suspend fun getTags(tagId: List): List { 11 | return snapshotRepository.get().tags.filter { tagDto -> tagId.contains(tagDto.id) } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_home.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_more.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /iosApp/iosApp/iosApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.applesignin 6 | 7 | Default 8 | 9 | com.apple.developer.associated-domains 10 | 11 | https://mixdrinks.org/ 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /iosApp/exportOptionsTestFly.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | ad-hoc 7 | provisioningProfiles 8 | 9 | org.mixdrinks.app9LJW42P73L 10 | TestFly(signIn) 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/visited/AuthExecutor.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.visited 2 | 3 | import org.mixdrinks.di.GraphHolder 4 | 5 | @Suppress("TooGenericExceptionCaught") 6 | suspend fun authExecutor(block: suspend () -> T): Result { 7 | return try { 8 | Result.success(block()) 9 | } catch (e: Exception) { 10 | GraphHolder.graph.authBus.logout() 11 | println("Error: $e") 12 | Result.failure(e) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /iosApp/exportOptionsRelease.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | method 6 | app-store 7 | provisioningProfiles 8 | 9 | org.mixdrinks.app9LJW42P73L 10 | Production(signIn) 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_plus.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 4 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_search.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/visited/UserVisitedCocktailsService.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.visited 2 | 3 | import de.jensklingenberg.ktorfit.http.GET 4 | import de.jensklingenberg.ktorfit.http.POST 5 | import de.jensklingenberg.ktorfit.http.Query 6 | 7 | internal interface UserVisitedCocktailsService { 8 | 9 | @GET("user-api/cocktail/visit/list") 10 | suspend fun getVisitedCocktails(): List 11 | 12 | @POST("user-api/cocktail/visit") 13 | suspend fun visitCocktail(@Query("id") id: Int) 14 | 15 | } 16 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_filter.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/filters/FilterValueChangeDelegate.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.filters 2 | 3 | import org.mixdrinks.dto.FilterGroupId 4 | import org.mixdrinks.dto.FilterId 5 | 6 | internal interface FilterValueChangeDelegate { 7 | 8 | fun onFilterStateChange(filterGroupId: FilterGroupId, id: FilterId, isSelect: Boolean) 9 | 10 | fun onFilterStateChange(filterItemUiModel: FilterItemUiModel, isSelect: Boolean) { 11 | onFilterStateChange(filterItemUiModel.groupId, filterItemUiModel.id, isSelect) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | #Gradle 2 | org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" 3 | #Kotlin 4 | kotlin.code.style=official 5 | 6 | #MPP 7 | kotlin.mpp.stability.nowarn=true 8 | kotlin.mpp.enableCInteropCommonization=true 9 | kotlin.mpp.androidSourceSetLayoutVersion=2 10 | #Compose 11 | org.jetbrains.compose.experimental.uikit.enabled=true 12 | kotlin.native.cacheKind=none 13 | #Android 14 | android.useAndroidX=true 15 | android.compileSdk=34 16 | android.targetSdk=34 17 | android.minSdk=24 18 | #Versions 19 | kotlin.version=1.9.0 20 | agp.version=8.0.0 21 | compose.version=1.4.3 22 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/CocktailsDetailNavigation.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details 2 | 3 | import org.mixdrinks.data.ItemsType 4 | import org.mixdrinks.dto.CocktailId 5 | import org.mixdrinks.dto.TagId 6 | import org.mixdrinks.dto.TasteId 7 | 8 | interface CocktailsDetailNavigation { 9 | 10 | fun navigateToItem(itemsType: ItemsType.Type, id: Int) 11 | 12 | fun navigateToDetails(cocktailId: CocktailId) 13 | 14 | fun navigateToTagCocktails(tagId: TagId) 15 | 16 | fun navigationToTasteCocktails(tasteId: TasteId) 17 | 18 | fun back() 19 | 20 | } 21 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/MixDrinksService.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import de.jensklingenberg.ktorfit.http.GET 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import org.mixdrinks.dto.SnapshotDto 7 | 8 | internal interface MixDrinksService { 9 | 10 | @GET("/snapshot") 11 | suspend fun getSnapshot(): SnapshotDto 12 | 13 | @GET("/version") 14 | suspend fun getVersion(): Version 15 | 16 | } 17 | 18 | @Serializable 19 | data class Version( 20 | @SerialName("version_code") 21 | val versionCode: Int, 22 | ) 23 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/app/utils/undomain/LazySuspend.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.app.utils.undomain 2 | 3 | import kotlin.concurrent.Volatile 4 | import kotlinx.coroutines.sync.Mutex 5 | import kotlinx.coroutines.sync.withLock 6 | 7 | internal class LazySuspend( 8 | private val block: suspend () -> T, 9 | ) { 10 | 11 | @Volatile 12 | private var value: T? = null 13 | 14 | private val mutex = Mutex() 15 | 16 | suspend operator fun invoke(): T { 17 | return mutex.withLock { 18 | if (value == null) { 19 | value = block() 20 | } 21 | value!! 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/app/styles/MixDrinksColors.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.app.styles 2 | 3 | import androidx.compose.runtime.Stable 4 | import androidx.compose.ui.graphics.Color 5 | 6 | @Suppress("MagicNumber") 7 | internal object MixDrinksColors { 8 | 9 | @Stable 10 | val Main = Color(0xFF2B4718) 11 | 12 | @Stable 13 | val Secondary = Color(0xFFe9f1e6) 14 | 15 | @Stable 16 | val White = Color(0xFFFFFFFF) 17 | 18 | @Stable 19 | val Black = Color(0xFF000000) 20 | 21 | @Stable 22 | val Grey = Color(0xFF9C9C9C) 23 | 24 | @Stable 25 | val DarkGrey = Color(0xFF404040) 26 | 27 | @Stable 28 | val Red = Color(0xFFFF0000) 29 | } 30 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/app/MixDrinksApp.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.app 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import com.arkivanov.decompose.DefaultComponentContext 6 | import com.arkivanov.essenty.backhandler.BackDispatcher 7 | import org.mixdrinks.di.Graph 8 | import org.mixdrinks.ui.RootComponent 9 | import org.mixdrinks.ui.RootContent 10 | 11 | @Composable 12 | internal fun MixDrinksApp(contextComponent: DefaultComponentContext, deepLink: String?) { 13 | val mainComponent = remember { 14 | val graph = Graph() 15 | RootComponent(contextComponent, graph) 16 | } 17 | 18 | RootContent(mainComponent, deepLink) 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/DetailGoods.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import org.mixdrinks.domain.FilterGroups 4 | 5 | data class DetailGoodsUiModel( 6 | val id: Int, 7 | val name: String, 8 | val about: String, 9 | val url: String 10 | ) 11 | 12 | data class ItemsType( 13 | val id: Int, 14 | val type: Type 15 | ) { 16 | enum class Type { 17 | GOODS, GLASSWARE, TOOL; 18 | 19 | fun getFilterGroup(): FilterGroups { 20 | return when (this) { 21 | GOODS -> FilterGroups.GOODS 22 | GLASSWARE -> FilterGroups.GLASSWARE 23 | TOOL -> FilterGroups.TOOLS 24 | } 25 | } 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/widgets/Loader.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.widgets 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.fillMaxSize 5 | import androidx.compose.material.CircularProgressIndicator 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import org.mixdrinks.app.styles.MixDrinksColors 10 | 11 | @Composable 12 | internal fun Loader() { 13 | Box( 14 | modifier = Modifier.fillMaxSize() 15 | ) { 16 | CircularProgressIndicator( 17 | color = MixDrinksColors.Main, 18 | modifier = Modifier.align(Alignment.Center) 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/tag/CommonTagNameProvider.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.tag 2 | 3 | import org.mixdrinks.data.SnapshotRepository 4 | 5 | internal class CommonTagNameProvider( 6 | private val snapshotRepository: SnapshotRepository, 7 | ) { 8 | 9 | suspend fun getName(commonTag: CommonTag): String? { 10 | return when (commonTag.type) { 11 | CommonTag.Type.TAG -> { 12 | snapshotRepository.get() 13 | .tags.find { it.id.id == commonTag.id }?.name 14 | } 15 | 16 | CommonTag.Type.TASTE -> { 17 | snapshotRepository.get() 18 | .tastes.find { it.id.id == commonTag.id }?.name 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/widgets/undomain/ComponentScope.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.widgets.undomain 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.essenty.lifecycle.doOnDestroy 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.SupervisorJob 8 | import kotlinx.coroutines.cancel 9 | import kotlinx.coroutines.launch 10 | 11 | internal fun ComponentContext.launch(block: suspend () -> Unit) { 12 | lifecycle.doOnDestroy { 13 | this.scope.cancel() 14 | } 15 | this.scope.launch { 16 | block() 17 | } 18 | } 19 | 20 | internal val ComponentContext.scope: CoroutineScope 21 | get() = CoroutineScope(Dispatchers.Default + SupervisorJob()) 22 | -------------------------------------------------------------------------------- /iosApp/.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/app/utils/ResString.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.app.utils 2 | 3 | internal object ResString { 4 | 5 | const val apply = "Застосувати" 6 | const val cocktailNotFound = "Коктейлів по запиту не знайдено" 7 | const val clearFilterForFoundSomething = "Очистіть фільтри, щоб знайти щось" 8 | const val filters = "Фільтри" 9 | const val clear = "Очистити" 10 | const val profile = "Профіль" 11 | const val visitedCocktails = "Відвідані коктейлі" 12 | const val logout = "Вийти" 13 | const val deleteAccount = "Видалити акаунт" 14 | const val deleteAccountDialogMessage = "Ви впевнені, що хочете видалити акаунт?" 15 | const val deleteAccountDialogYes = "Так" 16 | const val deleteAccountDialogNo = "Ні" 17 | const val search = "Пошук" 18 | } 19 | 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/widgets/undomain/StateInWhileSubscribe.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.widgets.undomain 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.distinctUntilChanged 9 | import kotlinx.coroutines.flow.stateIn 10 | 11 | internal fun Flow>.stateInWhileSubscribe(): StateFlow> { 12 | return this 13 | .distinctUntilChanged() 14 | .stateIn( 15 | CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(STOP_SUBSCRIBE_TIME_OUT), UiState.Loading 16 | ) 17 | } 18 | 19 | private const val STOP_SUBSCRIBE_TIME_OUT = 100L 20 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/predefine/PreDefineFilterStorage.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list.predefine 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | import org.mixdrinks.dto.FilterGroupId 6 | import org.mixdrinks.dto.FilterId 7 | import org.mixdrinks.ui.list.FilterObserver 8 | import org.mixdrinks.ui.list.main.MutableFilterStorage 9 | 10 | class PreDefineFilterStorage( 11 | filterGroupId: FilterGroupId, 12 | id: FilterId 13 | ) : FilterObserver { 14 | 15 | override val selected: StateFlow>> = 16 | MutableStateFlow( 17 | mapOf( 18 | filterGroupId to listOf(MutableFilterStorage.FilterSelected(id, 0)) 19 | ) 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/navigation/INavigator.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.navigation 2 | 3 | import org.mixdrinks.data.ItemsType 4 | import org.mixdrinks.dto.CocktailId 5 | import org.mixdrinks.dto.TagId 6 | import org.mixdrinks.dto.TasteId 7 | import org.mixdrinks.ui.filters.search.SearchItemComponent 8 | 9 | internal interface INavigator { 10 | 11 | fun back() 12 | 13 | fun navigateToItem(itemsType: ItemsType.Type, id: Int) 14 | 15 | fun navigateToDetails(cocktailId: CocktailId) 16 | 17 | fun navigateToSearchItem(searchItemType: SearchItemComponent.SearchItemType) 18 | 19 | fun navigateToFilters() 20 | 21 | fun navigateToTagCocktails(tagId: TagId) 22 | 23 | fun navigationToTasteCocktails(tasteId: TasteId) 24 | 25 | fun openFromDeepLink(config: MainTabNavigator.Config) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /iosApp/iosApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import shared 4 | import GoogleSignIn 5 | import Firebase 6 | import FirebaseAuth 7 | import AuthenticationServices 8 | 9 | struct ComposeView: UIViewControllerRepresentable { 10 | func makeUIViewController(context: Context) -> UIViewController { 11 | let controller = Main_iosKt.MainViewController() 12 | controller.navigationController?.interactivePopGestureRecognizer?.isEnabled = false 13 | return controller 14 | } 15 | 16 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} 17 | } 18 | 19 | struct ContentView: View { 20 | 21 | @StateObject private var viewModel = MainViewModel() 22 | 23 | var body: some View { 24 | ComposeView() 25 | .ignoresSafeArea(.keyboard) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_apple.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/auth/AuthBus.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.auth 2 | 3 | import kotlinx.coroutines.flow.MutableStateFlow 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | class AuthBus( 7 | private val tokenStorage: TokenStorage, 8 | ) { 9 | 10 | private val _showAuthDialog = MutableStateFlow(false) 11 | val showAuthDialog: StateFlow = _showAuthDialog 12 | 13 | private val logoutNotfier = mutableListOf<() -> Unit>() 14 | 15 | fun registerLogoutNotifier(notifier: () -> Unit) { 16 | logoutNotfier.add(notifier) 17 | } 18 | 19 | fun logout() { 20 | tokenStorage.clean() 21 | AuthCallbacks.logout() 22 | logoutNotfier.forEach { it() } 23 | } 24 | 25 | fun tryEmit(value: Boolean) { 26 | _showAuthDialog.tryEmit(value) 27 | } 28 | 29 | suspend fun emit(value: Boolean) { 30 | _showAuthDialog.emit(value) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/FullCocktail.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import org.mixdrinks.dto.CocktailId 4 | import org.mixdrinks.dto.GlasswareId 5 | import org.mixdrinks.dto.TagId 6 | import org.mixdrinks.dto.TasteId 7 | import org.mixdrinks.dto.ToolId 8 | 9 | internal data class FullCocktail( 10 | val id: CocktailId, 11 | val name: String, 12 | val receipt: List, 13 | val tools: List, 14 | val tags: List, 15 | val tastes: List, 16 | val glassware: Glassware, 17 | ) { 18 | data class Tool( 19 | val toolId: ToolId, 20 | val name: String, 21 | ) 22 | 23 | data class Tag( 24 | val id: TagId, 25 | val name: String, 26 | ) 27 | 28 | data class Taste( 29 | val id: TasteId, 30 | val name: String, 31 | ) 32 | 33 | data class Glassware( 34 | val id: GlasswareId, 35 | val name: String, 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/CocktailsListState.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list 2 | 3 | import androidx.compose.runtime.Immutable 4 | import org.mixdrinks.dto.CocktailId 5 | import org.mixdrinks.dto.TagId 6 | import org.mixdrinks.ui.filters.FilterItemUiModel 7 | 8 | @Immutable 9 | internal sealed class CocktailsListState { 10 | @Immutable 11 | data class Cocktails( 12 | val list: List, 13 | ) : CocktailsListState() { 14 | @Immutable 15 | data class Cocktail( 16 | val id: CocktailId, 17 | val url: String, 18 | val name: String, 19 | val tags: List, 20 | ) 21 | } 22 | 23 | @Immutable 24 | data class TagUIModel( 25 | val id: TagId, 26 | val name: String, 27 | ) 28 | 29 | @Immutable 30 | data class PlaceHolder( 31 | val filters: List, 32 | ) : CocktailsListState() 33 | } 34 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/auth/TokenStorage.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.auth 2 | 3 | import com.russhwolf.settings.Settings 4 | import kotlinx.coroutines.flow.MutableStateFlow 5 | import kotlinx.coroutines.flow.StateFlow 6 | 7 | class TokenStorage( 8 | private val settings: Settings, 9 | ) { 10 | 11 | private val _tokenFlow = MutableStateFlow(getToken()) 12 | val tokenFlow: StateFlow = _tokenFlow 13 | 14 | fun setToken(token: String) { 15 | println("new token: $token") 16 | settings.putString(KEY_TOKEN, token) 17 | _tokenFlow.tryEmit(token) 18 | } 19 | 20 | fun getToken(): String? { 21 | return settings.getStringOrNull(KEY_TOKEN) 22 | } 23 | 24 | fun clean() { 25 | print("clean") 26 | settings.remove(KEY_TOKEN) 27 | _tokenFlow.tryEmit(null) 28 | } 29 | 30 | private companion object { 31 | const val KEY_TOKEN = "KEY_TOKEN" 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/CocktailListMapper.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list 2 | 3 | import androidx.compose.ui.text.capitalize 4 | import androidx.compose.ui.text.intl.Locale 5 | import org.mixdrinks.data.CocktailsProvider 6 | import org.mixdrinks.domain.ImageUrlCreators 7 | 8 | internal class CocktailListMapper { 9 | 10 | fun map(cocktails: List): List { 11 | return cocktails.map { cocktail -> 12 | CocktailsListState.Cocktails.Cocktail( 13 | id = cocktail.id, 14 | url = ImageUrlCreators.createUrl( 15 | cocktail.id, ImageUrlCreators.Size.SIZE_400 16 | ), 17 | name = cocktail.name, 18 | tags = cocktail.tags.map { 19 | CocktailsListState.TagUIModel( 20 | it.id, it.name.capitalize(Locale.current) 21 | ) 22 | }, 23 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/goods/GoodsRepository.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details.goods 2 | 3 | import org.mixdrinks.dto.CocktailId 4 | import org.mixdrinks.dto.GoodId 5 | import org.mixdrinks.dto.SnapshotDto 6 | 7 | internal class GoodsRepository( 8 | private val snapshot: suspend () -> SnapshotDto, 9 | ) { 10 | 11 | suspend fun getGoods(cocktailId: CocktailId): List { 12 | val cocktail = snapshot().cocktails.find { it.id == cocktailId } 13 | ?: error("Cocktail $cocktailId not found") 14 | val goods = cocktail.goods.map { 15 | Good( 16 | goodId = it.goodId, 17 | name = snapshot().goods.first { good -> good.id == it.goodId }.name, 18 | amount = it.amount, 19 | unit = it.unit, 20 | ) 21 | } 22 | 23 | return goods; 24 | } 25 | 26 | data class Good( 27 | val goodId: GoodId, 28 | val name: String, 29 | val amount: Int, 30 | val unit: String, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /shared/src/androidMain/kotlin/main.android.kt: -------------------------------------------------------------------------------- 1 | import androidx.appcompat.app.AppCompatActivity 2 | import androidx.compose.runtime.Composable 3 | import androidx.compose.ui.platform.LocalContext 4 | import com.arkivanov.decompose.defaultComponentContext 5 | import org.mixdrinks.app.MixDrinksApp 6 | import org.mixdrinks.di.GraphHolder 7 | import org.mixdrinks.ui.auth.AuthCallbacks 8 | 9 | @Composable 10 | fun MainView(deepLink : String?) { 11 | val context = (LocalContext.current as AppCompatActivity).defaultComponentContext() 12 | MixDrinksApp(context, deepLink) 13 | } 14 | 15 | @Suppress("FunctionNaming") 16 | fun NewToken(token: String) { 17 | GraphHolder.graph.tokenStorage.setToken(token) 18 | } 19 | 20 | fun setLogout(block: () -> Unit) { 21 | AuthCallbacks.logout = { 22 | block() 23 | } 24 | } 25 | 26 | fun setGoogleAuthStart(block: () -> Unit) { 27 | AuthCallbacks.googleAuthStart = { 28 | block() 29 | } 30 | } 31 | 32 | fun setAppleAuthStart(block: () -> Unit) { 33 | AuthCallbacks.appleAuthStart = { 34 | block() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /androidApp/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -dontwarn org.bouncycastle.jsse.BCSSLSocket 2 | -dontwarn org.bouncycastle.jsse.BCSSLParameters 3 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 4 | -dontwarn org.conscrypt.* 5 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters 6 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket 7 | -dontwarn org.openjsse.net.ssl.OpenJSSE 8 | -dontwarn org.slf4j.impl.StaticLoggerBinder 9 | 10 | # Don't note a bunch of dynamically referenced classes 11 | -dontnote com.google.** 12 | -dontwarn com.google.firebase.messaging.** 13 | -keep public class com.google.firebase.** 14 | -keep class com.google.android.gms.internal.** 15 | -keepclasseswithmembers class com.google.firebase.FirebaseException 16 | -dontnote com.squareup.okhttp.** 17 | -dontnote okhttp3.internal.** 18 | 19 | # Recommended flags for Firebase Auth 20 | -keepattributes Signature 21 | -keepattributes *Annotation* 22 | 23 | # Retrofit config 24 | -dontnote retrofit2.Platform 25 | -dontwarn retrofit2.** 26 | -dontwarn okhttp3.** 27 | -dontwarn okio.** 28 | -keepattributes Exceptions 29 | 30 | # TODO remove https://github.com/google/gson/issues/1174 31 | -dontwarn com.google.gson.Gson$6 32 | 33 | -------------------------------------------------------------------------------- /shared/src/commonTest/kotlin/org/mixdrinks/utils/undomain/LazySuspendTest.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.utils.undomain 2 | 3 | import kotlinx.coroutines.delay 4 | import kotlinx.coroutines.launch 5 | import kotlinx.coroutines.runBlocking 6 | import org.mixdrinks.app.utils.undomain.LazySuspend 7 | import kotlin.test.Test 8 | import kotlin.test.assertEquals 9 | 10 | class LazySuspendTest { 11 | 12 | @Test 13 | fun `Verify get value from lazy suspend`() { 14 | var callCount = 0 15 | val test = LazySuspend { 16 | callCount++ 17 | delay(1000) 18 | return@LazySuspend "test" 19 | } 20 | 21 | runBlocking { 22 | launch { 23 | assertEquals("test", test.invoke()) 24 | } 25 | launch { 26 | assertEquals("test", test.invoke()) 27 | } 28 | } 29 | 30 | runBlocking { 31 | assertEquals("test", test.invoke()) 32 | } 33 | 34 | assertEquals(callCount, 1) 35 | 36 | runBlocking { 37 | assertEquals("test", test.invoke()) 38 | } 39 | 40 | assertEquals(callCount, 1) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/root/ProfileRootComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile.root 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.launch 6 | import org.mixdrinks.ui.auth.AuthBus 7 | import org.mixdrinks.ui.visited.authExecutor 8 | import org.mixdrinks.ui.widgets.undomain.scope 9 | 10 | internal class ProfileRootComponent( 11 | private val componentContext: ComponentContext, 12 | private val profileRootNavigation: ProfileRootNavigation, 13 | private val authBus: AuthBus, 14 | private val deleteAccountService: DeleteAccountService, 15 | ) : ComponentContext by componentContext, 16 | ProfileRootNavigation by profileRootNavigation { 17 | 18 | 19 | 20 | fun logout() { 21 | authBus.logout() 22 | } 23 | 24 | fun deleteAccount() { 25 | scope.launch { 26 | authExecutor { 27 | deleteAccountService.deleteAccount() 28 | } 29 | }.invokeOnCompletion { 30 | scope.launch(Dispatchers.Main) { 31 | authBus.logout() 32 | } 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/tag/TagCocktails.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.tag 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.lazy.LazyColumn 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import org.mixdrinks.ui.list.cocktailListInserter 9 | import org.mixdrinks.ui.widgets.MixDrinksHeader 10 | 11 | @OptIn(ExperimentalFoundationApi::class) 12 | @Composable 13 | internal fun TagCocktails( 14 | component: CommonTagCocktailsComponent, 15 | ) { 16 | val name by component.name.collectAsState() 17 | val cocktails by component.state.collectAsState() 18 | 19 | LazyColumn { 20 | stickyHeader { 21 | MixDrinksHeader( 22 | name = name, 23 | component::back 24 | ) 25 | } 26 | 27 | cocktailListInserter( 28 | cocktails = cocktails, 29 | onClick = component::navigateToDetails, 30 | onTagClick = component::navigateToTagCocktails, 31 | trackingScreen = "page_tag_cocktails" 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_profile.xml: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/widgets/undomain/ContentHolder.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.widgets.undomain 2 | 3 | import androidx.compose.material.Text 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.Immutable 6 | import androidx.compose.runtime.collectAsState 7 | import androidx.compose.runtime.getValue 8 | import kotlinx.coroutines.flow.StateFlow 9 | import org.mixdrinks.ui.widgets.Loader 10 | 11 | @Composable 12 | internal fun ContentHolder( 13 | stateflow: StateFlow>, 14 | content: @Composable (T) -> Unit, 15 | ) { 16 | val state by stateflow.collectAsState() 17 | 18 | when (val safeState = state) { 19 | is UiState.Data -> { 20 | content(safeState.data) 21 | } 22 | 23 | is UiState.Error -> { 24 | Text(safeState.reason) 25 | } 26 | 27 | UiState.Loading -> { 28 | Loader() 29 | } 30 | } 31 | } 32 | 33 | @Immutable 34 | internal sealed class UiState { 35 | @Immutable 36 | object Loading : UiState() 37 | 38 | @Immutable 39 | data class Error( 40 | val reason: String, 41 | ) : UiState() 42 | 43 | @Immutable 44 | data class Data(val data: T) : UiState() 45 | } 46 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/FutureCocktailSelector.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import org.mixdrinks.domain.CocktailSelector 4 | import org.mixdrinks.dto.CocktailId 5 | import org.mixdrinks.dto.FilterGroupId 6 | import org.mixdrinks.dto.FilterId 7 | import org.mixdrinks.dto.SnapshotDto 8 | import org.mixdrinks.ui.list.main.MutableFilterStorage 9 | 10 | internal class FutureCocktailSelector( 11 | private val snapshot: suspend () -> SnapshotDto, 12 | private val cocktailSelector: suspend () -> CocktailSelector, 13 | private val mutableFilterStorage: suspend () -> MutableFilterStorage, 14 | ) { 15 | suspend fun getCocktailIds(futureFilterGroupId: FilterGroupId, futureFilterId: FilterId): Set { 16 | val filters = mutableFilterStorage().getSelectedFilters() 17 | .mapValues { it.value.map { it.filterId } } 18 | .toMutableMap() 19 | 20 | filters[futureFilterGroupId] = filters[futureFilterGroupId].orEmpty() + futureFilterId 21 | 22 | val notEmptyFilter = filters.filter { it.value.isNotEmpty() } 23 | return if (notEmptyFilter.isEmpty()) { 24 | snapshot().cocktails.map { it.id }.toSet() 25 | } else { 26 | cocktailSelector().getCocktailIds(notEmptyFilter) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/widgets/CustomButton.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.widgets 2 | 3 | import androidx.compose.foundation.layout.fillMaxWidth 4 | import androidx.compose.foundation.layout.height 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.shape.RoundedCornerShape 7 | import androidx.compose.material.Button 8 | import androidx.compose.material.ButtonDefaults 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.unit.dp 13 | import org.mixdrinks.app.styles.MixDrinksColors 14 | import org.mixdrinks.app.styles.MixDrinksTextStyles 15 | 16 | @Composable 17 | internal fun CustomButton(modifier: Modifier, text: String, onClick: () -> Unit) { 18 | Button( 19 | onClick = { onClick() }, 20 | shape = RoundedCornerShape(16.dp), 21 | colors = ButtonDefaults.buttonColors(backgroundColor = MixDrinksColors.Main), 22 | modifier = modifier 23 | .fillMaxWidth() 24 | .padding(vertical = 8.dp) 25 | .height(40.dp) 26 | ) { 27 | Text( 28 | text = text, 29 | style = MixDrinksTextStyles.H4, 30 | color = MixDrinksColors.White 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /iosApp/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 18528209355-huqbcrn38tkgv9g1kvpt1l5r24jlt29v.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.18528209355-huqbcrn38tkgv9g1kvpt1l5r24jlt29v 9 | ANDROID_CLIENT_ID 10 | 18528209355-ei9b3v6gkcr9rjk29lspvdpfs5e7uvq0.apps.googleusercontent.com 11 | API_KEY 12 | AIzaSyBX4aFClqVRN1n6StMYr-kpUI41ra18afI 13 | GCM_SENDER_ID 14 | 18528209355 15 | PLIST_VERSION 16 | 1 17 | BUNDLE_ID 18 | org.mixdrinks.app9LJW42P73L 19 | PROJECT_ID 20 | mixdrinks-bb828 21 | STORAGE_BUCKET 22 | mixdrinks-bb828.appspot.com 23 | IS_ADS_ENABLED 24 | 25 | IS_ANALYTICS_ENABLED 26 | 27 | IS_APPINVITE_ENABLED 28 | 29 | IS_GCM_ENABLED 30 | 31 | IS_SIGNIN_ENABLED 32 | 33 | GOOGLE_APP_ID 34 | 1:18528209355:ios:45e2d12f94ef0428e5ce4e 35 | 36 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/SelectedFilterProvider.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list 2 | 3 | import org.mixdrinks.dto.FilterGroupId 4 | import org.mixdrinks.dto.FilterId 5 | import org.mixdrinks.dto.SnapshotDto 6 | import org.mixdrinks.ui.filters.FilterItemUiModel 7 | import org.mixdrinks.ui.list.main.MutableFilterStorage 8 | 9 | internal class SelectedFilterProvider( 10 | private val snapshot: suspend () -> SnapshotDto, 11 | private val mutableFilterStorage: suspend () -> MutableFilterStorage, 12 | ) { 13 | 14 | suspend fun getSelectedFiltersWithData(): List { 15 | return mutableFilterStorage().getSelectedFilters().flatMap { (filterGroupId, filters) -> 16 | filters.map { filter -> 17 | FilterItemUiModel( 18 | groupId = filterGroupId, 19 | id = filter.filterId, 20 | name = getFilterName(filterGroupId, filter.filterId), 21 | isSelect = true, 22 | isEnable = true, 23 | ) 24 | } 25 | } 26 | } 27 | 28 | private suspend fun getFilterName(filterGroupId: FilterGroupId, filterId: FilterId): String { 29 | return snapshot().filterGroups.find { it.id == filterGroupId }?.filters?.find { it.id == filterId }?.name 30 | ?: error("Cannot found filter") 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /iosApp/iosApp/iOSApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import FirebaseCore 3 | import GoogleSignIn 4 | import AuthenticationServices 5 | import UIKit 6 | 7 | class AppDelegate: NSObject, UIApplicationDelegate { 8 | 9 | func application(_ application: UIApplication, 10 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 11 | FirebaseApp.configure() 12 | return true 13 | } 14 | 15 | func application(_ app: UIApplication, 16 | open url: URL, 17 | options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { 18 | var handled: Bool 19 | 20 | handled = GIDSignIn.sharedInstance.handle(url) 21 | if handled { 22 | return true 23 | } 24 | // Handle other custom URL types. 25 | return false 26 | } 27 | 28 | } 29 | 30 | @main 31 | struct iOSApp: App { 32 | @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate 33 | 34 | var body: some Scene { 35 | WindowGroup { 36 | ZStack { 37 | GeometryReader { reader in 38 | Color.black 39 | .frame(height: reader.safeAreaInsets.top, alignment: .top) 40 | .ignoresSafeArea() 41 | } 42 | ContentView() 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "MixDrinks" 2 | 3 | include(":androidApp") 4 | include(":shared") 5 | 6 | pluginManagement { 7 | repositories { 8 | gradlePluginPortal() 9 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 10 | google() 11 | } 12 | 13 | plugins { 14 | val kotlinVersion = extra["kotlin.version"] as String 15 | val agpVersion = extra["agp.version"] as String 16 | val composeVersion = extra["compose.version"] as String 17 | 18 | kotlin("jvm").version(kotlinVersion) 19 | kotlin("multiplatform").version(kotlinVersion) 20 | kotlin("android").version(kotlinVersion) 21 | 22 | kotlin("plugin.serialization").version(kotlinVersion) 23 | id("com.google.devtools.ksp").version("${kotlinVersion}-1.0.13") 24 | id("de.jensklingenberg.ktorfit") version "1.0.0" 25 | 26 | id("com.android.application").version(agpVersion) 27 | id("com.android.library").version(agpVersion) 28 | 29 | id("org.jetbrains.compose").version(composeVersion) 30 | 31 | kotlin("native.cocoapods").version(kotlinVersion) 32 | id("com.google.gms.google-services").version("4.3.15") 33 | id("com.google.firebase.crashlytics").version("2.9.6") 34 | } 35 | } 36 | 37 | dependencyResolutionManagement { 38 | repositories { 39 | google() 40 | mavenCentral() 41 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/tag/Tag.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.tag 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.height 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.shape.RoundedCornerShape 9 | import androidx.compose.material.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.ui.Alignment 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.dp 14 | import org.mixdrinks.app.styles.MixDrinksColors 15 | import org.mixdrinks.app.styles.MixDrinksTextStyles 16 | import org.mixdrinks.dto.TagId 17 | import org.mixdrinks.ui.list.CocktailsListState 18 | 19 | @Composable 20 | internal fun Tag(tag: CocktailsListState.TagUIModel, onClick: (TagId) -> Unit) { 21 | Tag(name = tag.name, onClick = { onClick(tag.id) }) 22 | } 23 | 24 | @Composable 25 | internal fun Tag(name: String, onClick: () -> Unit) { 26 | Box( 27 | modifier = Modifier 28 | .height(32.dp) 29 | .padding(horizontal = 2.dp) 30 | .clickable { onClick() } 31 | .background(MixDrinksColors.Secondary, shape = RoundedCornerShape(4.dp)) 32 | ) { 33 | Text( 34 | modifier = Modifier 35 | .align(Alignment.Center) 36 | .padding(horizontal = 8.dp), 37 | text = name, 38 | color = MixDrinksColors.DarkGrey, 39 | style = MixDrinksTextStyles.H5, 40 | ) 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/app/styles/MixDrinksTextStyles.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.app.styles 2 | 3 | import androidx.compose.ui.text.TextStyle 4 | import androidx.compose.ui.text.font.FontFamily 5 | import androidx.compose.ui.text.font.FontWeight 6 | import androidx.compose.ui.unit.sp 7 | 8 | @Suppress("MagicNumber") 9 | internal object MixDrinksTextStyles { 10 | 11 | val H1 = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontSize = 32.sp, 14 | fontWeight = FontWeight.SemiBold, 15 | letterSpacing = (-0.07).sp 16 | ) 17 | 18 | val H2 = TextStyle( 19 | fontFamily = FontFamily.Default, 20 | fontSize = 28.sp, 21 | fontWeight = FontWeight.SemiBold, 22 | letterSpacing = (-0.07).sp 23 | ) 24 | 25 | val H3 = TextStyle( 26 | fontFamily = FontFamily.Default, 27 | fontSize = 22.sp, 28 | fontWeight = FontWeight.W600, 29 | letterSpacing = (-0.07).sp, 30 | lineHeight = 20.sp, 31 | ) 32 | 33 | val H4 = TextStyle( 34 | fontFamily = FontFamily.Default, 35 | fontSize = 18.sp, 36 | fontWeight = FontWeight.W600, 37 | letterSpacing = (-0.07).sp, 38 | lineHeight = 20.sp, 39 | ) 40 | 41 | val H5 = TextStyle( 42 | fontFamily = FontFamily.Default, 43 | fontSize = 14.sp, 44 | fontWeight = FontWeight.Bold, 45 | letterSpacing = (-0.07).sp, 46 | lineHeight = 20.sp, 47 | ) 48 | 49 | val H6 = TextStyle( 50 | fontFamily = FontFamily.Default, 51 | fontSize = 12.sp, 52 | fontWeight = FontWeight.Bold, 53 | letterSpacing = (-0.07).sp, 54 | lineHeight = 18.sp, 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /androidApp/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/predefine/PreDefineCocktailsComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list.predefine 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.distinctUntilChanged 9 | import kotlinx.coroutines.flow.emitAll 10 | import kotlinx.coroutines.flow.flow 11 | import kotlinx.coroutines.flow.flowOn 12 | import kotlinx.coroutines.flow.map 13 | import kotlinx.coroutines.flow.stateIn 14 | import org.mixdrinks.data.CocktailsProvider 15 | import org.mixdrinks.ui.items.ItemDetailsNavigation 16 | import org.mixdrinks.ui.list.CocktailListMapper 17 | import org.mixdrinks.ui.list.CocktailsListState 18 | 19 | internal class PreDefineCocktailsComponent( 20 | private val componentContext: ComponentContext, 21 | private val cocktailsProvider: CocktailsProvider, 22 | private val mainTabNavigator: ItemDetailsNavigation, 23 | private val cocktailsMapper: CocktailListMapper, 24 | ) : ComponentContext by componentContext, 25 | ItemDetailsNavigation by mainTabNavigator { 26 | 27 | val state: StateFlow = flow { 28 | emitAll(cocktailsProvider.getCocktails().map { cocktails -> 29 | CocktailsListState.Cocktails(cocktailsMapper.map(cocktails)) 30 | }) 31 | } 32 | .flowOn(Dispatchers.Default) 33 | .distinctUntilChanged() 34 | .stateIn( 35 | CoroutineScope(Dispatchers.Main), 36 | SharingStarted.WhileSubscribed(), 37 | CocktailsListState.Cocktails(emptyList()) 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/ProfileContent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile 2 | 3 | import androidx.compose.material.Scaffold 4 | import androidx.compose.runtime.Composable 5 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children 6 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide 7 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation 8 | import org.mixdrinks.ui.details.DetailView 9 | import org.mixdrinks.ui.items.ItemDetailsView 10 | import org.mixdrinks.ui.profile.root.ProfileRootContent 11 | import org.mixdrinks.ui.tag.TagCocktails 12 | import org.mixdrinks.ui.visited.VisitedCocktailsContent 13 | 14 | @Composable 15 | internal fun ProfileContent(component: ProfileComponent) { 16 | Scaffold( 17 | content = { 18 | Children( 19 | stack = component.stack, 20 | animation = stackAnimation( 21 | animator = slide() 22 | ), 23 | content = { 24 | when (val child = it.instance) { 25 | is ProfileComponent.ProfileChild.CommonTag -> TagCocktails(child.component) 26 | is ProfileComponent.ProfileChild.Details -> DetailView(child.component) 27 | is ProfileComponent.ProfileChild.Item -> ItemDetailsView(child.component) 28 | is ProfileComponent.ProfileChild.VisitedCocktails -> VisitedCocktailsContent(child.component) 29 | is ProfileComponent.ProfileChild.ProfileRoot -> ProfileRootContent(child.component) 30 | } 31 | } 32 | ) 33 | } 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "size": "60x60", 5 | "filename": "180.png", 6 | "idiom": "iphone", 7 | "scale": "3x" 8 | }, 9 | { 10 | "size": "40x40", 11 | "filename": "80.png", 12 | "idiom": "iphone", 13 | "scale": "2x" 14 | }, 15 | { 16 | "size": "40x40", 17 | "filename": "120.png", 18 | "idiom": "iphone", 19 | "scale": "3x" 20 | }, 21 | { 22 | "size": "60x60", 23 | "filename": "120.png", 24 | "idiom": "iphone", 25 | "scale": "2x" 26 | }, 27 | { 28 | "size": "57x57", 29 | "filename": "57.png", 30 | "idiom": "iphone", 31 | "scale": "1x" 32 | }, 33 | { 34 | "size": "29x29", 35 | "filename": "58.png", 36 | "idiom": "iphone", 37 | "scale": "2x" 38 | }, 39 | { 40 | "size": "29x29", 41 | "filename": "29.png", 42 | "idiom": "iphone", 43 | "scale": "1x" 44 | }, 45 | { 46 | "size": "29x29", 47 | "filename": "87.png", 48 | "idiom": "iphone", 49 | "scale": "3x" 50 | }, 51 | { 52 | "size": "57x57", 53 | "filename": "114.png", 54 | "idiom": "iphone", 55 | "scale": "2x" 56 | }, 57 | { 58 | "size": "20x20", 59 | "filename": "40.png", 60 | "idiom": "iphone", 61 | "scale": "2x" 62 | }, 63 | { 64 | "size": "20x20", 65 | "filename": "60.png", 66 | "idiom": "iphone", 67 | "scale": "3x" 68 | }, 69 | { 70 | "size": "1024x1024", 71 | "filename": "1024.png", 72 | "idiom": "ios-marketing", 73 | "scale": "1x" 74 | } 75 | ], 76 | "info": { 77 | "author": "xcode", 78 | "version": 1 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/FullCocktailRepository.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details 2 | 3 | import org.mixdrinks.data.FullCocktail 4 | import org.mixdrinks.data.SnapshotRepository 5 | import org.mixdrinks.dto.CocktailId 6 | 7 | internal class FullCocktailRepository( 8 | private val snapshotRepository: SnapshotRepository, 9 | ) { 10 | 11 | suspend fun getFullCocktail(cocktailId: CocktailId): FullCocktail? { 12 | val snapshot = snapshotRepository.get() 13 | val cocktail = snapshot.cocktails.find { it.id == cocktailId } ?: return null 14 | 15 | val tools = snapshot.tools.filter { cocktail.tools.contains(it.id) } 16 | .map { 17 | FullCocktail.Tool( 18 | toolId = it.id, 19 | name = it.name, 20 | ) 21 | } 22 | val tastes = snapshot.tastes.filter { cocktail.tastes.contains(it.id) } 23 | .map { 24 | FullCocktail.Taste( 25 | id = it.id, 26 | name = it.name, 27 | ) 28 | } 29 | val tags = snapshot.tags.filter { cocktail.tags.contains(it.id) } 30 | .map { 31 | FullCocktail.Tag( 32 | id = it.id, 33 | name = it.name, 34 | ) 35 | } 36 | 37 | val glassware = snapshot.glassware.find { it.id == cocktail.glassware } 38 | ?: error("Cannot found glassware for cocktail") 39 | 40 | return FullCocktail( 41 | id = cocktail.id, 42 | name = cocktail.name, 43 | receipt = cocktail.receipt, 44 | tools = tools, 45 | tags = tags, 46 | tastes = tastes, 47 | glassware = FullCocktail.Glassware(id = glassware.id, name = glassware.name), 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/main/MainContent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.main 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children 7 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.slide 8 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation 9 | import org.mixdrinks.ui.details.DetailView 10 | import org.mixdrinks.ui.filters.main.FilterView 11 | import org.mixdrinks.ui.filters.search.SearchItemView 12 | import org.mixdrinks.ui.items.ItemDetailsView 13 | import org.mixdrinks.ui.list.main.AllCocktailsPage 14 | import org.mixdrinks.ui.tag.TagCocktails 15 | 16 | @Composable 17 | internal fun MainContent(component: MainComponent, deepLink: String?) { 18 | Box { 19 | Children( 20 | stack = component.stack, 21 | animation = stackAnimation( 22 | animator = slide() 23 | ), 24 | content = { 25 | when (val child = it.instance) { 26 | is MainComponent.Child.List -> AllCocktailsPage(child.component) 27 | is MainComponent.Child.Item -> ItemDetailsView(child.component) 28 | is MainComponent.Child.Details -> DetailView(child.component) 29 | is MainComponent.Child.Filters -> FilterView(child.component) 30 | is MainComponent.Child.ItemSearch -> SearchItemView(child.component) 31 | is MainComponent.Child.CommonTagCocktails -> TagCocktails(child.component) 32 | } 33 | } 34 | ) 35 | } 36 | LaunchedEffect(deepLink) { 37 | if (deepLink != null) { 38 | component.onDeepLink(deepLink) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/items/ItemGoodsRepository.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.items 2 | 3 | import org.mixdrinks.data.DetailGoodsUiModel 4 | import org.mixdrinks.data.SnapshotRepository 5 | import org.mixdrinks.domain.ImageUrlCreators 6 | import org.mixdrinks.dto.GlasswareId 7 | import org.mixdrinks.dto.GoodId 8 | import org.mixdrinks.dto.ToolId 9 | 10 | 11 | internal class ItemGoodsRepository( 12 | private val snapshot: SnapshotRepository, 13 | ) { 14 | suspend fun getGoodDetails(goodId: GoodId): DetailGoodsUiModel { 15 | val good = snapshot.get().goods.find { it.id.id == goodId.id } 16 | ?: error("Goods ${goodId.id} not found") 17 | return DetailGoodsUiModel( 18 | good.id.id, good.name, good.about, 19 | ImageUrlCreators.createUrl( 20 | good.id, 21 | ImageUrlCreators.Size.SIZE_320 22 | ) 23 | ) 24 | } 25 | 26 | suspend fun getToolDetails(toolId: ToolId): DetailGoodsUiModel { 27 | val tool = snapshot.get().tools.find { it.id.id == toolId.id } 28 | ?: error("Tool ${toolId.id} not found") 29 | return DetailGoodsUiModel( 30 | tool.id.id, tool.name, tool.about, 31 | ImageUrlCreators.createUrl( 32 | tool.id, 33 | ImageUrlCreators.Size.SIZE_320 34 | ) 35 | ) 36 | } 37 | 38 | suspend fun getGlasswareDetails(glasswareId: GlasswareId): DetailGoodsUiModel { 39 | val glassware = snapshot.get().glassware.find { it.id == glasswareId } 40 | ?: error("Glassware $glasswareId not found") 41 | return DetailGoodsUiModel( 42 | glassware.id.value, glassware.name, glassware.about, 43 | ImageUrlCreators.createUrl( 44 | glassware.id, 45 | ImageUrlCreators.Size.SIZE_320 46 | ) 47 | ) 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/items/ItemDetailComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.items 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.flow.flowOn 8 | import kotlinx.coroutines.flow.map 9 | import org.mixdrinks.data.DetailGoodsUiModel 10 | import org.mixdrinks.data.ItemsType 11 | import org.mixdrinks.dto.GlasswareId 12 | import org.mixdrinks.dto.GoodId 13 | import org.mixdrinks.dto.ToolId 14 | import org.mixdrinks.ui.list.predefine.PreDefineCocktailsComponent 15 | import org.mixdrinks.ui.navigation.INavigator 16 | import org.mixdrinks.ui.widgets.undomain.UiState 17 | import org.mixdrinks.ui.widgets.undomain.stateInWhileSubscribe 18 | 19 | internal class ItemDetailComponent( 20 | private val componentContext: ComponentContext, 21 | private val goodsRepository: ItemGoodsRepository, 22 | private val itemDetailsNavigation: ItemDetailsNavigation, 23 | private val itemsType: ItemsType, 24 | public val predefineCocktailComponent: PreDefineCocktailsComponent, 25 | ) : ComponentContext by componentContext, 26 | ItemDetailsNavigation by itemDetailsNavigation { 27 | 28 | val state: StateFlow> = when (itemsType.type) { 29 | ItemsType.Type.GOODS -> flow { 30 | emit(goodsRepository.getGoodDetails(GoodId(itemsType.id))) 31 | } 32 | 33 | ItemsType.Type.TOOL -> flow { 34 | emit(goodsRepository.getToolDetails(ToolId(itemsType.id))) 35 | } 36 | 37 | ItemsType.Type.GLASSWARE -> flow { 38 | emit(goodsRepository.getGlasswareDetails(GlasswareId(itemsType.id))) 39 | } 40 | } 41 | .map { good: DetailGoodsUiModel -> 42 | UiState.Data(good) 43 | } 44 | .flowOn(Dispatchers.Default) 45 | .stateInWhileSubscribe() 46 | } 47 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/ProfileNavigator.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile 2 | 3 | import com.arkivanov.decompose.router.stack.StackNavigation 4 | import com.arkivanov.decompose.router.stack.pop 5 | import com.arkivanov.decompose.router.stack.push 6 | import org.mixdrinks.data.ItemsType 7 | import org.mixdrinks.dto.CocktailId 8 | import org.mixdrinks.dto.TagId 9 | import org.mixdrinks.dto.TasteId 10 | import org.mixdrinks.ui.details.CocktailsDetailNavigation 11 | import org.mixdrinks.ui.items.ItemDetailsNavigation 12 | import org.mixdrinks.ui.profile.root.ProfileRootNavigation 13 | import org.mixdrinks.ui.tag.CommonTag 14 | import org.mixdrinks.ui.tag.CommonTagNavigation 15 | import org.mixdrinks.ui.visited.VisitedCocktailsNavigation 16 | 17 | internal class ProfileNavigator( 18 | private val stackNavigation: StackNavigation, 19 | ) : VisitedCocktailsNavigation, 20 | CommonTagNavigation, 21 | CocktailsDetailNavigation, 22 | ItemDetailsNavigation, 23 | ProfileRootNavigation { 24 | 25 | override fun navigateToDetails(cocktailId: CocktailId) { 26 | stackNavigation.push(ProfileComponent.ProfileContentConfig.DetailsConfig(cocktailId.id)) 27 | } 28 | 29 | override fun navigateToTagCocktails(tagId: TagId) { 30 | stackNavigation.push(ProfileComponent.ProfileContentConfig.CommonTagConfig(tagId.id, CommonTag.Type.TAG)) 31 | } 32 | 33 | override fun navigationToTasteCocktails(tasteId: TasteId) { 34 | stackNavigation.push(ProfileComponent.ProfileContentConfig.CommonTagConfig(tasteId.id, CommonTag.Type.TASTE)) 35 | } 36 | 37 | override fun navigateToItem(itemsType: ItemsType.Type, id: Int) { 38 | stackNavigation.push(ProfileComponent.ProfileContentConfig.ItemConfig(id, itemsType.name)) 39 | } 40 | 41 | override fun back() { 42 | stackNavigation.pop() 43 | } 44 | 45 | override fun navigateToVisitedCocktails() { 46 | stackNavigation.push(ProfileComponent.ProfileContentConfig.VisitedCocktailsConfig()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/navigation/DeepLinkParser.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.navigation 2 | 3 | import io.ktor.http.Url 4 | import org.mixdrinks.domain.FilterGroups 5 | import org.mixdrinks.domain.FilterPathParser 6 | import org.mixdrinks.dto.FilterId 7 | import org.mixdrinks.dto.SnapshotDto 8 | 9 | internal class DeepLinkParser( 10 | private val snapshot: suspend () -> SnapshotDto, 11 | private val filterPathParser: FilterPathParser, 12 | ) { 13 | 14 | sealed class DeepLinkAction { 15 | data class Cocktail(val id: Int) : DeepLinkAction() 16 | data class Filters(val selectedFilters: Map>) : 17 | DeepLinkAction() 18 | } 19 | 20 | suspend fun parseDeepLink(link: String): DeepLinkAction? { 21 | val cleanPath = Url(link).encodedPath.removePrefix("/") 22 | return when { 23 | cleanPath.startsWith("cocktails/") -> { 24 | val cocktailSlug = cleanPath.removePrefix("cocktails/") 25 | val cocktailId = 26 | snapshot().cocktails.find { it.slug == cocktailSlug }?.id?.id ?: return null 27 | DeepLinkAction.Cocktail(cocktailId) 28 | } 29 | 30 | cleanPath.isFilterGroup() -> { 31 | val filters = filterPathParser.parse(cleanPath) 32 | 33 | val resultFilters = filters.mapNotNull { (group, filterSlugs) -> 34 | val filterGroupFromSnapshot = 35 | snapshot().filterGroups.find { it.id == group.id } ?: return@mapNotNull null 36 | 37 | group to filterGroupFromSnapshot.filters 38 | .filter { filterSlugs.contains(it.slug) } 39 | .map { it.id } 40 | }.toMap() 41 | 42 | DeepLinkAction.Filters(resultFilters) 43 | } 44 | 45 | else -> null 46 | } 47 | } 48 | 49 | private fun String.isFilterGroup(): Boolean { 50 | return FilterGroups.values().find { this.startsWith(it.queryName.value) } != null 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /androidApp/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "18528209355", 4 | "project_id": "mixdrinks-bb828", 5 | "storage_bucket": "mixdrinks-bb828.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:18528209355:android:5c2687eb3bada359e5ce4e", 11 | "android_client_info": { 12 | "package_name": "org.mixdrinks.app" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "18528209355-ei9b3v6gkcr9rjk29lspvdpfs5e7uvq0.apps.googleusercontent.com", 18 | "client_type": 1, 19 | "android_info": { 20 | "package_name": "org.mixdrinks.app", 21 | "certificate_hash": "391bf5ea50bf4d137b995aec1505aecaf21cb999" 22 | } 23 | }, 24 | { 25 | "client_id": "18528209355-nfp25i6n0pn8n98099b04q93plcsgre1.apps.googleusercontent.com", 26 | "client_type": 1, 27 | "android_info": { 28 | "package_name": "org.mixdrinks.app", 29 | "certificate_hash": "39fc6fcdbfcc7a5cdeb112ca87d8089112b82fed" 30 | } 31 | }, 32 | { 33 | "client_id": "18528209355-f88aa33m72rshqp56b2so77ujnm7s32d.apps.googleusercontent.com", 34 | "client_type": 3 35 | } 36 | ], 37 | "api_key": [ 38 | { 39 | "current_key": "AIzaSyB_ICk516QsqWbQdogKkt_9B6V_-lmOgTg" 40 | } 41 | ], 42 | "services": { 43 | "appinvite_service": { 44 | "other_platform_oauth_client": [ 45 | { 46 | "client_id": "18528209355-8o18kp2sabqr0928mkp0icaatiu265v3.apps.googleusercontent.com", 47 | "client_type": 3 48 | }, 49 | { 50 | "client_id": "18528209355-27v672k8ptniriie05l143938toknieo.apps.googleusercontent.com", 51 | "client_type": 2, 52 | "ios_info": { 53 | "bundle_id": "org.mixdrinks.app" 54 | } 55 | } 56 | ] 57 | } 58 | } 59 | } 60 | ], 61 | "configuration_version": "1" 62 | } -------------------------------------------------------------------------------- /shared/src/commonMain/resources/ic_google.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 24 | 30 | 36 | 37 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/tag/CommonTagCocktailsComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.tag 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.SharingStarted 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.distinctUntilChanged 9 | import kotlinx.coroutines.flow.emitAll 10 | import kotlinx.coroutines.flow.flow 11 | import kotlinx.coroutines.flow.flowOn 12 | import kotlinx.coroutines.flow.map 13 | import kotlinx.coroutines.flow.stateIn 14 | import org.mixdrinks.data.CocktailsProvider 15 | import org.mixdrinks.ui.list.CocktailListMapper 16 | import org.mixdrinks.ui.list.CocktailsListState 17 | import org.mixdrinks.ui.widgets.undomain.scope 18 | 19 | internal class CommonTagCocktailsComponent( 20 | private val componentContext: ComponentContext, 21 | private val commonTagNameProvider: CommonTagNameProvider, 22 | private val cocktailsProvider: CocktailsProvider, 23 | private val commonTag: CommonTag, 24 | private val profileNavigator: CommonTagNavigation, 25 | private val commonCocktailListMapper: CocktailListMapper, 26 | ) : ComponentContext by componentContext, 27 | CommonTagNavigation by profileNavigator { 28 | 29 | val name: StateFlow = flow { 30 | emit(commonTagNameProvider.getName(commonTag) ?: "") 31 | } 32 | .map { 33 | "Коктейлі $it" 34 | } 35 | .stateIn( 36 | scope = componentContext.scope, 37 | started = SharingStarted.WhileSubscribed(), 38 | initialValue = "" 39 | ) 40 | 41 | val state: StateFlow = flow { 42 | emitAll(cocktailsProvider.getCocktails().map { cocktails -> 43 | CocktailsListState.Cocktails(commonCocktailListMapper.map(cocktails)) 44 | }) 45 | } 46 | .flowOn(Dispatchers.Default) 47 | .distinctUntilChanged() 48 | .stateIn( 49 | CoroutineScope(Dispatchers.Main), 50 | SharingStarted.WhileSubscribed(), 51 | CocktailsListState.Cocktails(emptyList()) 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/filters/search/ItemRepository.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.filters.search 2 | 3 | import org.mixdrinks.data.FutureCocktailSelector 4 | import org.mixdrinks.data.SnapshotRepository 5 | import org.mixdrinks.domain.FilterGroups 6 | import org.mixdrinks.dto.FilterId 7 | import org.mixdrinks.dto.GoodId 8 | import org.mixdrinks.dto.SnapshotDto 9 | import org.mixdrinks.dto.ToolId 10 | 11 | internal class ItemRepository( 12 | private val snapshotRepository: SnapshotRepository, 13 | private val futureCocktailSelector: FutureCocktailSelector, 14 | ) { 15 | 16 | suspend fun getItems(searchItemType: SearchItemComponent.SearchItemType): List { 17 | return when (searchItemType) { 18 | SearchItemComponent.SearchItemType.GOODS -> getGoods() 19 | SearchItemComponent.SearchItemType.TOOLS -> getTools() 20 | } 21 | } 22 | 23 | private suspend fun getTools(): List { 24 | return snapshotRepository.get().tools 25 | .map { 26 | ItemDto( 27 | id = ItemId.Tool(it.id), 28 | name = it.name, 29 | cocktailCount = futureCocktailSelector.getCocktailIds( 30 | FilterGroups.TOOLS.id, 31 | FilterId(it.id.id), 32 | ).size, 33 | ) 34 | } 35 | } 36 | 37 | private suspend fun getGoods(): List { 38 | return snapshotRepository.get().goods 39 | .map { 40 | ItemDto( 41 | id = ItemId.Good(it.id), 42 | name = it.name, 43 | cocktailCount = futureCocktailSelector.getCocktailIds( 44 | FilterGroups.GOODS.id, 45 | FilterId(it.id.id), 46 | ).size, 47 | ) 48 | } 49 | } 50 | 51 | sealed class ItemId(val value: Int) { 52 | data class Good(val id: GoodId) : ItemId(id.id) 53 | data class Tool(val id: ToolId) : ItemId(id.id) 54 | } 55 | 56 | data class ItemDto( 57 | val id: ItemId, 58 | val name: String, 59 | val cocktailCount: Int, 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/SnapshotRepository.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import com.russhwolf.settings.Settings 4 | import com.russhwolf.settings.set 5 | import kotlinx.coroutines.CoroutineScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.filterNotNull 9 | import kotlinx.coroutines.flow.first 10 | import kotlinx.coroutines.launch 11 | import kotlinx.serialization.encodeToString 12 | import kotlinx.serialization.json.Json 13 | import org.mixdrinks.dto.SnapshotDto 14 | 15 | 16 | internal class SnapshotRepository( 17 | private val mixDrinksService: MixDrinksService, 18 | private val settings: Settings, 19 | private val json: Json, 20 | ) { 21 | 22 | private val flowSnapshot: MutableStateFlow = MutableStateFlow(null) 23 | 24 | init { 25 | CoroutineScope(Dispatchers.Main).launch { 26 | settings.getStringOrNull(SNAPSHOT_KEY)?.let { cachedSnapshotStr -> 27 | try { 28 | flowSnapshot.emit(json.decodeFromString(cachedSnapshotStr)) 29 | } catch (_: Exception) { 30 | updateSnapshot() 31 | } 32 | } 33 | 34 | val currentVersion = settings.getInt(SNAPSHOT_VERSION_KEY, -1) 35 | val newVersion = mixDrinksService.getVersion().versionCode 36 | if (currentVersion != newVersion) { 37 | updateSnapshot() 38 | settings[SNAPSHOT_VERSION_KEY] = newVersion 39 | } 40 | } 41 | } 42 | 43 | private suspend fun updateSnapshot() { 44 | try { 45 | println("Update snapshot") 46 | val snapshot = mixDrinksService.getSnapshot() 47 | settings[SNAPSHOT_KEY] = json.encodeToString(snapshot) 48 | flowSnapshot.emit(snapshot) 49 | } catch (_: Exception) { 50 | 51 | } 52 | } 53 | 54 | suspend fun get(): SnapshotDto { 55 | return flowSnapshot.filterNotNull().first() 56 | } 57 | 58 | companion object { 59 | private const val SNAPSHOT_KEY = "SNAPSHOT_KEY" 60 | private const val SNAPSHOT_VERSION_KEY = "SNAPSHOT_VERSION_KEY" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /iosApp/iosApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleURLTypes 20 | 21 | 22 | CFBundleTypeRole 23 | Editor 24 | CFBundleURLSchemes 25 | 26 | com.googleusercontent.apps.18528209355-27v672k8ptniriie05l143938toknieo 27 | 28 | 29 | 30 | CFBundleTypeRole 31 | Editor 32 | CFBundleURLSchemes 33 | 34 | com.googleusercontent.apps.18528209355-huqbcrn38tkgv9g1kvpt1l5r24jlt29v 35 | 36 | 37 | 38 | CFBundleVersion 39 | 1 40 | LSRequiresIPhoneOS 41 | 42 | UIApplicationSceneManifest 43 | 44 | UIApplicationSupportsMultipleScenes 45 | 46 | 47 | UILaunchScreen 48 | 49 | UIRequiredDeviceCapabilities 50 | 51 | armv7 52 | 53 | UISupportedInterfaceOrientations 54 | 55 | UIInterfaceOrientationPortrait 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | UISupportedInterfaceOrientations~ipad 60 | 61 | UIInterfaceOrientationPortrait 62 | UIInterfaceOrientationPortraitUpsideDown 63 | UIInterfaceOrientationLandscapeLeft 64 | UIInterfaceOrientationLandscapeRight 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/goods/GoodsSubComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details.goods 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.MutableStateFlow 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.combine 8 | import kotlinx.coroutines.flow.flow 9 | import kotlinx.coroutines.flow.flowOn 10 | import org.mixdrinks.domain.ImageUrlCreators 11 | import org.mixdrinks.dto.CocktailId 12 | import org.mixdrinks.dto.GoodId 13 | import org.mixdrinks.ui.widgets.undomain.UiState 14 | import org.mixdrinks.ui.widgets.undomain.stateInWhileSubscribe 15 | 16 | internal class GoodsSubComponent( 17 | private val componentContext: ComponentContext, 18 | private val goodsRepository: GoodsRepository, 19 | private val cocktailId: CocktailId, 20 | ) : ComponentContext by componentContext { 21 | 22 | private val _counter = MutableStateFlow(1) 23 | 24 | val state: StateFlow> = flow { 25 | emit(goodsRepository.getGoods(cocktailId)) 26 | } 27 | .combine(_counter) { goods: List, count: Int -> 28 | UiState.Data( 29 | GoodsUi( 30 | count = count, 31 | goods = goods.map { good -> 32 | GoodUi( 33 | goodId = good.goodId, 34 | url = ImageUrlCreators.createUrl(good.goodId, ImageUrlCreators.Size.SIZE_400), 35 | name = good.name, 36 | amount = "${good.amount * count} ${good.unit}" 37 | ) 38 | } 39 | ) 40 | ) 41 | } 42 | .flowOn(Dispatchers.Default) 43 | .stateInWhileSubscribe() 44 | 45 | fun onPlusClick() { 46 | _counter.value++ 47 | } 48 | 49 | fun onMinusClick() { 50 | if (_counter.value > 1) { 51 | _counter.value-- 52 | } 53 | } 54 | 55 | data class GoodsUi( 56 | val count: Int, 57 | val goods: List, 58 | ) 59 | 60 | data class GoodUi( 61 | val goodId: GoodId, 62 | val url: String, 63 | val name: String, 64 | val amount: String, 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/widgets/Header.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.widgets 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.material.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.unit.dp 17 | import org.jetbrains.compose.resources.ExperimentalResourceApi 18 | import org.jetbrains.compose.resources.painterResource 19 | import org.mixdrinks.app.styles.MixDrinksColors 20 | import org.mixdrinks.app.styles.MixDrinksTextStyles 21 | 22 | @OptIn(ExperimentalResourceApi::class) 23 | @Composable 24 | internal fun MixDrinksHeader(name: String, onBackClick: (() -> Unit)? = null) { 25 | Row( 26 | modifier = Modifier 27 | .background(MixDrinksColors.Main) 28 | .fillMaxWidth() 29 | .height(52.dp), 30 | ) { 31 | if (onBackClick != null) { 32 | Box( 33 | modifier = Modifier.size(52.dp) 34 | .clickable { 35 | onBackClick() 36 | } 37 | ) { 38 | Image( 39 | modifier = Modifier 40 | .align(Alignment.Center) 41 | .size(32.dp) 42 | .padding(start = 12.dp), 43 | painter = painterResource("ic_arrow_back.xml"), 44 | contentDescription = "Назад" 45 | ) 46 | } 47 | } 48 | val padding = if (onBackClick == null) 16.dp else 4.dp 49 | Text( 50 | modifier = Modifier.padding(start = padding) 51 | .align(Alignment.CenterVertically), 52 | color = MixDrinksColors.White, 53 | text = name, 54 | style = MixDrinksTextStyles.H2, 55 | softWrap = false, 56 | maxLines = 1, 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/visited/VisititedCocktailsContent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.visited 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxSize 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.lazy.LazyColumn 8 | import androidx.compose.material.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.text.style.TextAlign 13 | import androidx.compose.ui.unit.dp 14 | import org.mixdrinks.app.styles.MixDrinksTextStyles 15 | import org.mixdrinks.app.utils.ResString 16 | import org.mixdrinks.ui.list.cocktailListInserter 17 | import org.mixdrinks.ui.widgets.MixDrinksHeader 18 | import org.mixdrinks.ui.widgets.undomain.ContentHolder 19 | 20 | @Composable 21 | internal fun VisitedCocktailsContent(component: VisitedCocktailsComponent) { 22 | Column { 23 | MixDrinksHeader( 24 | name = ResString.visitedCocktails, 25 | onBackClick = { component.back() } 26 | ) 27 | 28 | ContentHolder( 29 | stateflow = component.state, 30 | ) { cocktails -> 31 | when (cocktails) { 32 | is VisitedCocktailsComponent.VisitedCocktailList.Cocktails -> { 33 | LazyColumn { 34 | cocktailListInserter( 35 | cocktails = cocktails.cocktails, 36 | onClick = component::navigateToDetails, 37 | onTagClick = component::navigateToTagCocktails, 38 | trackingScreen = "page_visited_cocktails", 39 | ) 40 | } 41 | } 42 | 43 | VisitedCocktailsComponent.VisitedCocktailList.Empty -> Box( 44 | modifier = Modifier.fillMaxSize() 45 | ) { 46 | Text( 47 | modifier = Modifier 48 | .padding(16.dp) 49 | .align(Alignment.Center), 50 | textAlign = TextAlign.Center, 51 | text = "Тут будуть переглянуті вами коктейлі", 52 | style = MixDrinksTextStyles.H1, 53 | ) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /shared/src/iosMain/kotlin/main.ios.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("WildcardImport") 2 | import androidx.compose.foundation.layout.fillMaxSize 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.ArrowBack 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.window.ComposeUIViewController 7 | import cocoapods.FirebaseAnalytics.* 8 | import com.arkivanov.decompose.DefaultComponentContext 9 | import com.arkivanov.decompose.ExperimentalDecomposeApi 10 | import com.arkivanov.decompose.extensions.compose.jetbrains.PredictiveBackGestureIcon 11 | import com.arkivanov.decompose.extensions.compose.jetbrains.PredictiveBackGestureOverlay 12 | import com.arkivanov.essenty.backhandler.BackDispatcher 13 | import com.arkivanov.essenty.lifecycle.LifecycleRegistry 14 | import org.mixdrinks.app.MixDrinksApp 15 | import org.mixdrinks.di.GraphHolder 16 | import org.mixdrinks.ui.auth.AuthCallbacks 17 | import platform.UIKit.UIViewController 18 | 19 | @OptIn(ExperimentalDecomposeApi::class) 20 | @Suppress("FunctionNaming") 21 | fun MainViewController(): UIViewController { 22 | val backDispatcher = BackDispatcher() 23 | val componentContext = 24 | DefaultComponentContext( 25 | lifecycle = LifecycleRegistry(), 26 | backHandler = backDispatcher, 27 | ) 28 | 29 | return ComposeUIViewController { 30 | PredictiveBackGestureOverlay( 31 | modifier = Modifier.fillMaxSize(), 32 | backDispatcher = backDispatcher, 33 | backIcon = { progress, _ -> 34 | PredictiveBackGestureIcon( 35 | imageVector = Icons.Default.ArrowBack, 36 | progress = progress, 37 | ) 38 | } 39 | ) { 40 | MixDrinksApp(componentContext, null) 41 | } 42 | } 43 | } 44 | 45 | actual fun trackEvent(action: String, data: Map) { 46 | FIRAnalytics.logEventWithName(action, data as Map?) 47 | } 48 | 49 | @Suppress("FunctionNaming") 50 | fun NewToken(token: String) { 51 | GraphHolder.graph.tokenStorage.setToken(token) 52 | } 53 | 54 | fun setLogout(block: () -> Unit) { 55 | AuthCallbacks.logout = { 56 | block() 57 | } 58 | } 59 | 60 | fun setGoogleAuthStart(block: () -> Unit) { 61 | AuthCallbacks.googleAuthStart = { 62 | block() 63 | } 64 | } 65 | 66 | fun setAppleAuthStart(block: () -> Unit) { 67 | AuthCallbacks.appleAuthStart = { 68 | block() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/di/Graph.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.di 2 | 3 | import com.russhwolf.settings.Settings 4 | import de.jensklingenberg.ktorfit.Ktorfit 5 | import io.ktor.client.HttpClient 6 | import io.ktor.client.plugins.auth.Auth 7 | import io.ktor.client.plugins.auth.providers.BearerTokens 8 | import io.ktor.client.plugins.auth.providers.bearer 9 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation 10 | import io.ktor.client.plugins.logging.Logging 11 | import io.ktor.serialization.kotlinx.json.json 12 | import kotlinx.serialization.json.Json 13 | import org.mixdrinks.data.MixDrinksService 14 | import org.mixdrinks.data.SnapshotRepository 15 | import org.mixdrinks.ui.auth.AuthBus 16 | import org.mixdrinks.ui.auth.TokenStorage 17 | import org.mixdrinks.ui.list.main.MutableFilterStorage 18 | import org.mixdrinks.ui.profile.root.DeleteAccountService 19 | import org.mixdrinks.ui.visited.UserVisitedCocktailsService 20 | 21 | internal class Graph { 22 | 23 | private val baseUrl = "https://api.mixdrinks.org/" 24 | 25 | init { 26 | GraphHolder.graph = this 27 | } 28 | 29 | private val settings: Settings = Settings() 30 | 31 | private val json = Json { 32 | isLenient = true 33 | ignoreUnknownKeys = true 34 | } 35 | 36 | private val snapshotService = Ktorfit.Builder() 37 | .httpClient(HttpClient { 38 | install(ContentNegotiation) { 39 | json(json) 40 | } 41 | }) 42 | .baseUrl(baseUrl) 43 | .build() 44 | .create() 45 | 46 | val tokenStorage = TokenStorage(settings) 47 | 48 | private val httpClient = HttpClient { 49 | install(Logging) 50 | install(ContentNegotiation) { 51 | json(json) 52 | } 53 | install(Auth) { 54 | bearer { 55 | loadTokens { BearerTokens(tokenStorage.getToken() ?: "", "") } 56 | } 57 | } 58 | } 59 | 60 | private val ktorfit = Ktorfit.Builder() 61 | .httpClient(httpClient) 62 | .baseUrl(baseUrl) 63 | .build() 64 | 65 | val visitedCocktailsService = ktorfit 66 | .create() 67 | 68 | val deleteAccountService = ktorfit 69 | .create() 70 | 71 | val snapshotRepository: SnapshotRepository = SnapshotRepository(snapshotService, settings, json) 72 | 73 | val mutableFilterStorage = MutableFilterStorage { snapshotRepository.get() } 74 | 75 | val authBus = AuthBus(tokenStorage) 76 | 77 | } 78 | 79 | internal object GraphHolder { 80 | lateinit var graph: Graph 81 | } 82 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/data/CocktailsProvider.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.data 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.coroutines.flow.map 5 | import org.mixdrinks.domain.CocktailSelector 6 | import org.mixdrinks.domain.ImageUrlCreators 7 | import org.mixdrinks.dto.CocktailId 8 | import org.mixdrinks.dto.TagDto 9 | import org.mixdrinks.ui.list.FilterObserver 10 | 11 | internal class CocktailsProvider( 12 | private val snapshotRepository: SnapshotRepository, 13 | private val filterRepository: FilterObserver, 14 | private val cocktailSelector: suspend () -> CocktailSelector, 15 | private val tagsRepository: TagsRepository, 16 | ) { 17 | 18 | data class Cocktail( 19 | val id: CocktailId, 20 | val url: String, 21 | val name: String, 22 | val tags: List, 23 | ) 24 | 25 | suspend fun getCocktails(): Flow> { 26 | return filterRepository.selected.map { 27 | val notEmptyFilter = 28 | it.filter { filterGroup -> filterGroup.value.isNotEmpty() } 29 | if (notEmptyFilter.isEmpty()) { 30 | snapshotRepository.get().cocktails 31 | } else { 32 | val notEmptyFilterIds = notEmptyFilter 33 | .mapValues { filterGroupIdListEntry -> filterGroupIdListEntry.value.map { it.filterId } } 34 | 35 | val ids = cocktailSelector().getCocktailIds(notEmptyFilterIds) 36 | snapshotRepository.get().cocktails 37 | .filter { cocktailDto -> ids.contains(cocktailDto.id) } 38 | } 39 | .map { cocktailDto -> 40 | Cocktail( 41 | id = cocktailDto.id, 42 | url = ImageUrlCreators.createUrl( 43 | cocktailDto.id, 44 | ImageUrlCreators.Size.SIZE_400 45 | ), 46 | name = cocktailDto.name, 47 | tags = tagsRepository.getTags(cocktailDto.tags) 48 | ) 49 | } 50 | } 51 | } 52 | 53 | suspend fun getAllCocktails(): List { 54 | return snapshotRepository.get().cocktails 55 | .map { cocktailDto -> 56 | Cocktail( 57 | id = cocktailDto.id, 58 | url = ImageUrlCreators.createUrl( 59 | cocktailDto.id, 60 | ImageUrlCreators.Size.SIZE_400 61 | ), 62 | name = cocktailDto.name, 63 | tags = tagsRepository.getTags(cocktailDto.tags) 64 | ) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /androidApp/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnusedPrivateMember") 2 | 3 | plugins { 4 | kotlin("multiplatform") 5 | id("com.android.application") 6 | id("org.jetbrains.compose") 7 | id("com.google.gms.google-services") 8 | id("com.google.firebase.crashlytics") 9 | } 10 | 11 | kotlin { 12 | android() 13 | sourceSets { 14 | val androidMain by getting { 15 | dependencies { 16 | implementation(project(":shared")) 17 | implementation("com.google.android.gms:play-services-auth:20.6.0") 18 | implementation(platform("com.google.firebase:firebase-bom:32.1.1")) 19 | implementation("com.google.firebase:firebase-auth-ktx") 20 | implementation("com.google.firebase:firebase-crashlytics-ktx") 21 | implementation("com.google.firebase:firebase-analytics-ktx") 22 | } 23 | } 24 | } 25 | } 26 | 27 | android { 28 | compileSdk = (findProperty("android.compileSdk") as String).toInt() 29 | namespace = "org.mixdrinks" 30 | 31 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 32 | 33 | defaultConfig { 34 | applicationId = "org.mixdrinks.app" 35 | minSdk = (findProperty("android.minSdk") as String).toInt() 36 | targetSdk = (findProperty("android.targetSdk") as String).toInt() 37 | versionCode = System.getenv("MIXDRINKS_MOBILE_APP_VERSION_CODE")?.toIntOrNull() ?: 1 38 | versionName = System.getenv("MIXDRINKS_MOBILE_APP_VERSION_NAME") ?: "0.0.1" 39 | } 40 | buildTypes { 41 | getByName("release") { 42 | isMinifyEnabled = true 43 | isShrinkResources = true 44 | this.resValue("string", "app_name", "MixDrinks") 45 | proguardFiles( 46 | getDefaultProguardFile("proguard-android-optimize.txt"), 47 | "proguard-rules.pro" 48 | ) 49 | } 50 | getByName("debug") { 51 | isDebuggable = true 52 | isMinifyEnabled = true 53 | isShrinkResources = true 54 | this.resValue("string", "app_name", "MixDrinks") 55 | 56 | proguardFiles( 57 | getDefaultProguardFile("proguard-android-optimize.txt"), 58 | "proguard-rules.pro" 59 | ) 60 | } 61 | } 62 | compileOptions { 63 | sourceCompatibility = JavaVersion.VERSION_11 64 | targetCompatibility = JavaVersion.VERSION_11 65 | } 66 | kotlin { 67 | jvmToolchain(11) 68 | } 69 | } 70 | 71 | dependencies { 72 | implementation(platform("com.google.firebase:firebase-bom:32.1.1")) 73 | implementation("com.google.firebase:firebase-analytics-ktx") 74 | } 75 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/visited/VisitedCocktailsComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.visited 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.flow.StateFlow 6 | import kotlinx.coroutines.flow.flow 7 | import kotlinx.coroutines.flow.flowOn 8 | import org.mixdrinks.data.CocktailsProvider 9 | import org.mixdrinks.data.SnapshotRepository 10 | import org.mixdrinks.data.TagsRepository 11 | import org.mixdrinks.domain.ImageUrlCreators 12 | import org.mixdrinks.dto.CocktailId 13 | import org.mixdrinks.ui.list.CocktailListMapper 14 | import org.mixdrinks.ui.list.CocktailsListState 15 | import org.mixdrinks.ui.widgets.undomain.UiState 16 | import org.mixdrinks.ui.widgets.undomain.stateInWhileSubscribe 17 | 18 | @Suppress("LongParameterList") 19 | internal class VisitedCocktailsComponent( 20 | private val componentContext: ComponentContext, 21 | private val visitedCocktailsService: UserVisitedCocktailsService, 22 | private val snapshotRepository: SnapshotRepository, 23 | private val commonCocktailListMapper: CocktailListMapper, 24 | private val tagsRepository: TagsRepository, 25 | private val visitedCocktailsNavigation: VisitedCocktailsNavigation, 26 | ) : ComponentContext by componentContext, 27 | VisitedCocktailsNavigation by visitedCocktailsNavigation { 28 | 29 | val state: StateFlow> = flow { 30 | emit(UiState.Loading) 31 | val result = authExecutor { visitedCocktailsService.getVisitedCocktails() } 32 | 33 | result.onSuccess { cocktailIds -> 34 | if (cocktailIds.isEmpty()) { 35 | emit(UiState.Data(VisitedCocktailList.Empty)) 36 | } else { 37 | emit(UiState.Data(VisitedCocktailList.Cocktails(getCocktailsByIds(cocktailIds.map { it.id })))) 38 | } 39 | } 40 | } 41 | .flowOn(Dispatchers.Main) 42 | .stateInWhileSubscribe() 43 | 44 | private suspend fun getCocktailsByIds(ids: List): List { 45 | val cocktails = snapshotRepository.get().cocktails 46 | .filter { cocktailDto -> ids.contains(cocktailDto.id) } 47 | .sortedBy { cocktailDto -> ids.indexOf(cocktailDto.id) } 48 | .map { cocktailDto -> 49 | CocktailsProvider.Cocktail( 50 | id = cocktailDto.id, 51 | url = ImageUrlCreators.createUrl( 52 | cocktailDto.id, 53 | ImageUrlCreators.Size.SIZE_400 54 | ), 55 | name = cocktailDto.name, 56 | tags = tagsRepository.getTags(cocktailDto.tags) 57 | ) 58 | } 59 | 60 | return commonCocktailListMapper.map(cocktails) 61 | } 62 | 63 | sealed class VisitedCocktailList { 64 | data class Cocktails(val cocktails: List) : VisitedCocktailList() 65 | 66 | data object Empty : VisitedCocktailList() 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/main/MutableFilterStorage.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list.main 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.SupervisorJob 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.launch 9 | import org.mixdrinks.domain.FilterGroups 10 | import org.mixdrinks.dto.FilterGroupDto 11 | import org.mixdrinks.dto.FilterGroupId 12 | import org.mixdrinks.dto.FilterId 13 | import org.mixdrinks.dto.SelectionType 14 | import org.mixdrinks.dto.SnapshotDto 15 | import org.mixdrinks.ui.filters.FilterValueChangeDelegate 16 | import org.mixdrinks.ui.list.FilterObserver 17 | 18 | class MutableFilterStorage( 19 | private val snapshot: suspend () -> SnapshotDto, 20 | ) : FilterValueChangeDelegate, FilterObserver { 21 | 22 | private var operationCount: Long = 0 23 | 24 | data class FilterSelected( 25 | val filterId: FilterId, 26 | val operationIndex: Long, 27 | ) 28 | 29 | private val _selected = MutableStateFlow>>(mapOf()) 30 | override val selected: StateFlow>> = _selected 31 | 32 | override fun onFilterStateChange( 33 | filterGroupId: FilterGroupId, 34 | id: FilterId, 35 | isSelect: Boolean 36 | ) { 37 | CoroutineScope(Dispatchers.Main + SupervisorJob()).launch { 38 | onValueChange(filterGroupId, id, isSelect) 39 | } 40 | } 41 | 42 | suspend fun selectMany(newFilters: Map>) { 43 | val newMap = buildMap { 44 | newFilters.forEach { (filterGroupId, ids) -> 45 | this[filterGroupId.id] = ids.map { FilterSelected(it, operationCount++) } 46 | } 47 | } 48 | 49 | _selected.emit(newMap) 50 | } 51 | 52 | suspend fun onValueChange(filterGroupId: FilterGroupId, id: FilterId, isSelect: Boolean) { 53 | val copy = _selected.value.toMutableMap() 54 | 55 | val selectedFilters = copy.getOrPut(filterGroupId, ::listOf).toMutableList() 56 | if (isSelect) { 57 | if (getFilterSelectionType(filterGroupId) == SelectionType.SINGLE) { 58 | selectedFilters.clear() 59 | } 60 | selectedFilters.add(FilterSelected(id, operationCount++)) 61 | } else { 62 | selectedFilters.removeAll { it.filterId == id } 63 | } 64 | 65 | copy[filterGroupId] = selectedFilters 66 | 67 | _selected.emit(copy) 68 | } 69 | 70 | suspend fun clear() { 71 | _selected.emit(emptyMap()) 72 | } 73 | 74 | suspend fun getFilterGroups(): List { 75 | return snapshot().filterGroups 76 | } 77 | 78 | fun getSelectedFilters(): Map> { 79 | return _selected.value 80 | } 81 | 82 | private suspend fun getFilterSelectionType(filterGroupId: FilterGroupId): SelectionType { 83 | return snapshot().filterGroups.find { it.id == filterGroupId }?.selectionType 84 | ?: error("Cannot found filter group") 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/filters/FilterItem.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.filters 2 | 3 | import androidx.compose.animation.animateColor 4 | import androidx.compose.animation.core.updateTransition 5 | import androidx.compose.foundation.BorderStroke 6 | import androidx.compose.foundation.interaction.MutableInteractionSource 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.selection.toggleable 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.Card 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.Immutable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.remember 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.unit.dp 23 | import org.mixdrinks.app.styles.MixDrinksColors 24 | import org.mixdrinks.app.styles.MixDrinksTextStyles 25 | import org.mixdrinks.dto.FilterGroupId 26 | import org.mixdrinks.dto.FilterId 27 | 28 | @Composable 29 | internal fun FilterItem( 30 | modifier: Modifier = Modifier, 31 | filterUi: FilterItemUiModel, 32 | onValue: (FilterItemUiModel, Boolean) -> Unit, 33 | ) { 34 | val color = updateTransition(filterUi, label = "Checked indicator") 35 | 36 | val backgroundColor by color.animateColor( 37 | label = "BackgroundColor" 38 | ) { filter -> 39 | when { 40 | filter.isSelect -> MixDrinksColors.Main 41 | !filter.isEnable -> Color.Transparent 42 | else -> MixDrinksColors.White 43 | } 44 | } 45 | 46 | val textColor by color.animateColor( 47 | label = "TextColor" 48 | ) { filter -> 49 | when { 50 | filter.isSelect -> MixDrinksColors.White 51 | !filter.isEnable -> MixDrinksColors.Grey 52 | else -> MixDrinksColors.Main 53 | } 54 | } 55 | 56 | Card( 57 | modifier = modifier 58 | .height(32.dp), 59 | shape = RoundedCornerShape(16.dp), 60 | backgroundColor = backgroundColor, 61 | border = BorderStroke(1.dp, MixDrinksColors.Main) 62 | ) { 63 | Box( 64 | modifier = Modifier 65 | .toggleable( 66 | value = filterUi.isSelect, 67 | enabled = filterUi.isEnable, 68 | onValueChange = { onValue(filterUi, it) }, 69 | interactionSource = remember { MutableInteractionSource() }, 70 | indication = null, 71 | ) 72 | .fillMaxHeight() 73 | ) { 74 | Text( 75 | modifier = Modifier 76 | .padding(horizontal = 16.dp) 77 | .align(Alignment.Center), 78 | text = filterUi.name, 79 | color = textColor, 80 | style = MixDrinksTextStyles.H6, 81 | ) 82 | } 83 | } 84 | } 85 | 86 | @Immutable 87 | internal data class FilterItemUiModel( 88 | val groupId: FilterGroupId, 89 | val id: FilterId, 90 | val name: String, 91 | val isSelect: Boolean, 92 | val isEnable: Boolean, 93 | ) 94 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/items/ItemDetailsView.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.items 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.background 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.lazy.LazyColumn 12 | import androidx.compose.foundation.lazy.LazyListScope 13 | import androidx.compose.material.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.runtime.collectAsState 16 | import androidx.compose.runtime.getValue 17 | import androidx.compose.runtime.remember 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.layout.ContentScale 20 | import androidx.compose.ui.unit.dp 21 | import com.seiko.imageloader.rememberAsyncImagePainter 22 | import org.mixdrinks.app.styles.MixDrinksColors 23 | import org.mixdrinks.app.styles.MixDrinksTextStyles 24 | import org.mixdrinks.data.DetailGoodsUiModel 25 | import org.mixdrinks.ui.list.cocktailListInserter 26 | import org.mixdrinks.ui.widgets.MixDrinksHeader 27 | import org.mixdrinks.ui.widgets.undomain.ContentHolder 28 | 29 | @Composable 30 | internal fun ItemDetailsView(component: ItemDetailComponent) { 31 | Box( 32 | modifier = Modifier 33 | .fillMaxSize() 34 | .background(MixDrinksColors.White), 35 | ) { 36 | ContentHolder( 37 | stateflow = component.state 38 | ) { 39 | ItemViewContent(it, component) 40 | } 41 | } 42 | } 43 | 44 | @OptIn(ExperimentalFoundationApi::class) 45 | @Composable 46 | internal fun ItemViewContent( 47 | good: DetailGoodsUiModel, 48 | component: ItemDetailComponent, 49 | ) { 50 | val predefineComponent = remember(good) { component.predefineCocktailComponent } 51 | val cocktails by predefineComponent.state.collectAsState() 52 | LazyColumn { 53 | stickyHeader { 54 | MixDrinksHeader(good.name, component::back) 55 | } 56 | goodsViewScrollContent(Modifier.padding(horizontal = 8.dp), good) 57 | item { 58 | Text( 59 | modifier = Modifier.padding(8.dp), 60 | style = MixDrinksTextStyles.H2, 61 | text = "Коктейлі з ${good.name}", 62 | ) 63 | } 64 | cocktailListInserter( 65 | cocktails = cocktails, 66 | onClick = predefineComponent::navigateToDetails, 67 | onTagClick = predefineComponent::navigateToTagCocktails, 68 | trackingScreen = "page_item_details" 69 | ) 70 | } 71 | } 72 | 73 | internal fun LazyListScope.goodsViewScrollContent(modifier: Modifier, good: DetailGoodsUiModel) { 74 | item { 75 | Image( 76 | painter = rememberAsyncImagePainter(good.url), 77 | contentDescription = good.name, 78 | contentScale = ContentScale.FillHeight, 79 | modifier = modifier.fillMaxWidth() 80 | .padding(top = 8.dp, bottom = 24.dp) 81 | .height(300.dp), 82 | ) 83 | } 84 | item { 85 | Text( 86 | modifier = modifier, 87 | style = MixDrinksTextStyles.H4, 88 | text = good.about, 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/goods/Counter.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details.goods 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.border 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.PaddingValues 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxSize 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.shape.RoundedCornerShape 14 | import androidx.compose.material.Button 15 | import androidx.compose.material.ButtonDefaults 16 | import androidx.compose.material.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.layout.ContentScale 22 | import androidx.compose.ui.unit.Dp 23 | import androidx.compose.ui.unit.dp 24 | import org.jetbrains.compose.resources.ExperimentalResourceApi 25 | import org.jetbrains.compose.resources.painterResource 26 | import org.mixdrinks.app.styles.MixDrinksColors 27 | import org.mixdrinks.app.styles.MixDrinksTextStyles 28 | 29 | @Composable 30 | internal fun Counter( 31 | count: Int, 32 | onPlus: () -> Unit, 33 | onMinus: () -> Unit, 34 | ) { 35 | 36 | val counterHeight = 40.dp 37 | Row { 38 | ChangeCountButton( 39 | counterHeight = counterHeight, 40 | resource = "ic_minus.xml", 41 | contentDescription = "Менше", 42 | onClick = onMinus 43 | ) 44 | 45 | Spacer( 46 | modifier = Modifier.width(4.dp) 47 | ) 48 | 49 | Box( 50 | modifier = Modifier 51 | .size(counterHeight) 52 | .border(1.dp, Color.Black, RoundedCornerShape(4.dp)), 53 | ) { 54 | Text( 55 | modifier = Modifier.align(Alignment.Center), 56 | text = count.toString(), 57 | style = MixDrinksTextStyles.H4, 58 | color = MixDrinksColors.Black, 59 | ) 60 | } 61 | 62 | Spacer( 63 | modifier = Modifier.width(4.dp) 64 | ) 65 | 66 | ChangeCountButton( 67 | counterHeight = counterHeight, 68 | resource = "ic_plus.xml", 69 | contentDescription = "Більше", 70 | onClick = onPlus 71 | ) 72 | 73 | Spacer( 74 | modifier = Modifier.width(4.dp) 75 | ) 76 | } 77 | } 78 | 79 | @OptIn(ExperimentalResourceApi::class) 80 | @Composable 81 | internal fun ChangeCountButton( 82 | counterHeight: Dp, 83 | resource: String, 84 | contentDescription: String, 85 | onClick: () -> Unit, 86 | ) { 87 | Button( 88 | colors = ButtonDefaults.buttonColors(backgroundColor = MixDrinksColors.Main), 89 | onClick = onClick, 90 | contentPadding = PaddingValues(0.dp), 91 | shape = RoundedCornerShape(4.dp), 92 | modifier = Modifier 93 | .size(counterHeight) 94 | ) { 95 | Image( 96 | modifier = Modifier 97 | .align(Alignment.CenterVertically) 98 | .padding(4.dp) 99 | .fillMaxSize(), 100 | painter = painterResource(resource), 101 | contentDescription = contentDescription, 102 | contentScale = ContentScale.Fit, 103 | ) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/auth/AuthView.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.auth 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.background 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.foundation.layout.wrapContentSize 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.Button 14 | import androidx.compose.material.ButtonDefaults 15 | import androidx.compose.material.Text 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.layout.ContentScale 21 | import androidx.compose.ui.text.style.TextAlign 22 | import androidx.compose.ui.unit.dp 23 | import org.jetbrains.compose.resources.ExperimentalResourceApi 24 | import org.jetbrains.compose.resources.painterResource 25 | import org.mixdrinks.app.styles.MixDrinksColors 26 | import org.mixdrinks.app.styles.MixDrinksTextStyles 27 | 28 | 29 | @Composable 30 | internal fun AuthView(modifier: Modifier, onClose: () -> Unit) { 31 | Column(modifier = modifier 32 | .wrapContentSize() 33 | .background(shape = RoundedCornerShape(6.dp), color = MixDrinksColors.White) 34 | ) { 35 | Text( 36 | modifier = Modifier 37 | .fillMaxWidth() 38 | .padding(8.dp), 39 | text = "Авторизуся", 40 | style = MixDrinksTextStyles.H2, 41 | textAlign = TextAlign.Center, 42 | color = MixDrinksColors.Main, 43 | ) 44 | Spacer(modifier = Modifier.height(8.dp)) 45 | SocialButton( 46 | socialButtonType = SocialButtonType.Google, 47 | onClick = { 48 | AuthCallbacks.googleAuthStart() 49 | }) 50 | 51 | SocialButton( 52 | socialButtonType = SocialButtonType.Apple, 53 | onClick = { 54 | AuthCallbacks.appleAuthStart() 55 | } 56 | ) 57 | 58 | Button( 59 | modifier = Modifier.padding(8.dp).height(48.dp).fillMaxWidth(), 60 | onClick = { onClose() }, 61 | shape = RoundedCornerShape(6.dp), 62 | colors = ButtonDefaults.buttonColors( 63 | backgroundColor = Color.White, 64 | ) 65 | ) { 66 | Text(text = "Закрити", modifier = Modifier.padding(6.dp)) 67 | } 68 | } 69 | } 70 | 71 | internal enum class SocialButtonType( 72 | val icon: String, 73 | val text: String, 74 | ) { 75 | Google("ic_google.xml", "Sign in with Google"), 76 | Apple("ic_apple.xml", "Sign in with Apple"), 77 | } 78 | 79 | @OptIn(ExperimentalResourceApi::class) 80 | @Composable 81 | internal fun SocialButton(socialButtonType: SocialButtonType, onClick: () -> Unit) { 82 | Button( 83 | modifier = Modifier.padding(8.dp).height(48.dp).fillMaxWidth(), 84 | onClick = { onClick() }, 85 | shape = RoundedCornerShape(6.dp), 86 | elevation = ButtonDefaults.elevation(8.dp, 12.dp), 87 | colors = ButtonDefaults.buttonColors( 88 | backgroundColor = Color.White, 89 | ) 90 | ) { 91 | Image( 92 | modifier = Modifier 93 | .align(Alignment.CenterVertically) 94 | .size(48.dp), 95 | painter = painterResource(socialButtonType.icon), 96 | contentDescription = socialButtonType.text, 97 | contentScale = ContentScale.Fit, 98 | ) 99 | Text(text = socialButtonType.text, modifier = Modifier.padding(6.dp)) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/goods/GoodsView.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details.goods 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.fillMaxHeight 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.size 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.material.Card 16 | import androidx.compose.material.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.collectAsState 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.layout.ContentScale 23 | import androidx.compose.ui.unit.dp 24 | import com.seiko.imageloader.rememberAsyncImagePainter 25 | import org.mixdrinks.app.styles.MixDrinksColors 26 | import org.mixdrinks.app.styles.MixDrinksTextStyles 27 | import org.mixdrinks.dto.GoodId 28 | import org.mixdrinks.ui.widgets.undomain.UiState 29 | 30 | @Composable 31 | internal fun GoodsView( 32 | goodsSubComponent: GoodsSubComponent, 33 | onGoodClick: (goodId: GoodId) -> Unit 34 | ) { 35 | 36 | val state by goodsSubComponent.state.collectAsState() 37 | 38 | if (state is UiState.Data) { 39 | val safeState = (state as UiState.Data).data 40 | Column { 41 | Box(modifier = Modifier.fillMaxWidth()) { 42 | Text( 43 | modifier = Modifier 44 | .padding(start = 12.dp, bottom = 12.dp) 45 | .align(Alignment.CenterStart), 46 | color = MixDrinksColors.Black, 47 | text = "Інгрідієнти", 48 | style = MixDrinksTextStyles.H1, 49 | ) 50 | Box( 51 | modifier = Modifier 52 | .padding(horizontal = 12.dp) 53 | .align(Alignment.CenterEnd) 54 | ) { 55 | Counter( 56 | count = safeState.count, 57 | onPlus = goodsSubComponent::onPlusClick, 58 | onMinus = goodsSubComponent::onMinusClick, 59 | ) 60 | } 61 | } 62 | 63 | safeState.goods.forEach { 64 | Good(it, onGoodClick) 65 | } 66 | } 67 | } 68 | } 69 | 70 | @Composable 71 | internal fun Good(good: GoodsSubComponent.GoodUi, onGoodClick: (goodId: GoodId) -> Unit) { 72 | Card( 73 | modifier = Modifier 74 | .height(80.dp) 75 | .fillMaxWidth() 76 | .padding(horizontal = 12.dp, vertical = 4.dp), 77 | shape = RoundedCornerShape(8.dp), 78 | ) { 79 | Row(modifier = Modifier.clickable { onGoodClick(good.goodId) }) { 80 | Image( 81 | painter = rememberAsyncImagePainter(good.url), 82 | contentDescription = good.name, 83 | contentScale = ContentScale.Inside, 84 | modifier = Modifier.size(80.dp, 80.dp) 85 | .padding(2.dp), 86 | ) 87 | Text( 88 | modifier = Modifier.align(Alignment.CenterVertically) 89 | .padding(horizontal = 8.dp), 90 | text = good.name, 91 | style = MixDrinksTextStyles.H5, 92 | ) 93 | Spacer(Modifier.weight(1f).fillMaxHeight()) 94 | Text( 95 | modifier = Modifier.align(Alignment.CenterVertically) 96 | .padding(horizontal = 8.dp), 97 | text = good.amount, 98 | style = MixDrinksTextStyles.H5, 99 | ) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/navigation/MainTabNavigator.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.navigation 2 | 3 | import com.arkivanov.decompose.router.stack.StackNavigation 4 | import com.arkivanov.decompose.router.stack.pop 5 | import com.arkivanov.decompose.router.stack.push 6 | import com.arkivanov.essenty.parcelable.Parcelable 7 | import com.arkivanov.essenty.parcelable.Parcelize 8 | import kotlin.native.concurrent.ThreadLocal 9 | import org.mixdrinks.data.ItemsType 10 | import org.mixdrinks.dto.CocktailId 11 | import org.mixdrinks.dto.TagId 12 | import org.mixdrinks.dto.TasteId 13 | import org.mixdrinks.ui.details.CocktailsDetailNavigation 14 | import org.mixdrinks.ui.filters.search.SearchItemComponent 15 | import org.mixdrinks.ui.items.ItemDetailsNavigation 16 | import org.mixdrinks.ui.tag.CommonTag 17 | import org.mixdrinks.ui.tag.CommonTagNavigation 18 | 19 | internal class MainTabNavigator( 20 | private val stackNavigation: StackNavigation, 21 | ) : INavigator, 22 | CommonTagNavigation, 23 | CocktailsDetailNavigation, 24 | ItemDetailsNavigation { 25 | 26 | sealed class Config(open val operationIndex: Int) : Parcelable { 27 | @Parcelize 28 | data class ListConfig( 29 | override val operationIndex: Int, 30 | ) : Config(operationIndex) { 31 | constructor() : this(operation++) 32 | } 33 | 34 | @Parcelize 35 | data class FilterConfig( 36 | override val operationIndex: Int, 37 | ) : Config(operationIndex) { 38 | constructor() : this(operation++) 39 | } 40 | 41 | @Parcelize 42 | data class DetailsConfig( 43 | val id: Int, 44 | override val operationIndex: Int, 45 | ) : Config(operationIndex) { 46 | constructor(id: Int) : this(id, operation++) 47 | } 48 | 49 | @Parcelize 50 | data class ItemConfig( 51 | val id: Int, 52 | val typeGoods: String, 53 | override val operationIndex: Int, 54 | ) : Config(operationIndex) { 55 | constructor(id: Int, itemType: String) : this(id, itemType, operation++) 56 | } 57 | 58 | @Parcelize 59 | data class SearchItemConfig( 60 | val searchItemType: SearchItemComponent.SearchItemType, 61 | override val operationIndex: Int, 62 | ) : Config(operationIndex) { 63 | 64 | constructor(searchItemType: SearchItemComponent.SearchItemType) : this( 65 | searchItemType, 66 | operation++ 67 | ) 68 | } 69 | 70 | @Parcelize 71 | data class CommonTagConfig( 72 | val id: Int, 73 | val type: CommonTag.Type, 74 | override val operationIndex: Int, 75 | ) : Config(operationIndex) { 76 | constructor(id: Int, type: CommonTag.Type) : this(id, type, operation++) 77 | } 78 | 79 | @ThreadLocal 80 | companion object { 81 | private var operation: Int = 0 82 | } 83 | } 84 | 85 | override fun back() { 86 | stackNavigation.pop() 87 | } 88 | 89 | override fun navigateToItem(itemsType: ItemsType.Type, id: Int) { 90 | stackNavigation.push( 91 | Config.ItemConfig(id, itemsType.name) 92 | ) 93 | } 94 | 95 | override fun navigateToDetails(cocktailId: CocktailId) { 96 | stackNavigation.push(Config.DetailsConfig(cocktailId.id)) 97 | } 98 | 99 | override fun navigateToSearchItem(searchItemType: SearchItemComponent.SearchItemType) { 100 | stackNavigation.push(Config.SearchItemConfig(searchItemType)) 101 | } 102 | 103 | override fun navigateToFilters() { 104 | stackNavigation.push(Config.FilterConfig()) 105 | } 106 | 107 | override fun navigateToTagCocktails(tagId: TagId) { 108 | stackNavigation.push(Config.CommonTagConfig(tagId.id, CommonTag.Type.TAG)) 109 | } 110 | 111 | override fun navigationToTasteCocktails(tasteId: TasteId) { 112 | stackNavigation.push(Config.CommonTagConfig(tasteId.id, CommonTag.Type.TASTE)) 113 | } 114 | 115 | override fun openFromDeepLink(config: Config) { 116 | stackNavigation.push(config) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Run Gradle on PRs 2 | on: pull_request 3 | 4 | jobs: 5 | tests: 6 | strategy: 7 | matrix: 8 | os: [ ubuntu-22.04 ] 9 | runs-on: ${{ matrix.os }} 10 | continue-on-error: true 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up JDK 11 for x64 14 | uses: actions/setup-java@v3 15 | with: 16 | java-version: '17' 17 | distribution: 'adopt' 18 | architecture: x64 19 | - name: Build with Gradle 20 | uses: gradle/gradle-build-action@v2.5.1 21 | with: 22 | arguments: check 23 | 24 | build_android: 25 | env: 26 | MIXDRINKS_MOBILE_APP_VERSION_NAME: "0.0.1" 27 | MIXDRINKS_MOBILE_APP_VERSION_CODE: 10 28 | runs-on: ubuntu-22.04 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: actions/setup-java@v3 32 | with: 33 | java-version: '17' 34 | distribution: 'adopt' 35 | architecture: x64 36 | 37 | - name: Build with Gradle 38 | uses: gradle/gradle-build-action@v2.5.1 39 | with: 40 | arguments: androidApp:bundleRelease 41 | 42 | - uses: r0adkll/sign-android-release@v1 43 | name: "Sign app aab file" 44 | id: sign_app 45 | with: 46 | releaseDirectory: androidApp/build/outputs/bundle/release 47 | signingKeyBase64: ${{ secrets.MIXDRINKS_ANDROID_SIGNING_KEY }} 48 | alias: ${{ secrets.MIXDRINKS_ANDROID_ALIAS }} 49 | keyStorePassword: ${{ secrets.MIXDRINKS_ANDROID_KEY_STORE_PASSWORD }} 50 | keyPassword: ${{ secrets.MIXDRINKS_ANDROID_KEY_PASSWORD }} 51 | env: 52 | BUILD_TOOLS_VERSION: "30.0.2" 53 | 54 | - uses: actions/upload-artifact@v3 55 | with: 56 | name: "Upload AAB file as artifact" 57 | path: ${{steps.sign_app.outputs.signedReleaseFile}} 58 | 59 | 60 | build_ios: 61 | env: 62 | MIXDRINKS_MOBILE_APP_VERSION_NAME: "0.0.1" 63 | MIXDRINKS_MOBILE_APP_VERSION_CODE: 10 64 | runs-on: macos-12 65 | steps: 66 | - uses: actions/checkout@v3 67 | 68 | - name: Apply versions 69 | run: | 70 | /usr/libexec/Plistbuddy -c "Set CFBundleVersion $MIXDRINKS_MOBILE_APP_VERSION_CODE" iosApp/iosApp/Info.plist 71 | /usr/libexec/Plistbuddy -c "Set CFBundleShortVersionString $MIXDRINKS_MOBILE_APP_VERSION_NAME" iosApp/iosApp/Info.plist 72 | 73 | - uses: actions/setup-java@v3 74 | with: 75 | java-version: '17' 76 | distribution: 'adopt' 77 | architecture: x64 78 | 79 | - name: Build pod install 80 | uses: gradle/gradle-build-action@v2.5.1 81 | with: 82 | arguments: podInstall 83 | 84 | - name: "Build IOS App" 85 | uses: yukiarrr/ios-build-action@v1.11.0 86 | with: 87 | project-path: iosApp/iosApp.xcodeproj 88 | p12-base64: ${{ secrets.MIXDRINKS_IOS_P12_BASE64 }} 89 | mobileprovision-base64: ${{ secrets.PROD_MIXDRINKS_IOS_BUILD_PROVISION_PROFILE_BASE64 }} 90 | code-signing-identity: "iPhone Distribution" 91 | team-id: ${{ secrets.MIXDRINKS_IOS_TEAM_ID }} 92 | certificate-password: ${{ secrets.MIXDRINKS_IOS_CERTIFICATE_PASSWORD }} 93 | export-options: iosApp/exportOptionsRelease.plist 94 | workspace-path: iosApp/iosApp.xcworkspace 95 | export-method: "app-store" 96 | - name: "Upload IPA file as artifact" 97 | uses: actions/upload-artifact@v3 98 | with: 99 | name: IOS IPA 100 | path: "output.ipa" 101 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/main/ListComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list.main 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.SharingStarted 8 | import kotlinx.coroutines.flow.StateFlow 9 | import kotlinx.coroutines.flow.combineTransform 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.stateIn 12 | import org.mixdrinks.data.CocktailsProvider 13 | import org.mixdrinks.ui.filters.FilterItemUiModel 14 | import org.mixdrinks.ui.list.CocktailListMapper 15 | import org.mixdrinks.ui.list.CocktailsListState 16 | import org.mixdrinks.ui.list.SelectedFilterProvider 17 | import org.mixdrinks.ui.navigation.INavigator 18 | import org.mixdrinks.ui.navigation.MainTabNavigator 19 | import org.mixdrinks.ui.widgets.undomain.UiState 20 | import org.mixdrinks.ui.widgets.undomain.launch 21 | 22 | @Suppress("LongParameterList") 23 | internal class ListComponent( 24 | private val componentContext: ComponentContext, 25 | private val cocktailsProvider: CocktailsProvider, 26 | private val selectedFilterProvider: SelectedFilterProvider, 27 | private val mainTabNavigator: MainTabNavigator, 28 | private val mutableFilterStorage: MutableFilterStorage, 29 | private val cocktailListMapper: CocktailListMapper, 30 | ) : ComponentContext by componentContext, 31 | INavigator by mainTabNavigator { 32 | 33 | private val _state = MutableStateFlow>(UiState.Loading) 34 | val state: StateFlow> = _state 35 | 36 | private val _searchQuery: MutableStateFlow = MutableStateFlow("") 37 | val searchQuery: StateFlow = _searchQuery 38 | 39 | private val _isSearchActive = MutableStateFlow(false) 40 | val isSearchActive: StateFlow = _isSearchActive 41 | 42 | init { 43 | launch { 44 | cocktailsProvider.getCocktails() 45 | .map { cocktails -> map(cocktails) } 46 | .combineTransform(isSearchActive) { cocktails, isSearchActive -> 47 | if (!isSearchActive) { 48 | emit(cocktails) 49 | } 50 | } 51 | .collect(_state) 52 | } 53 | launch { 54 | searchQuery 55 | .map { query -> 56 | cocktailsProvider.getAllCocktails() 57 | .filter { cocktail -> 58 | cocktail.name.contains(query, ignoreCase = true) 59 | } 60 | .sortedBy { it.name } 61 | } 62 | .map { cocktails -> map(cocktails) } 63 | .combineTransform(isSearchActive) { cocktails, isSearchActive -> 64 | if (isSearchActive) { 65 | emit(cocktails) 66 | } 67 | } 68 | .collect(_state) 69 | } 70 | } 71 | 72 | val filterCountState: StateFlow = mutableFilterStorage.selected 73 | .map { filterGroups -> 74 | filterGroups.flatMap { it.value }.count().takeIf { it > 0 } 75 | } 76 | .stateIn( 77 | CoroutineScope(Dispatchers.Main), SharingStarted.WhileSubscribed(), null 78 | ) 79 | 80 | fun openSearch() = launch { 81 | _isSearchActive.emit(true) 82 | } 83 | 84 | fun closeSearch() = launch { 85 | _searchQuery.emit("") 86 | _isSearchActive.emit(false) 87 | } 88 | 89 | fun onSearchQueryChange(query: String) = launch { 90 | println("onSearchQueryChange: $query") 91 | _searchQuery.emit(query) 92 | } 93 | 94 | private suspend fun map(cocktails: List): UiState.Data { 95 | return UiState.Data( 96 | if (cocktails.isEmpty()) { 97 | CocktailsListState.PlaceHolder(selectedFilterProvider.getSelectedFiltersWithData()) 98 | } else { 99 | CocktailsListState.Cocktails(cocktailListMapper.map(cocktails)) 100 | } 101 | ) 102 | } 103 | 104 | fun onFilterStateChange( 105 | filterGroupId: FilterItemUiModel, 106 | isSelect: Boolean, 107 | ) { 108 | mutableFilterStorage.onFilterStateChange(filterGroupId, isSelect) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to our repository! Please note that our native app projects are now archived. 2 | 3 | ## [Visit our web](https://mixdrinks.org/) 4 | 5 | ## MixDrinks app 6 | 7 | The app is available on **Google Play** and **App Store**: 8 | 9 | - [Android](https://play.google.com/store/apps/details?id=org.mixdrinks.app) 10 | - [App Store](https://apps.apple.com/app/id6447103081) 11 | - [Web](https://mixdrinks.org/) 12 | 13 | ## Before start any works on project 14 | 15 | > **Warning** 16 | > Writing and running iOS-specific code for a simulated or real device requires macOS. This is an 17 | > Apple limitation. 18 | 19 | 20 | Check your environment has all requirements for Kotlin Multiplatform Mobile Development: 21 | 22 | - The computer must be running latest macOS. 23 | - [Xcode](https://developer.apple.com/xcode/) 24 | - [Android Studio](https://developer.android.com/studio) 25 | - [Kotlin Multiplatform Mobile plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile) 26 | - [CocoaPods](https://kotlinlang.org/docs/native-cocoapods.html) 27 | 28 | ### The environment can be checked by `kdoctor` 29 | 30 | **Before opening the project in Android Studio**, use [`kdoctor`](https://github.com/Kotlin/kdoctor) 31 | to ensure your development environment is configured correctly. Install `kdoctor` 32 | via [`brew`](https://brew.sh/): 33 | 34 | ``` 35 | brew install kdoctor 36 | ``` 37 | 38 | The kdoctor tool will check your environment and provide a list of errors if something goes wrong: 39 | 40 | ``` 41 | Environment diagnose (to see all details, use -v option): 42 | [✓] Operation System 43 | [✓] Java 44 | [✓] Android Studio 45 | [✓] Xcode 46 | [✓] Cocoapods 47 | 48 | Conclusion: 49 | ✓ Your system is ready for Kotlin Multiplatform Mobile Development! 50 | ``` 51 | 52 | #### Codding environment preparation 53 | 54 | Check you have 55 | installed [Kotlin Multiplatform Mobile plugin](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform-mobile) 56 | 57 | ## Project structure 58 | 59 | To view the project structure use **Project view**. 60 | 61 | The project has three modules: 62 | 63 | ### `shared` 64 | 65 | The module contains code that will be shared across all platforms. 66 | 67 | App `@Composable` фукція знаходиться в `shared/src/commonMain/kotlin/App.kt`. 68 | 69 | ### `androidApp` 70 | 71 | The module contains code that will be used only on Android. 72 | 73 | ### `iosApp` 74 | 75 | The module contains code that will be used only on iOS. 76 | The module `iosApp` depends on the `shared` module as a CocoaPods dependency. 77 | 78 | ## Run the app/project 79 | 80 | ## Android 81 | 82 | Chose the configuration `androidApp` -> `Run` 83 | 84 | Or run by gradle command 85 | `./gradlew installDebug` 86 | 87 | ## iOS 88 | 89 | Before run the mixdrinks project for ios, we highly recommend to run the `Hello, World` Xcode 90 | project. Just to be sure that your environment is ready for ios development. 91 | 92 | ### Run ios mixdrinks app on simulator 93 | 94 | Chose the configuration `iosApp` -> `Run` 95 | 96 | ### Run ios mixdrinks app on real ios device 97 | 98 | Before run the app on real ios device you need to prepare your environment: 99 | 100 | - Create an [Apple ID](https://support.apple.com/en-us/HT204316) 101 | - Register your iphone in Xcode 102 | 103 | Change the `TEAM_ID` to your team id (can be found in apple.developer), 104 | in `iosApp/Configuration/Config.xcconfig` 105 | 106 | Alternatively way to get team id, use kdoctor command `kdoctor --team-ids`. The command will produce 107 | the list of team ids from your system, choose the one you prefer. 108 | 109 | ### Contributing 110 | 111 | We are happy to accept small and large contributions, you can just make changes and create a pull, 112 | or you can check our [issue tab](https://github.com/MixDrinks/Mobile/issues) and choose the one you 113 | like. 114 | 115 | ### Troubleshooting 116 | 117 | If you have any problems with the project, please check the following list: 118 | 119 | #### Most popular problems, you can run ios app. 120 | 121 | Usually happens after you make change is some ios specific files, 122 | like `iosApp/Configuration/Config.xcconfig` or `iosApp/Info.plist` 123 | 124 | **Close Android Studio or you idea. Than run the `./cleanup.sh`.** Now you can open the project 125 | again. 126 | 127 | #### The resource files are not available in ios project 128 | 129 | After you maker changes into resources into `shared` module, you need to run `pod install` in 130 | the `iosApp` folder. 131 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/details/DetailsComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.details 2 | 3 | import androidx.compose.ui.text.capitalize 4 | import androidx.compose.ui.text.intl.Locale 5 | import com.arkivanov.decompose.ComponentContext 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.flow 9 | import kotlinx.coroutines.flow.flowOn 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.launch 12 | import org.mixdrinks.data.FullCocktail 13 | import org.mixdrinks.domain.ImageUrlCreators 14 | import org.mixdrinks.dto.CocktailId 15 | import org.mixdrinks.dto.GlasswareId 16 | import org.mixdrinks.dto.TagId 17 | import org.mixdrinks.dto.TasteId 18 | import org.mixdrinks.dto.ToolId 19 | import org.mixdrinks.ui.details.goods.GoodsRepository 20 | import org.mixdrinks.ui.details.goods.GoodsSubComponent 21 | import org.mixdrinks.ui.visited.UserVisitedCocktailsService 22 | import org.mixdrinks.ui.visited.authExecutor 23 | import org.mixdrinks.ui.widgets.undomain.UiState 24 | import org.mixdrinks.ui.widgets.undomain.scope 25 | import org.mixdrinks.ui.widgets.undomain.stateInWhileSubscribe 26 | 27 | internal class DetailsComponent( 28 | private val componentContext: ComponentContext, 29 | private val fullCocktailRepository: FullCocktailRepository, 30 | private val cocktailId: CocktailId, 31 | private val cocktailsDetailNavigation: CocktailsDetailNavigation, 32 | goodsRepository: GoodsRepository, 33 | visitedCocktailsService: UserVisitedCocktailsService, 34 | ) : ComponentContext by componentContext, 35 | CocktailsDetailNavigation by cocktailsDetailNavigation { 36 | 37 | val goodsSubComponent = GoodsSubComponent( 38 | componentContext, 39 | goodsRepository, 40 | cocktailId 41 | ) 42 | 43 | init { 44 | scope.launch { 45 | authExecutor { 46 | visitedCocktailsService.visitCocktail(cocktailId.id) 47 | } 48 | } 49 | } 50 | 51 | val state: StateFlow> = flow { 52 | fullCocktailRepository.getFullCocktail(cocktailId)?.let { 53 | emit(it) 54 | } 55 | } 56 | .map { cocktail: FullCocktail -> 57 | UiState.Data(map(cocktail)) 58 | } 59 | .flowOn(Dispatchers.Default) 60 | .stateInWhileSubscribe() 61 | 62 | private fun map(fullCocktail: FullCocktail): FullCocktailUiModel { 63 | return FullCocktailUiModel( 64 | id = fullCocktail.id, 65 | name = fullCocktail.name, 66 | url = ImageUrlCreators.createUrl(fullCocktail.id, ImageUrlCreators.Size.SIZE_560), 67 | receipt = fullCocktail.receipt, 68 | glassware = FullCocktailUiModel.GlasswareUi( 69 | id = fullCocktail.glassware.id, 70 | name = fullCocktail.glassware.name, 71 | url = ImageUrlCreators.createUrl( 72 | fullCocktail.glassware.id, 73 | ImageUrlCreators.Size.SIZE_400 74 | ) 75 | ), 76 | tools = fullCocktail.tools.map { 77 | FullCocktailUiModel.ToolUi( 78 | id = it.toolId, 79 | name = it.name, 80 | url = ImageUrlCreators.createUrl(it.toolId, ImageUrlCreators.Size.SIZE_400) 81 | ) 82 | }, 83 | tags = fullCocktail.tastes.map { 84 | FullCocktailUiModel.TagUi.Taste( 85 | id = it.id, 86 | name = it.name.capitalize(Locale.current), 87 | ) 88 | }.plus(fullCocktail.tags.map { 89 | FullCocktailUiModel.TagUi.Tag( 90 | id = it.id, 91 | name = it.name.capitalize(Locale.current), 92 | ) 93 | }) 94 | ) 95 | } 96 | } 97 | 98 | internal data class FullCocktailUiModel( 99 | val id: CocktailId, 100 | val url: String, 101 | val name: String, 102 | val receipt: List, 103 | val tools: List, 104 | val glassware: GlasswareUi, 105 | val tags: List, 106 | ) { 107 | 108 | data class ToolUi( 109 | val id: ToolId, 110 | val name: String, 111 | val url: String, 112 | ) 113 | 114 | data class GlasswareUi( 115 | val id: GlasswareId, 116 | val name: String, 117 | val url: String, 118 | ) 119 | 120 | sealed class TagUi(open val name: String) { 121 | data class Tag( 122 | val id: TagId, 123 | override val name: String, 124 | ) : TagUi(name) 125 | 126 | data class Taste( 127 | val id: TasteId, 128 | override val name: String, 129 | ) : TagUi(name) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/filters/search/SearchItemComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.filters.search 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.arkivanov.decompose.ComponentContext 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.combine 9 | import kotlinx.coroutines.flow.flowOn 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.transform 12 | import org.mixdrinks.domain.FilterGroups 13 | import org.mixdrinks.domain.ImageUrlCreators 14 | import org.mixdrinks.dto.FilterGroupId 15 | import org.mixdrinks.dto.FilterId 16 | import org.mixdrinks.ui.list.main.MutableFilterStorage 17 | import org.mixdrinks.ui.navigation.MainTabNavigator 18 | import org.mixdrinks.ui.widgets.undomain.UiState 19 | import org.mixdrinks.ui.widgets.undomain.launch 20 | import org.mixdrinks.ui.widgets.undomain.stateInWhileSubscribe 21 | 22 | internal class SearchItemComponent( 23 | private val componentContext: ComponentContext, 24 | private val searchItemType: SearchItemType, 25 | private val mutableFilterStorage: MutableFilterStorage, 26 | private val itemRepository: ItemRepository, 27 | private val mainTabNavigator: MainTabNavigator, 28 | ) : ComponentContext by componentContext { 29 | 30 | private val _textState = MutableStateFlow("") 31 | val textState: StateFlow = _textState 32 | 33 | val state: StateFlow>> = mutableFilterStorage.selected 34 | .map { 35 | it[searchItemType.filterGroupId] ?: emptyList() 36 | } 37 | .transform { selected -> 38 | this.emit( 39 | UiState.Data( 40 | mapItemsToUi( 41 | itemRepository.getItems(searchItemType), 42 | selected, 43 | ) 44 | ) 45 | ) 46 | } 47 | .combine(textState) { items, query -> 48 | items.copy( 49 | data = items.data.filter { it.name.contains(query, ignoreCase = true) } 50 | ) 51 | } 52 | .flowOn(Dispatchers.Default) 53 | .stateInWhileSubscribe() 54 | 55 | private fun mapItemsToUi( 56 | items: List, 57 | selected: List, 58 | ): List { 59 | return items 60 | .map { item -> 61 | val imageUrl = when (item.id) { 62 | is ItemRepository.ItemId.Good -> ImageUrlCreators 63 | .createUrl(item.id.id, ImageUrlCreators.Size.SIZE_320) 64 | 65 | is ItemRepository.ItemId.Tool -> ImageUrlCreators 66 | .createUrl(item.id.id, ImageUrlCreators.Size.SIZE_320) 67 | } 68 | 69 | val inSelected = selected.find { it.filterId == FilterId(item.id.value) } 70 | 71 | val isSelect = inSelected != null 72 | 73 | val operationIndex = inSelected?.operationIndex ?: -1L 74 | 75 | ItemUiModel( 76 | id = item.id, 77 | name = item.name, 78 | imageUrl = imageUrl, 79 | isSelected = isSelect, 80 | count = item.cocktailCount, 81 | operationIndex = operationIndex, 82 | ) 83 | } 84 | .sortedWith( 85 | compareByDescending { it.isSelected } 86 | .then { a, b -> 87 | if (a.isSelected) { 88 | compareBy { it.operationIndex }.compare(a, b) 89 | } else { 90 | compareBy({ -it.count }, { it.name }).compare(a, b) 91 | } 92 | } 93 | ) 94 | } 95 | 96 | fun close() { 97 | mainTabNavigator.back() 98 | } 99 | 100 | fun onSearchQueryChanged(query: String) = launch { 101 | _textState.emit(query) 102 | } 103 | 104 | fun onItemClicked(id: ItemRepository.ItemId, isSelected: Boolean) = launch { 105 | mutableFilterStorage.onValueChange( 106 | searchItemType.filterGroupId, 107 | FilterId(id.value), 108 | isSelected 109 | ) 110 | } 111 | 112 | @Immutable 113 | data class ItemUiModel( 114 | val id: ItemRepository.ItemId, 115 | val name: String, 116 | val imageUrl: String, 117 | val isSelected: Boolean, 118 | val count: Int, 119 | val operationIndex: Long, 120 | ) 121 | 122 | enum class SearchItemType(val filterGroupId: FilterGroupId) { 123 | GOODS(FilterGroups.GOODS.id), TOOLS(FilterGroups.TOOLS.id) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnusedPrivateMember", "MaxLineLength") 2 | 3 | plugins { 4 | kotlin("multiplatform") 5 | kotlin("native.cocoapods") 6 | id("com.android.library") 7 | id("org.jetbrains.compose") 8 | kotlin("plugin.serialization") 9 | id("com.google.devtools.ksp") 10 | id("de.jensklingenberg.ktorfit") 11 | id("kotlin-parcelize") 12 | } 13 | 14 | val ktorVersion = "2.2.4" 15 | val ktorfitVersion = "1.0.1" 16 | 17 | kotlin { 18 | android() 19 | 20 | iosX64() 21 | iosArm64() 22 | iosSimulatorArm64() 23 | 24 | cocoapods { 25 | version = "1.0.0" 26 | summary = "Some description for the Shared Module" 27 | homepage = "Link to the Shared Module homepage" 28 | ios.deploymentTarget = "14.1" 29 | podfile = project.file("../iosApp/Podfile") 30 | framework { 31 | baseName = "shared" 32 | isStatic = false 33 | } 34 | extraSpecAttributes["resources"] = 35 | "['src/commonMain/resources/**', 'src/iosMain/resources/**']" 36 | podfile = project.file("../iosApp/Podfile") 37 | 38 | pod("GoogleSignIn") 39 | pod("FirebaseCore") 40 | pod("FirebaseAuth") 41 | pod("FirebaseAnalytics") 42 | } 43 | 44 | sourceSets { 45 | val commonMain by getting { 46 | dependencies { 47 | implementation(compose.ui) 48 | implementation(compose.foundation) 49 | implementation(compose.material) 50 | implementation(compose.runtime) 51 | 52 | implementation("org.jetbrains.compose.components:components-resources:${org.jetbrains.compose.ComposeBuildConfig.composeVersion}") 53 | 54 | implementation("org.mixdrinks:core:1.8.7") 55 | 56 | implementation("de.jensklingenberg.ktorfit:ktorfit-lib:1.0.1") 57 | 58 | implementation("io.ktor:ktor-client-auth:$ktorVersion") 59 | implementation("io.ktor:ktor-client-logging:$ktorVersion") 60 | implementation("io.ktor:ktor-client-serialization:$ktorVersion") 61 | implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") 62 | implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") 63 | 64 | implementation("com.arkivanov.decompose:decompose:2.1.0-compose-experimental-alpha-03") 65 | implementation("com.arkivanov.decompose:extensions-compose-jetbrains:2.1.0-compose-experimental-alpha-03") 66 | 67 | implementation("io.github.qdsfdhvh:image-loader:1.2.10") 68 | 69 | implementation("com.russhwolf:multiplatform-settings-no-arg:1.0.0") 70 | } 71 | } 72 | val commonTest by getting { 73 | dependencies { 74 | implementation(kotlin("test")) 75 | } 76 | } 77 | val androidMain by getting { 78 | dependencies { 79 | api("androidx.activity:activity-compose:1.7.2") 80 | api("androidx.appcompat:appcompat:1.6.1") 81 | api("androidx.core:core-ktx:1.10.1") 82 | implementation(platform("com.google.firebase:firebase-bom:32.3.1")) 83 | implementation("com.google.firebase:firebase-analytics-ktx") 84 | } 85 | } 86 | val iosX64Main by getting 87 | val iosArm64Main by getting 88 | val iosSimulatorArm64Main by getting 89 | val iosMain by creating { 90 | dependsOn(commonMain) 91 | iosX64Main.dependsOn(this) 92 | iosArm64Main.dependsOn(this) 93 | iosSimulatorArm64Main.dependsOn(this) 94 | } 95 | } 96 | } 97 | 98 | android { 99 | compileSdk = (findProperty("android.compileSdk") as String).toInt() 100 | namespace = "com.myapplication.common" 101 | 102 | sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") 103 | sourceSets["main"].res.srcDirs("src/androidMain/res") 104 | sourceSets["main"].resources.srcDirs("src/commonMain/resources") 105 | 106 | defaultConfig { 107 | minSdk = (findProperty("android.minSdk") as String).toInt() 108 | targetSdk = (findProperty("android.targetSdk") as String).toInt() 109 | } 110 | compileOptions { 111 | sourceCompatibility = JavaVersion.VERSION_11 112 | targetCompatibility = JavaVersion.VERSION_11 113 | } 114 | kotlin { 115 | jvmToolchain(11) 116 | } 117 | } 118 | 119 | dependencies { 120 | implementation("com.google.firebase:firebase-common-ktx:20.4.0") 121 | add("kspCommonMainMetadata", "de.jensklingenberg.ktorfit:ktorfit-ksp:$ktorfitVersion") 122 | add("kspAndroid", "de.jensklingenberg.ktorfit:ktorfit-ksp:$ktorfitVersion") 123 | add("kspIosX64", "de.jensklingenberg.ktorfit:ktorfit-ksp:$ktorfitVersion") 124 | add("kspIosArm64", "de.jensklingenberg.ktorfit:ktorfit-ksp:$ktorfitVersion") 125 | add("kspIosSimulatorArm64", "de.jensklingenberg.ktorfit:ktorfit-ksp:$ktorfitVersion") 126 | } 127 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/list/CocktailList.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.list 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.layout.width 13 | import androidx.compose.foundation.lazy.LazyColumn 14 | import androidx.compose.foundation.lazy.LazyListScope 15 | import androidx.compose.foundation.lazy.LazyRow 16 | import androidx.compose.foundation.lazy.items 17 | import androidx.compose.foundation.shape.RoundedCornerShape 18 | import androidx.compose.material.Card 19 | import androidx.compose.material.Text 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.layout.ContentScale 23 | import androidx.compose.ui.unit.dp 24 | import com.seiko.imageloader.rememberAsyncImagePainter 25 | import org.mixdrinks.app.styles.MixDrinksColors 26 | import org.mixdrinks.app.styles.MixDrinksTextStyles 27 | import org.mixdrinks.data.Tracking 28 | import org.mixdrinks.dto.CocktailId 29 | import org.mixdrinks.dto.TagId 30 | import org.mixdrinks.ui.tag.Tag 31 | 32 | @OptIn(ExperimentalFoundationApi::class) 33 | @Composable 34 | internal fun CocktailList( 35 | cocktails: CocktailsListState.Cocktails, 36 | onClick: (CocktailId) -> Unit, 37 | onTagClick: (TagId) -> Unit, 38 | trackingScreen: String, 39 | ) { 40 | LazyColumn { 41 | items(cocktails.list, key = { cocktail -> cocktail.id.id }) { cocktail -> 42 | Cocktail( 43 | modifier = Modifier.animateItemPlacement(), 44 | cocktail = cocktail, 45 | onClick = onClick, 46 | onTagClick = onTagClick, 47 | trackingScreen = trackingScreen 48 | ) 49 | } 50 | } 51 | } 52 | 53 | internal fun LazyListScope.cocktailListInserter( 54 | cocktails: CocktailsListState.Cocktails, 55 | onClick: (CocktailId) -> Unit, 56 | onTagClick: (TagId) -> Unit, 57 | trackingScreen: String, 58 | ) { 59 | cocktails.list.forEach { 60 | item(key = it.id.id) { 61 | Cocktail( 62 | modifier = Modifier, 63 | cocktail = it, 64 | onClick = onClick, 65 | onTagClick = onTagClick, 66 | trackingScreen = trackingScreen 67 | ) 68 | } 69 | } 70 | } 71 | 72 | internal fun LazyListScope.cocktailListInserter( 73 | cocktails: List, 74 | onClick: (CocktailId) -> Unit, 75 | onTagClick: (TagId) -> Unit, 76 | trackingScreen: String, 77 | ) { 78 | cocktails.forEach { 79 | item(key = it.id.id) { 80 | Cocktail( 81 | modifier = Modifier, 82 | cocktail = it, 83 | onClick = onClick, 84 | onTagClick = onTagClick, 85 | trackingScreen = trackingScreen 86 | ) 87 | } 88 | } 89 | } 90 | 91 | 92 | @Composable 93 | internal fun Cocktail( 94 | modifier: Modifier, 95 | cocktail: CocktailsListState.Cocktails.Cocktail, 96 | onClick: (CocktailId) -> Unit, 97 | onTagClick: (TagId) -> Unit, 98 | trackingScreen: String, 99 | ) { 100 | Card( 101 | modifier = modifier 102 | .height(100.dp) 103 | .fillMaxWidth() 104 | .padding(horizontal = 8.dp, vertical = 4.dp), 105 | shape = RoundedCornerShape(8.dp), 106 | ) { 107 | Row(modifier = Modifier.clickable { 108 | Tracking.track( 109 | action = "open_cocktail_details", 110 | screen = trackingScreen, 111 | data = mapOf("cocktail_name" to cocktail.name) 112 | ) 113 | onClick(cocktail.id) 114 | }) { 115 | Image( 116 | painter = rememberAsyncImagePainter(cocktail.url), 117 | contentDescription = "Коктейль ${cocktail.name}", 118 | contentScale = ContentScale.FillHeight, 119 | modifier = Modifier.width(100.dp), 120 | ) 121 | 122 | Column { 123 | Text( 124 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), 125 | text = cocktail.name, 126 | color = MixDrinksColors.Main, 127 | style = MixDrinksTextStyles.H4, 128 | ) 129 | 130 | LazyRow( 131 | modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), 132 | horizontalArrangement = Arrangement.spacedBy(4.dp) 133 | ) { 134 | items(cocktail.tags) { 135 | Tag(it, onTagClick) 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/main/MainComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.main 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.decompose.router.stack.ChildStack 5 | import com.arkivanov.decompose.router.stack.StackNavigation 6 | import com.arkivanov.decompose.router.stack.childStack 7 | import com.arkivanov.decompose.value.Value 8 | import kotlinx.coroutines.Dispatchers 9 | import kotlinx.coroutines.coroutineScope 10 | import kotlinx.coroutines.launch 11 | import org.mixdrinks.di.ComponentsFactory 12 | import org.mixdrinks.di.Graph 13 | import org.mixdrinks.domain.FilterPathParser 14 | import org.mixdrinks.dto.CocktailId 15 | import org.mixdrinks.ui.details.DetailsComponent 16 | import org.mixdrinks.ui.filters.main.FilterComponent 17 | import org.mixdrinks.ui.filters.search.SearchItemComponent 18 | import org.mixdrinks.ui.items.ItemDetailComponent 19 | import org.mixdrinks.ui.list.main.ListComponent 20 | import org.mixdrinks.ui.navigation.DeepLinkParser 21 | import org.mixdrinks.ui.navigation.MainTabNavigator 22 | import org.mixdrinks.ui.tag.CommonTag 23 | import org.mixdrinks.ui.tag.CommonTagCocktailsComponent 24 | import org.mixdrinks.ui.widgets.undomain.launch 25 | 26 | 27 | internal class MainComponent( 28 | componentContext: ComponentContext, 29 | private val graph: Graph, 30 | private val componentsFactory: ComponentsFactory, 31 | ) : ComponentContext by componentContext { 32 | 33 | private val navigation = StackNavigation() 34 | 35 | private val mainTabNavigator: MainTabNavigator = MainTabNavigator(navigation) 36 | 37 | private val _stack: Value> = childStack( 38 | source = navigation, 39 | initialConfiguration = MainTabNavigator.Config.ListConfig(), 40 | handleBackButton = true, 41 | childFactory = ::createChild 42 | ) 43 | 44 | val stack: Value> = _stack 45 | 46 | private val deepLinkParser = DeepLinkParser( 47 | suspend { graph.snapshotRepository.get() }, 48 | FilterPathParser(), 49 | ) 50 | 51 | fun onDeepLink(deepLink: String) { 52 | launch { 53 | deepLinkParser.parseDeepLink(deepLink)?.let { deepLinkAction -> 54 | val config: MainTabNavigator.Config = when (deepLinkAction) { 55 | is DeepLinkParser.DeepLinkAction.Cocktail -> MainTabNavigator.Config.DetailsConfig( 56 | deepLinkAction.id 57 | ) 58 | 59 | is DeepLinkParser.DeepLinkAction.Filters -> { 60 | graph.mutableFilterStorage.selectMany(deepLinkAction.selectedFilters) 61 | MainTabNavigator.Config.ListConfig() 62 | } 63 | } 64 | 65 | coroutineScope { 66 | this.launch(Dispatchers.Main) { 67 | mainTabNavigator.openFromDeepLink(config) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | 74 | private fun createChild(config: MainTabNavigator.Config, componentContext: ComponentContext): Child = 75 | when (config) { 76 | is MainTabNavigator.Config.ListConfig -> Child.List( 77 | componentsFactory.cocktailListComponent(componentContext, mainTabNavigator) 78 | ) 79 | 80 | is MainTabNavigator.Config.DetailsConfig -> Child.Details( 81 | componentsFactory.cocktailDetailsComponent( 82 | componentContext, 83 | CocktailId(config.id), 84 | mainTabNavigator 85 | ) 86 | ) 87 | 88 | is MainTabNavigator.Config.FilterConfig -> Child.Filters( 89 | componentsFactory.filterScreenComponent(componentContext, mainTabNavigator) 90 | ) 91 | 92 | is MainTabNavigator.Config.SearchItemConfig -> Child.ItemSearch( 93 | componentsFactory.searchItemScreen( 94 | componentContext, 95 | config.searchItemType, 96 | mainTabNavigator 97 | ) 98 | ) 99 | 100 | is MainTabNavigator.Config.ItemConfig -> Child.Item( 101 | componentsFactory.detailGoodsScreen( 102 | componentContext, mainTabNavigator, config.id, config.typeGoods 103 | ) 104 | ) 105 | 106 | is MainTabNavigator.Config.CommonTagConfig -> Child.CommonTagCocktails( 107 | componentsFactory.commonTagCocktailsComponent( 108 | componentContext, CommonTag(config.id, config.type), mainTabNavigator 109 | ) 110 | ) 111 | } 112 | 113 | sealed class Child { 114 | class List(val component: ListComponent) : Child() 115 | class Details(val component: DetailsComponent) : Child() 116 | class Filters(val component: FilterComponent) : Child() 117 | class Item(val component: ItemDetailComponent) : Child() 118 | class ItemSearch(val component: SearchItemComponent) : Child() 119 | class CommonTagCocktails(val component: CommonTagCocktailsComponent) : Child() 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/RootContent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.material.BottomNavigation 9 | import androidx.compose.material.BottomNavigationItem 10 | import androidx.compose.material.Colors 11 | import androidx.compose.material.Icon 12 | import androidx.compose.material.MaterialTheme 13 | import androidx.compose.material.Scaffold 14 | import androidx.compose.material.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.collectAsState 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.unit.dp 23 | import androidx.compose.ui.unit.sp 24 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children 25 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade 26 | import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.stackAnimation 27 | import org.jetbrains.compose.resources.ExperimentalResourceApi 28 | import org.jetbrains.compose.resources.painterResource 29 | import org.mixdrinks.app.styles.MixDrinksColors 30 | import org.mixdrinks.ui.auth.AuthView 31 | import org.mixdrinks.ui.main.MainContent 32 | import org.mixdrinks.ui.profile.ProfileContent 33 | 34 | @Composable 35 | internal fun RootContent(component: RootComponent, deepLink: String?) { 36 | MaterialTheme( 37 | colors = Colors( 38 | primary = MixDrinksColors.Main, 39 | primaryVariant = MixDrinksColors.Main, 40 | secondary = MixDrinksColors.Secondary, 41 | secondaryVariant = MixDrinksColors.Secondary, 42 | background = MixDrinksColors.White, 43 | surface = MixDrinksColors.White, 44 | error = MixDrinksColors.White, 45 | onPrimary = MixDrinksColors.White, 46 | onSecondary = MixDrinksColors.White, 47 | onBackground = MixDrinksColors.Black, 48 | onSurface = MixDrinksColors.White, 49 | onError = Color.Red, 50 | isLight = true, 51 | ) 52 | ) { 53 | RootScreen(component, deepLink) 54 | } 55 | 56 | LaunchedEffect(deepLink) { 57 | if (deepLink != null) { 58 | component.open(RootComponent.BottomNavigationTab.Main) 59 | } 60 | } 61 | } 62 | 63 | @Composable 64 | private fun RootScreen(component: RootComponent, deepLink: String?) { 65 | val showAuthDialog by component.showAuthDialog.collectAsState() 66 | val tabs by component.selectedTab.collectAsState() 67 | Box { 68 | Scaffold( 69 | bottomBar = { 70 | BottomNavigationBar(tabs, component) 71 | }, 72 | content = { paddingValues -> 73 | Children( 74 | modifier = Modifier.padding(paddingValues), 75 | stack = component.stack, 76 | animation = stackAnimation( 77 | animator = fade() 78 | ), 79 | content = { 80 | when (val child = it.instance) { 81 | is RootComponent.Child.Main -> MainContent(child.component, deepLink) 82 | is RootComponent.Child.Profile -> ProfileContent(child.component) 83 | } 84 | } 85 | ) 86 | } 87 | ) 88 | 89 | if (showAuthDialog) { 90 | Box(modifier = Modifier 91 | .clickable(enabled = false, onClick = { }) 92 | .fillMaxSize() 93 | .background(Color.DarkGray.copy(alpha = 0.5f)) 94 | ) { 95 | AuthView( 96 | modifier = Modifier 97 | .padding(16.dp) 98 | .align(Alignment.Center), 99 | onClose = { component.authFlowCancel() } 100 | ) 101 | } 102 | } 103 | } 104 | } 105 | 106 | @OptIn(ExperimentalResourceApi::class) 107 | @Composable 108 | private fun BottomNavigationBar(tabs: List, component: RootComponent) { 109 | BottomNavigation( 110 | backgroundColor = MixDrinksColors.Main, 111 | elevation = 4.dp 112 | ) { 113 | tabs.forEach { tab -> 114 | BottomNavigationItem( 115 | icon = { Icon(painterResource(tab.tab.icon), contentDescription = tab.tab.title) }, 116 | label = { Text(text = tab.tab.title, fontSize = 12.sp) }, 117 | selectedContentColor = Color.White, 118 | unselectedContentColor = Color.White.copy(alpha = 0.3f), 119 | alwaysShowLabel = true, 120 | selected = tab.isSelected, 121 | onClick = { 122 | component.open(tab.tab) 123 | } 124 | ) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/ProfileComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.decompose.router.stack.ChildStack 5 | import com.arkivanov.decompose.router.stack.StackNavigation 6 | import com.arkivanov.decompose.router.stack.childStack 7 | import com.arkivanov.decompose.value.Value 8 | import com.arkivanov.essenty.parcelable.Parcelable 9 | import com.arkivanov.essenty.parcelable.Parcelize 10 | import kotlin.native.concurrent.ThreadLocal 11 | import org.mixdrinks.di.ComponentsFactory 12 | import org.mixdrinks.dto.CocktailId 13 | import org.mixdrinks.ui.details.DetailsComponent 14 | import org.mixdrinks.ui.items.ItemDetailComponent 15 | import org.mixdrinks.ui.profile.root.ProfileRootComponent 16 | import org.mixdrinks.ui.tag.CommonTag 17 | import org.mixdrinks.ui.tag.CommonTagCocktailsComponent 18 | import org.mixdrinks.ui.visited.VisitedCocktailsComponent 19 | 20 | internal class ProfileComponent( 21 | private val componentContext: ComponentContext, 22 | private val componentsFactory: ComponentsFactory, 23 | ) : ComponentContext by componentContext { 24 | 25 | private val navigation = StackNavigation() 26 | 27 | private val profileTabNavigator: ProfileNavigator = ProfileNavigator(navigation) 28 | 29 | private val _stack: Value> = childStack( 30 | source = navigation, 31 | initialConfiguration = ProfileContentConfig.ProfileRoot, 32 | handleBackButton = true, 33 | childFactory = ::createChild 34 | ) 35 | 36 | val stack: Value> = _stack 37 | 38 | private fun createChild(config: ProfileContentConfig, componentContext: ComponentContext): ProfileChild { 39 | return when (config) { 40 | is ProfileContentConfig.ProfileRoot -> ProfileChild.ProfileRoot( 41 | componentsFactory.profileRootComponent( 42 | componentContext, 43 | profileTabNavigator, 44 | ) 45 | ) 46 | is ProfileContentConfig.CommonTagConfig -> ProfileChild.CommonTag( 47 | componentsFactory.commonTagCocktailsComponent( 48 | componentContext, CommonTag(config.id, config.type), profileTabNavigator, 49 | ) 50 | ) 51 | 52 | is ProfileContentConfig.DetailsConfig -> ProfileChild.Details( 53 | componentsFactory.cocktailDetailsComponent(componentContext, CocktailId(config.id), profileTabNavigator) 54 | ) 55 | 56 | is ProfileContentConfig.ItemConfig -> ProfileChild.Item( 57 | componentsFactory.detailGoodsScreen( 58 | componentContext = componentContext, 59 | itemDetailsNavigation = profileTabNavigator, 60 | id = config.id, 61 | type = config.typeGoods 62 | ) 63 | ) 64 | 65 | is ProfileContentConfig.VisitedCocktailsConfig -> ProfileChild.VisitedCocktails( 66 | componentsFactory.visitedCocktailsComponent(componentContext, profileTabNavigator) 67 | ) 68 | } 69 | } 70 | 71 | sealed class ProfileContentConfig(open val operationIndex: Int) : Parcelable { 72 | 73 | @Parcelize 74 | data object ProfileRoot: ProfileContentConfig(0) 75 | 76 | @Parcelize 77 | data class VisitedCocktailsConfig( 78 | override val operationIndex: Int, 79 | ) : ProfileContentConfig(operationIndex) { 80 | constructor(): this(Companion.operation++) 81 | } 82 | 83 | 84 | @Parcelize 85 | data class DetailsConfig( 86 | val id: Int, 87 | override val operationIndex: Int, 88 | ) : ProfileContentConfig(operationIndex) { 89 | constructor(id: Int) : this(id, Companion.operation++) 90 | } 91 | 92 | @Parcelize 93 | data class ItemConfig( 94 | val id: Int, 95 | val typeGoods: String, 96 | override val operationIndex: Int, 97 | ) : ProfileContentConfig(operationIndex) { 98 | constructor(id: Int, itemType: String) : this(id, itemType, Companion.operation++) 99 | } 100 | 101 | @Parcelize 102 | data class CommonTagConfig( 103 | val id: Int, 104 | val type: CommonTag.Type, 105 | override val operationIndex: Int, 106 | ) : ProfileContentConfig(operationIndex) { 107 | constructor(id: Int, type: CommonTag.Type) : this(id, type, operation++) 108 | } 109 | 110 | @ThreadLocal 111 | companion object { 112 | private var operation: Int = 0 113 | } 114 | } 115 | 116 | sealed class ProfileChild { 117 | class ProfileRoot(val component: ProfileRootComponent) : ProfileChild() 118 | class VisitedCocktails(val component: VisitedCocktailsComponent) : ProfileChild() 119 | class Details(val component: DetailsComponent) : ProfileChild() 120 | class Item(val component: ItemDetailComponent) : ProfileChild() 121 | class CommonTag(val component: CommonTagCocktailsComponent) : ProfileChild() 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/profile/root/ProfileRootContent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.profile.root 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxHeight 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.Button 14 | import androidx.compose.material.Card 15 | import androidx.compose.material.OutlinedButton 16 | import androidx.compose.material.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.mutableStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.graphics.Color 23 | import androidx.compose.ui.text.font.FontWeight 24 | import androidx.compose.ui.unit.dp 25 | import androidx.compose.ui.unit.sp 26 | import org.mixdrinks.app.styles.MixDrinksColors 27 | import org.mixdrinks.app.styles.MixDrinksTextStyles 28 | import org.mixdrinks.app.utils.ResString 29 | import org.mixdrinks.ui.widgets.MixDrinksHeader 30 | 31 | @Composable 32 | internal fun ProfileRootContent(component: ProfileRootComponent) { 33 | val showAuthDialog = remember { mutableStateOf(false) } 34 | Box { 35 | Column { 36 | MixDrinksHeader( 37 | name = ResString.profile, 38 | ) 39 | ProfileButton(ResString.visitedCocktails, MixDrinksColors.Black) { 40 | component.navigateToVisitedCocktails() 41 | } 42 | ProfileButton(ResString.logout, MixDrinksColors.Black) { 43 | component.logout() 44 | } 45 | ProfileButton(ResString.deleteAccount, MixDrinksColors.Red) { 46 | showAuthDialog.value = true 47 | } 48 | } 49 | 50 | if (showAuthDialog.value) { 51 | CustomAlertDialog( 52 | onDismiss = { showAuthDialog.value = false }, 53 | onConfirm = { component.deleteAccount() } 54 | ) 55 | } 56 | } 57 | } 58 | 59 | @Composable 60 | private fun ProfileButton( 61 | text: String, 62 | color: Color, 63 | onClick: () -> Unit, 64 | ) { 65 | Card( 66 | modifier = Modifier.background( 67 | color = MixDrinksColors.White, 68 | shape = RoundedCornerShape(8.dp) 69 | ) 70 | .fillMaxWidth() 71 | .height(56.dp) 72 | .padding(horizontal = 8.dp, vertical = 4.dp) 73 | .clickable { onClick() }, 74 | ) { 75 | Text( 76 | modifier = Modifier 77 | .padding(8.dp) 78 | .fillMaxWidth() 79 | .height(48.dp), 80 | text = text.uppercase(), 81 | style = MixDrinksTextStyles.H3.copy(fontWeight = FontWeight.W400), 82 | color = color, 83 | ) 84 | } 85 | } 86 | 87 | @Composable 88 | private fun CustomAlertDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { 89 | Box( 90 | Modifier 91 | .fillMaxWidth() 92 | .fillMaxHeight() 93 | .background(Color.Black.copy(alpha = 0.5F)) 94 | ) { 95 | Card( 96 | shape = RoundedCornerShape(8.dp), 97 | modifier = Modifier 98 | .align(Alignment.Center) 99 | .fillMaxWidth() 100 | .padding(8.dp), 101 | elevation = 8.dp 102 | ) { 103 | Column( 104 | Modifier 105 | .fillMaxWidth() 106 | .background(Color.White) 107 | ) { 108 | Text( 109 | text = ResString.deleteAccountDialogMessage, 110 | modifier = Modifier.padding(8.dp), fontSize = 20.sp 111 | ) 112 | 113 | Row(Modifier.padding(top = 10.dp)) { 114 | OutlinedButton( 115 | onClick = { onDismiss() }, 116 | 117 | Modifier 118 | .fillMaxWidth() 119 | .padding(8.dp) 120 | .weight(1F) 121 | ) { 122 | Text( 123 | text = ResString.deleteAccountDialogNo, 124 | style = MixDrinksTextStyles.H4, 125 | ) 126 | } 127 | Button( 128 | onClick = { onConfirm() }, 129 | Modifier 130 | .fillMaxWidth() 131 | .padding(8.dp) 132 | .weight(1F) 133 | ) { 134 | Text( 135 | text = ResString.deleteAccountDialogYes, 136 | style = MixDrinksTextStyles.H4, 137 | ) 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/RootComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui 2 | 3 | import com.arkivanov.decompose.ComponentContext 4 | import com.arkivanov.decompose.router.stack.ChildStack 5 | import com.arkivanov.decompose.router.stack.StackNavigation 6 | import com.arkivanov.decompose.router.stack.bringToFront 7 | import com.arkivanov.decompose.router.stack.childStack 8 | import com.arkivanov.decompose.value.Value 9 | import com.arkivanov.essenty.parcelable.Parcelable 10 | import com.arkivanov.essenty.parcelable.Parcelize 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.SupervisorJob 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.flow.collectLatest 16 | import kotlinx.coroutines.launch 17 | import kotlinx.coroutines.plus 18 | import kotlinx.coroutines.withContext 19 | import org.mixdrinks.di.ComponentsFactory 20 | import org.mixdrinks.di.Graph 21 | import org.mixdrinks.ui.main.MainComponent 22 | import org.mixdrinks.ui.profile.ProfileComponent 23 | import org.mixdrinks.ui.widgets.undomain.scope 24 | 25 | internal class RootComponent( 26 | private val componentContext: ComponentContext, 27 | private val graph: Graph, 28 | ) : ComponentContext by componentContext { 29 | 30 | private val navigation = StackNavigation() 31 | 32 | private val _stack: Value> = childStack( 33 | source = navigation, 34 | initialConfiguration = Config.Main, 35 | handleBackButton = true, 36 | childFactory = ::createChild 37 | ) 38 | 39 | val stack: Value> = _stack 40 | 41 | private val _selectedTab = MutableStateFlow(listOf( 42 | TabUiModel(BottomNavigationTab.Main, true), 43 | TabUiModel(BottomNavigationTab.Profile, false) 44 | )) 45 | 46 | init { 47 | stack.subscribe { 48 | if (it.active.configuration == Config.Main) { 49 | _selectedTab.tryEmit(listOf( 50 | TabUiModel(BottomNavigationTab.Main, true), 51 | TabUiModel(BottomNavigationTab.Profile, false) 52 | )) 53 | } 54 | } 55 | 56 | graph.authBus.registerLogoutNotifier { 57 | openTab(BottomNavigationTab.Main) 58 | } 59 | } 60 | 61 | val selectedTab: StateFlow> = _selectedTab 62 | 63 | val showAuthDialog: StateFlow = graph.authBus.showAuthDialog 64 | 65 | private fun createChild(config: Config, componentContext: ComponentContext): Child { 66 | return when (config) { 67 | is Config.Main -> Child.Main(createMainComponent(componentContext)) 68 | is Config.Profile -> Child.Profile(createProfileComponent(componentContext)) 69 | } 70 | } 71 | 72 | private fun createMainComponent(componentContext: ComponentContext): MainComponent { 73 | return MainComponent(componentContext, graph, ComponentsFactory(graph)) 74 | } 75 | 76 | private fun createProfileComponent(componentContext: ComponentContext): ProfileComponent { 77 | return ProfileComponent( 78 | componentContext, 79 | ComponentsFactory(graph), 80 | ) 81 | } 82 | 83 | fun open(tab: BottomNavigationTab) { 84 | when (tab) { 85 | BottomNavigationTab.Main -> { 86 | openTab(BottomNavigationTab.Main) 87 | } 88 | 89 | BottomNavigationTab.Profile -> { 90 | openProfile() 91 | } 92 | } 93 | } 94 | 95 | fun authFlowCancel() { 96 | graph.authBus.tryEmit(false) 97 | } 98 | 99 | private val loginDialogScope = scope + SupervisorJob() 100 | 101 | private fun openProfile() { 102 | if (graph.tokenStorage.getToken() != null) { 103 | openTab(BottomNavigationTab.Profile) 104 | return 105 | } 106 | loginDialogScope.launch { 107 | graph.authBus.emit(true) 108 | graph.tokenStorage.tokenFlow 109 | .collectLatest { 110 | if (it != null) { 111 | graph.authBus.emit(false) 112 | withContext(Dispatchers.Main) { 113 | openTab(BottomNavigationTab.Profile) 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | private fun openTab(newTab: BottomNavigationTab) { 121 | scope.launch { 122 | _selectedTab.emit(BottomNavigationTab.values() 123 | .map { tab -> 124 | TabUiModel(tab, tab == newTab) 125 | }) 126 | } 127 | 128 | val config = when (newTab) { 129 | BottomNavigationTab.Main -> Config.Main 130 | BottomNavigationTab.Profile -> Config.Profile 131 | } 132 | 133 | navigation.bringToFront(config) 134 | } 135 | 136 | data class TabUiModel( 137 | val tab: BottomNavigationTab, 138 | val isSelected: Boolean, 139 | ) 140 | 141 | enum class BottomNavigationTab( 142 | val icon: String, 143 | val title: String, 144 | ) { 145 | Main( 146 | icon = "ic_home.xml", 147 | title = "Головна" 148 | ), 149 | Profile( 150 | icon = "ic_profile.xml", 151 | title = "Профіль" 152 | ) 153 | } 154 | 155 | sealed class Config : Parcelable { 156 | 157 | @Parcelize 158 | object Main : Config() 159 | 160 | @Parcelize 161 | object Profile : Config() 162 | } 163 | 164 | sealed class Child { 165 | data class Main(val component: MainComponent) : Child() 166 | data class Profile(val component: ProfileComponent) : Child() 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/filters/main/FilterView.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.filters.main 2 | 3 | import androidx.compose.animation.core.tween 4 | import androidx.compose.foundation.BorderStroke 5 | import androidx.compose.foundation.ExperimentalFoundationApi 6 | import androidx.compose.foundation.background 7 | import androidx.compose.foundation.clickable 8 | import androidx.compose.foundation.layout.Box 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.height 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.lazy.LazyColumn 16 | import androidx.compose.foundation.lazy.items 17 | import androidx.compose.foundation.shape.RoundedCornerShape 18 | import androidx.compose.material.Button 19 | import androidx.compose.material.ButtonDefaults 20 | import androidx.compose.material.Surface 21 | import androidx.compose.material.Text 22 | import androidx.compose.material.TopAppBar 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.graphics.Color 27 | import androidx.compose.ui.unit.dp 28 | import org.mixdrinks.app.styles.MixDrinksColors 29 | import org.mixdrinks.app.styles.MixDrinksTextStyles 30 | import org.mixdrinks.app.utils.ResString 31 | import org.mixdrinks.dto.FilterGroupId 32 | import org.mixdrinks.ui.filters.FilterItem 33 | import org.mixdrinks.ui.widgets.CustomButton 34 | import org.mixdrinks.ui.widgets.undomain.ContentHolder 35 | import org.mixdrinks.ui.widgets.undomain.FlowRow 36 | 37 | @Composable 38 | internal fun FilterView(filterComponent: FilterComponent) { 39 | Surface( 40 | modifier = Modifier.fillMaxSize(), 41 | color = MixDrinksColors.White, 42 | ) { 43 | Column(modifier = Modifier.fillMaxSize()) { 44 | TopAppBar( 45 | backgroundColor = Color.White, 46 | title = { 47 | Text( 48 | color = MixDrinksColors.Black, 49 | text = ResString.filters, 50 | style = MixDrinksTextStyles.H1, 51 | ) 52 | }, 53 | actions = { 54 | Text( 55 | modifier = Modifier 56 | .clickable { filterComponent.clear() }, 57 | color = MixDrinksColors.Black, 58 | text = ResString.clear, 59 | style = MixDrinksTextStyles.H1, 60 | ) 61 | } 62 | ) 63 | 64 | Box( 65 | modifier = Modifier.weight(1F) 66 | .padding(horizontal = 8.dp) 67 | ) { 68 | ContentHolder( 69 | stateflow = filterComponent.state, 70 | ) { groups -> 71 | FilterContent(groups, filterComponent) 72 | } 73 | } 74 | 75 | Box( 76 | modifier = Modifier 77 | .background(Color.White) 78 | .fillMaxWidth() 79 | ) { 80 | CustomButton( 81 | Modifier.align(Alignment.Center) 82 | .padding(horizontal = 8.dp), 83 | ResString.apply, 84 | filterComponent::close 85 | ) 86 | } 87 | } 88 | } 89 | } 90 | 91 | @OptIn(ExperimentalFoundationApi::class) 92 | @Composable 93 | private fun FilterContent(groups: List, filterComponent: FilterComponent) { 94 | LazyColumn { 95 | items(items = groups, key = { it.key }) { filterGroupUi -> 96 | when (filterGroupUi) { 97 | is FilterComponent.FilterScreenElement.FilterGroupUi -> { 98 | FlowRow( 99 | mainAxisSpacing = 4.dp, 100 | crossAxisSpacing = 4.dp 101 | ) { 102 | filterGroupUi.filterItems.forEach { filterItem -> 103 | FilterItem( 104 | modifier = Modifier.animateItemPlacement(tween()), 105 | filterUi = filterItem, 106 | onValue = filterComponent::onFilterStateChange, 107 | ) 108 | } 109 | } 110 | } 111 | 112 | is FilterComponent.FilterScreenElement.Title -> { 113 | Text( 114 | modifier = Modifier 115 | .animateItemPlacement(tween()) 116 | .padding(bottom = 12.dp), 117 | color = MixDrinksColors.Black, 118 | text = filterGroupUi.name, 119 | style = MixDrinksTextStyles.H2, 120 | ) 121 | } 122 | 123 | is FilterComponent.FilterScreenElement.FilterOpenSearch -> { 124 | AddMoreFilterButton( 125 | modifier = Modifier 126 | .animateItemPlacement(tween()) 127 | .fillMaxWidth(), 128 | filterGroupId = filterGroupUi.filterGroupId, 129 | text = filterGroupUi.text, 130 | filterComponent = filterComponent, 131 | ) 132 | } 133 | } 134 | } 135 | item { 136 | Spacer(modifier = Modifier.fillMaxWidth().height(32.dp)) 137 | } 138 | } 139 | } 140 | 141 | @Composable 142 | internal fun AddMoreFilterButton( 143 | modifier: Modifier = Modifier, 144 | filterGroupId: FilterGroupId, 145 | text: String, 146 | filterComponent: FilterComponent, 147 | ) { 148 | Button( 149 | modifier = modifier 150 | .fillMaxWidth() 151 | .padding(top = 4.dp) 152 | .height(32.dp), 153 | onClick = { filterComponent.openDetailSearch(filterGroupId) }, 154 | colors = ButtonDefaults.buttonColors(MixDrinksColors.White), 155 | shape = RoundedCornerShape(16.dp), 156 | border = BorderStroke(1.dp, MixDrinksColors.Main) 157 | ) { 158 | Text( 159 | text = text, 160 | color = MixDrinksColors.Main, 161 | style = MixDrinksTextStyles.H6, 162 | ) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /shared/src/commonMain/kotlin/org/mixdrinks/ui/filters/main/FilterComponent.kt: -------------------------------------------------------------------------------- 1 | package org.mixdrinks.ui.filters.main 2 | 3 | import androidx.compose.runtime.Immutable 4 | import com.arkivanov.decompose.ComponentContext 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.flow.StateFlow 7 | import kotlinx.coroutines.flow.flowOn 8 | import kotlinx.coroutines.flow.transform 9 | import org.mixdrinks.data.FutureCocktailSelector 10 | import org.mixdrinks.domain.FilterGroups 11 | import org.mixdrinks.dto.FilterGroupDto 12 | import org.mixdrinks.dto.FilterGroupId 13 | import org.mixdrinks.dto.FilterId 14 | import org.mixdrinks.dto.SelectionType 15 | import org.mixdrinks.ui.filters.FilterItemUiModel 16 | import org.mixdrinks.ui.filters.FilterValueChangeDelegate 17 | import org.mixdrinks.ui.filters.search.SearchItemComponent 18 | import org.mixdrinks.ui.list.main.MutableFilterStorage 19 | import org.mixdrinks.ui.navigation.MainTabNavigator 20 | import org.mixdrinks.ui.widgets.undomain.UiState 21 | import org.mixdrinks.ui.widgets.undomain.launch 22 | import org.mixdrinks.ui.widgets.undomain.stateInWhileSubscribe 23 | 24 | internal class FilterComponent( 25 | private val componentContext: ComponentContext, 26 | private val mutableFilterStorage: MutableFilterStorage, 27 | private val futureCocktailSelector: FutureCocktailSelector, 28 | private val mainTabNavigator: MainTabNavigator, 29 | ) : ComponentContext by componentContext, 30 | FilterValueChangeDelegate by mutableFilterStorage { 31 | 32 | val state: StateFlow>> = mutableFilterStorage.selected 33 | .transform { selected -> 34 | this.emit( 35 | UiState.Data(mutableFilterStorage.getFilterGroups() 36 | .flatMap { filterGroupDto -> 37 | flatMapFilterGroup(filterGroupDto, selected) 38 | }) 39 | ) 40 | } 41 | .flowOn(Dispatchers.Default) 42 | .stateInWhileSubscribe() 43 | 44 | private suspend fun flatMapFilterGroup( 45 | filterGroupDto: FilterGroupDto, 46 | selected: Map>, 47 | ): List { 48 | val list = listOf( 49 | FilterScreenElement.Title(filterGroupDto.name), 50 | ) 51 | 52 | return when (filterGroupDto.id) { 53 | FilterGroups.TAGS.id -> emptyList() 54 | !in listOf(FilterGroups.GOODS.id, FilterGroups.TOOLS.id) -> { 55 | list.plus( 56 | FilterScreenElement.FilterGroupUi( 57 | filterGroupId = filterGroupDto.id, 58 | filterItems = buildFilterItems(filterGroupDto, selected) 59 | ) 60 | ) 61 | } 62 | 63 | else -> { 64 | list 65 | .plus( 66 | FilterScreenElement.FilterGroupUi( 67 | filterGroupId = filterGroupDto.id, 68 | filterItems = buildSelectedFilterItems( 69 | filterGroupDto, 70 | selected[filterGroupDto.id].orEmpty().map { it.filterId } 71 | ) 72 | ) 73 | ) 74 | .plus( 75 | FilterScreenElement.FilterOpenSearch( 76 | filterGroupDto.id, 77 | "Додати ${filterGroupDto.name.lowercase()} до фільтру" 78 | ) 79 | ) 80 | } 81 | } 82 | } 83 | 84 | private fun buildSelectedFilterItems( 85 | filterGroupDto: FilterGroupDto, 86 | filters: List, 87 | ): List { 88 | return filterGroupDto.filters 89 | .filter { it.id in filters } 90 | .map { filter -> 91 | FilterItemUiModel( 92 | groupId = filterGroupDto.id, 93 | id = filter.id, 94 | name = filter.name, 95 | isSelect = true, 96 | isEnable = true, 97 | ) 98 | } 99 | } 100 | 101 | private suspend fun buildFilterItems( 102 | filterGroupDto: FilterGroupDto, 103 | selected: Map>, 104 | ): List { 105 | val filters = filterGroupDto.filters.map { filter -> 106 | val cocktailCount = futureCocktailSelector.getCocktailIds( 107 | filterGroupDto.id, 108 | filter.id, 109 | ) 110 | .size 111 | 112 | val isSelected = 113 | selected[filterGroupDto.id].orEmpty().map { it.filterId }.contains(filter.id) 114 | val isEnable = 115 | isSelected || filterGroupDto.selectionType == SelectionType.SINGLE || cocktailCount != 0 116 | 117 | FilterItemUiModel( 118 | groupId = filterGroupDto.id, 119 | id = filter.id, 120 | name = filter.name, 121 | isSelect = isSelected, 122 | isEnable = isEnable, 123 | ) 124 | } 125 | 126 | return filters 127 | } 128 | 129 | fun clear() = launch { 130 | mutableFilterStorage.clear() 131 | } 132 | 133 | fun close() { 134 | mainTabNavigator.back() 135 | } 136 | 137 | fun openDetailSearch(filterGroupId: FilterGroupId) { 138 | val searchItemType = when (filterGroupId) { 139 | FilterGroups.GOODS.id -> SearchItemComponent.SearchItemType.GOODS 140 | FilterGroups.TOOLS.id -> SearchItemComponent.SearchItemType.TOOLS 141 | else -> error("Unknown filter group id: $filterGroupId") 142 | } 143 | 144 | mainTabNavigator.navigateToSearchItem(searchItemType) 145 | } 146 | 147 | @Immutable 148 | sealed class FilterScreenElement(val key: Int) { 149 | @Immutable 150 | data class Title( 151 | val name: String, 152 | ) : FilterScreenElement(name.hashCode()) 153 | 154 | @Immutable 155 | data class FilterGroupUi( 156 | val filterGroupId: FilterGroupId, 157 | val filterItems: List, 158 | ) : FilterScreenElement(filterGroupId.value) 159 | 160 | @Immutable 161 | data class FilterOpenSearch( 162 | val filterGroupId: FilterGroupId, 163 | val text: String, 164 | ) : FilterScreenElement(-filterGroupId.value /*Use - for make key difference from Title element*/) 165 | } 166 | 167 | } 168 | --------------------------------------------------------------------------------