From 7a91650e4c71b7f859902ab30942385f14768d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luka=CC=81s=CC=8C=20Matus=CC=8Cka?= Date: Tue, 14 Apr 2026 17:31:55 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20[iOS,=20KMM,=20AN]=20Native=20n?= =?UTF-8?q?avigation=20bar=20with=20Liquid=20Glass=20support=20on=20iOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ^ Conflicts: ^ android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt ^ build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt ^ gradle/libs.versions.toml ^ ios/.swiftlint.yml ^ shared/samplefeature/src/iosMain/kotlin/kmp/shared/samplefeature/presentation/SampleFeatureMainScreenViewController.kt --- .../samplefeature/ui/SampleFeatureMain.kt | 58 +---- .../KmpLibraryComposeConventionPlugin.kt | 6 +- gradle/libs.versions.toml | 6 +- ios/Application/AppDelegate.swift | 8 - .../SharedDomain/ViewModels+Extensions.swift | 9 + .../SampleFeature/SampleFeatureView.swift | 26 +- .../Extensions/Moko+Extensions.swift | 16 +- .../Extensions/View+Extensions.swift | 34 ++- .../View+NavigationBarTitleColor.swift | 70 ++++++ .../UIToolkit/Extensions/View+Toolbar.swift | 154 ++++++++++++ ios/scripts/build-kmp.sh | 8 +- ios/scripts/generate-strings.sh | 5 + .../base/presentation/ui/HazeIconButton.kt | 50 ++++ .../base/presentation/ui/HazeTextButton.kt | 51 ++++ .../base/presentation/ui/Keyboard.android.kt | 32 +++ .../base/presentation/ui/NativeScaffold.kt | 234 ++++++++++++++++++ .../vm/BaseScopedViewModel.android.kt | 13 + .../shared/base/presentation/ui/Keyboard.kt | 17 ++ .../base/presentation/ui/NativeScaffold.kt | 16 ++ .../shared/base/presentation/ui/Toolbar.kt | 73 ++++++ .../presentation/vm/BaseScopedViewModel.kt | 8 + .../images/toolbar_brand_logo-dark@1x.png | Bin 0 -> 4424 bytes .../images/toolbar_brand_logo-dark@2x.png | Bin 0 -> 7212 bytes .../images/toolbar_brand_logo-dark@3x.png | Bin 0 -> 1998 bytes .../images/toolbar_brand_logo-light@1x.png | Bin 0 -> 4754 bytes .../images/toolbar_brand_logo-light@2x.png | Bin 0 -> 8356 bytes .../images/toolbar_brand_logo-light@3x.png | Bin 0 -> 8515 bytes .../base/presentation/ui/BlurredContainer.kt | 96 +++++++ .../base/presentation/ui/Keyboard.ios.kt | 90 +++++++ .../base/presentation/ui/NativeColor.kt | 10 + .../base/presentation/ui/NativeScaffold.kt | 44 ++++ .../vm/BaseScopedViewModel.ios.kt | 10 + .../ui/SampleFeatureMainScreen.kt | 52 ++-- .../presentation/ui/SampleFeatureRoute.kt | 42 ++++ .../presentation/vm/SampleFeatureViewModel.kt | 9 + .../SampleFeatureMainScreenViewController.kt | 23 +- 36 files changed, 1146 insertions(+), 124 deletions(-) create mode 100644 ios/DomainLayer/SharedDomain/Sources/SharedDomain/ViewModels+Extensions.swift create mode 100644 ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+NavigationBarTitleColor.swift create mode 100644 ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Toolbar.swift create mode 100644 shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeIconButton.kt create mode 100644 shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeTextButton.kt create mode 100644 shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.android.kt create mode 100644 shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt create mode 100644 shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.kt create mode 100644 shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt create mode 100644 shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Toolbar.kt create mode 100644 shared/base/src/commonMain/moko-resources/images/toolbar_brand_logo-dark@1x.png create mode 100644 shared/base/src/commonMain/moko-resources/images/toolbar_brand_logo-dark@2x.png create mode 100644 shared/base/src/commonMain/moko-resources/images/toolbar_brand_logo-dark@3x.png create mode 100644 shared/base/src/commonMain/moko-resources/images/toolbar_brand_logo-light@1x.png create mode 100644 shared/base/src/commonMain/moko-resources/images/toolbar_brand_logo-light@2x.png create mode 100644 shared/base/src/commonMain/moko-resources/images/toolbar_brand_logo-light@3x.png create mode 100644 shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/BlurredContainer.kt create mode 100644 shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.ios.kt create mode 100644 shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/NativeColor.kt create mode 100644 shared/base/src/iosMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt create mode 100644 shared/samplefeature/src/commonMain/kotlin/kmp/shared/samplefeature/presentation/ui/SampleFeatureRoute.kt diff --git a/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt index afbc580f..a9cdb45a 100644 --- a/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt +++ b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt @@ -1,64 +1,20 @@ package kmp.android.samplefeature.ui import android.widget.Toast -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.displayCutout -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import dev.icerock.moko.resources.compose.stringResource -import kmp.shared.base.MR -import kmp.shared.samplefeature.presentation.ui.SampleFeatureMainScreen -import kmp.shared.samplefeature.presentation.vm.SampleFeatureEvent -import kmp.shared.samplefeature.presentation.vm.SampleFeatureIntent +import kmp.shared.samplefeature.presentation.ui.SampleFeatureRoute import kmp.shared.samplefeature.presentation.vm.SampleFeatureViewModel -import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun SampleFeatureMainRoute( - viewModel: SampleFeatureViewModel = koinViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = viewModel) { - viewModel.onViewAppeared() - } - +internal fun SampleFeatureMainRoute(viewModel: SampleFeatureViewModel = koinViewModel()) { val context = LocalContext.current - LaunchedEffect(viewModel) { - viewModel.events.collectLatest { event -> - when (event) { - is SampleFeatureEvent.ShowMessage -> Toast.makeText( - context, - event.message, - Toast.LENGTH_SHORT, - ).show() - } - } - } - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = stringResource(MR.strings.sample_feature_title)) }, - windowInsets = WindowInsets.displayCutout, - ) + SampleFeatureRoute( + viewModel = viewModel, + onShowMessage = { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }, - ) { padding -> - SampleFeatureMainScreen( - state = state, - onIntent = { viewModel.onIntent(it) }, - modifier = Modifier.consumeWindowInsets(padding), - ) - } + ) } diff --git a/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt index 0bd46bbc..d9adc75d 100644 --- a/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/plugin/KmpLibraryComposeConventionPlugin.kt @@ -1,7 +1,6 @@ package plugin import extensions.apply -import extensions.compose import extensions.debugImplementation import extensions.ktlintRuleset import extensions.libs @@ -34,6 +33,9 @@ class KmpLibraryComposeConventionPlugin : Plugin { implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.uiUtil) implementation(libs.jetbrains.compose.uiToolingPreview) + implementation(libs.mokoResources.compose) + implementation(libs.haze) + implementation(libs.haze.materials) ktlintRuleset(libs.ktlint.composeRules) } } @@ -45,4 +47,4 @@ class KmpLibraryComposeConventionPlugin : Plugin { } } } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4d0190a..581fef48 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ koin = "4.2.1" androidXCore = "1.18.0" lifecycle = "2.10.0" paging = "3.4.2" -jetbrains-compose = "1.11.0-beta01" # Note: version 1.11.X fixes iOS lags +jetbrains-compose = "1.11.0-alpha03" # Note: version 1.11.X fixes iOS lags, `haze` supports alpha03 jetbrains-compose-material3 = "1.9.0" activity = "1.13.0" navigation3 = "1.0.1" @@ -38,6 +38,7 @@ skie = "0.10.11" firebase = "22.5.0" googleServices = "4.4.4" sentiary = "1.0.1" +haze = "1.7.2" [libraries] # Kotlin @@ -125,6 +126,9 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx", ve ktlint-gradlePlugin = { module = "org.jlleitschuh.gradle.ktlint:org.jlleitschuh.gradle.ktlint.gradle.plugin", version.ref = "ktLint" } # Sentiary sentiary-gradlePlugin = { module = "com.sentiary:gradle-plugin", version.ref = "sentiary" } +# Haze +haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } +haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } [bundles] settings = [ diff --git a/ios/Application/AppDelegate.swift b/ios/Application/AppDelegate.swift index 1552924f..f6c48c64 100644 --- a/ios/Application/AppDelegate.swift +++ b/ios/Application/AppDelegate.swift @@ -79,14 +79,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: Setup appearance private func setupAppearance() { - // Navigation bar - let appearance = UINavigationBarAppearance() - appearance.backgroundColor = UIColor(AppTheme.Colors.navBarBackground) - appearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor(AppTheme.Colors.navBarTitle)] - UINavigationBar.appearance().standardAppearance = appearance - UINavigationBar.appearance().scrollEdgeAppearance = appearance - UINavigationBar.appearance().tintColor = UIColor(AppTheme.Colors.navBarTitle) - // Tab bar UITabBar.appearance().tintColor = UIColor(AppTheme.Colors.primaryColor) diff --git a/ios/DomainLayer/SharedDomain/Sources/SharedDomain/ViewModels+Extensions.swift b/ios/DomainLayer/SharedDomain/Sources/SharedDomain/ViewModels+Extensions.swift new file mode 100644 index 00000000..79596a7e --- /dev/null +++ b/ios/DomainLayer/SharedDomain/Sources/SharedDomain/ViewModels+Extensions.swift @@ -0,0 +1,9 @@ +// +// Created by Lukáš Matuška on 14.04.2026 +// Copyright © 2026 Matee. All rights reserved. +// + +import Foundation +import KMPShared + +extension SampleFeatureViewModel: @retroactive ObservableObject { } diff --git a/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift b/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift index 4e112710..1f5f74e3 100644 --- a/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift +++ b/ios/PresentationLayer/SampleFeature/Sources/SampleFeature/SampleFeatureView.swift @@ -11,28 +11,26 @@ import SwiftUI import UIToolkit public struct SampleFeatureView: View { - + @State private var toastData: ToastData? - @Injected(\.sampleFeatureViewModel) private var viewModel: SampleFeatureViewModel - + @InjectedObject(\.sampleFeatureViewModel) private var viewModel: SampleFeatureViewModel + public init() {} - + public var body: some View { ManagedNavigationStack { _ in ComposeViewController { - SampleFeatureMainScreenViewController(viewModel: viewModel) + SampleFeatureMainScreenViewController( + viewModel: viewModel, + onShowMessage: { message in + toastData = ToastData(message, hideAfter: 2) + } + ) } + .ignoresSafeArea() .frame(maxWidth: .infinity, maxHeight: .infinity) + .bindViewModel(viewModel) } - .tint(AppTheme.Colors.navBarTitle) // Back button color .toastView($toastData) - .bindViewModel(viewModel, onEvent: onEvent) - } - - private func onEvent(_ event: SampleFeatureEvent) { - switch onEnum(of: event) { - case .showMessage(let data): - toastData = ToastData(data.message, hideAfter: 2) - } } } diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Moko+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Moko+Extensions.swift index 8a9510cf..92b17ee7 100644 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Moko+Extensions.swift +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/Moko+Extensions.swift @@ -5,10 +5,22 @@ import Foundation import KMPShared +import SwiftUI public extension StringResource { - func toLocalized() -> String { - return self.desc().localized() + self.desc().localized() + } +} + +public extension StringDesc { + func toLocalized() -> String { + localized() + } +} + +public extension Image { + init(_ resource: KMPShared.ImageResource) { + self.init(resource.assetImageName, bundle: resource.bundle) } } diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift index 42ef97cd..d073218d 100644 --- a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Extensions.swift @@ -40,21 +40,33 @@ public extension View { @MainActor public extension View { func bindViewModel( - _ viewModel: BaseScopedViewModel, - onEvent: @escaping (E) -> Void + _ viewModel: BaseScopedViewModel ) -> some View { self - .task { - // Make sure that onViewAppeared will be called after event subcsription - Task { - viewModel.onViewAppeared() - } - for await event in viewModel.events { - onEvent(event) - } - } + .modifier(ToolbarBindingModifier(viewModel: viewModel)) .onDismiss { viewModel.clearScope() } } } + +private struct ToolbarBindingModifier: ViewModifier { + let viewModel: BaseScopedViewModel + + @State private var toolbar: Toolbar? + + init(viewModel: BaseScopedViewModel) { + self.viewModel = viewModel + _toolbar = State(initialValue: viewModel.toolbar.value) + } + + func body(content: Content) -> some View { + content + .task { + for await toolbar in viewModel.toolbar { + self.toolbar = toolbar + } + } + .toolbar(toolbar) + } +} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+NavigationBarTitleColor.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+NavigationBarTitleColor.swift new file mode 100644 index 00000000..5d80972d --- /dev/null +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+NavigationBarTitleColor.swift @@ -0,0 +1,70 @@ +// +// Created by Lukáš Matuška on 14.04.2026 +// Copyright © 2026 Matee. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder + func navigationBarTitleColor(_ color: Color?) -> some View { + if let color { + self.background(NavBarTitleColorSetter(color: UIColor(color))) + } else { + self + } + } +} + +private struct NavBarTitleColorSetter: UIViewControllerRepresentable { + let color: UIColor + + func makeUIViewController(context: Context) -> NavBarColorVC { + NavBarColorVC(color: color) + } + + func updateUIViewController(_ vc: NavBarColorVC, context: Context) { + vc.color = color + vc.applyColor() + } +} + +private final class NavBarColorVC: UIViewController { + var color: UIColor + + init(color: UIColor) { + self.color = color + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + applyColor() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + applyColor() + } + + func applyColor() { + var host = parent + while let current = host, current.navigationController == nil { + host = current.parent + } + + guard let host, let navBar = host.navigationController?.navigationBar else { + return + } + + let appearance = UINavigationBarAppearance(barAppearance: navBar.standardAppearance) + appearance.titleTextAttributes[.foregroundColor] = color + host.navigationItem.standardAppearance = appearance + host.navigationItem.scrollEdgeAppearance = appearance + } +} diff --git a/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Toolbar.swift b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Toolbar.swift new file mode 100644 index 00000000..a93285dd --- /dev/null +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Toolbar.swift @@ -0,0 +1,154 @@ +// +// Created by Lukáš Matuška on 26.01.2026 +// Copyright © 2026 Matee. All rights reserved. +// + +import KMPShared +import SwiftUI +import UIKit + +public extension View { + @ViewBuilder + func toolbar(_ toolbar: Toolbar?) -> some View { + if let toolbar { + self + .navigationTitle(toolbar.title?.toLocalized().uppercased() ?? "") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(!toolbar.buttons.contains { $0.checkIsBackButton() }) + .navigationBarTitleColor(toolbar.titleColor.map { Color(kmpColor: $0) }) + .toolbar { toolbarContent(toolbar) } + } else { + self + } + } + + @ToolbarContentBuilder + private func toolbarContent(_ toolbar: Toolbar) -> some ToolbarContent { + let leading = toolbar.buttons.filter { $0.position == .leading && !$0.checkIsBackButton() } + let trailing = toolbar.buttons.filter { $0.position == .trailing && !$0.checkIsBackButton() } + + if !leading.isEmpty { + ToolbarItemGroup(placement: .topBarLeading) { + ForEach(Array(leading.enumerated()), id: \.offset) { _, button in + switch button { + case let button as ToolbarButtonData.Button: + ToolbarIconButton(button: button) + case let menu as ToolbarButtonData.Menu: + ToolbarMenuButton(menu: menu) + default: + EmptyView() + } + } + } + } + + if !trailing.isEmpty { + ToolbarItemGroup(placement: .topBarTrailing) { + ForEach(Array(trailing.enumerated()), id: \.offset) { _, button in + switch button { + case let button as ToolbarButtonData.Button: + ToolbarIconButton(button: button) + case let menu as ToolbarButtonData.Menu: + ToolbarMenuButton(menu: menu) + default: + EmptyView() + } + } + } + } + + if let headerLogo = toolbar.headerLogo { + if #available(iOS 26, *) { + ToolbarItem(placement: .topBarLeading) { + ToolbarHeaderLogo(icon: headerLogo) + } + .sharedBackgroundVisibility(.hidden) + } else { + ToolbarItem(placement: .topBarLeading) { + ToolbarHeaderLogo(icon: headerLogo) + } + } + } + } +} + +private struct ToolbarIconButton: View { + let button: ToolbarButtonData.Button + + var body: some View { + Button { + button.onClick() + } label: { + button.buttonContent + } + .tint(button.tint.map { Color(kmpColor: $0) } ?? .primary) + } +} + +private struct ToolbarMenuButton: View { + let menu: ToolbarButtonData.Menu + + var body: some View { + Menu { + ForEach(menu.options, id: \.self) { option in + Button { + option.onClick() + } label: { + Label { + Text(option.label.toLocalized()) + } icon: { + if let icon = option.icon { + Image(icon) + } + } + } + } + } label: { + menu.buttonContent + } + } +} + +private struct ToolbarHeaderLogo: View { + let icon: KMPShared.ImageResource + + var body: some View { + Image(icon) + .resizable() + .scaledToFill() + .frame(height: 32) + } +} + +private extension Color { + init(kmpColor: NativeColor) { + self.init(uiColor: kmpColor.toUIColor()) + } +} + +private extension ToolbarButtonData { + func checkIsBackButton() -> Bool { + switch self { + case let button as ToolbarButtonData.Button: + button.isBackButton + default: + false + } + } + + var buttonContent: some View { + HStack { + if let icon { + Image(icon) + .renderingMode(tint == nil ? .original : .template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + + if let label { + Text(label.toLocalized()) + } + } + } +} diff --git a/ios/scripts/build-kmp.sh b/ios/scripts/build-kmp.sh index 5d4fb30b..d8115dd6 100755 --- a/ios/scripts/build-kmp.sh +++ b/ios/scripts/build-kmp.sh @@ -1,7 +1,13 @@ +if [ "$ACTION" = "clean" ]; then + echo "Running Gradle clean for Xcode clean" + ./gradlew clean < /dev/null + exit $? +fi + ./gradlew :shared:umbrella:embedAndSignAppleFrameworkForXcode < /dev/null | ./ios/scripts/kmp-beautify.sh # Copy the framework to indexer directory to support Xcode hinting/autocomplete DERIVED_DATA_DIR="$(echo "${TARGET_BUILD_DIR}" | awk -F'/Build/' '{print $1}')" INDEXER_DATA_DIR="${DERIVED_DATA_DIR}/Index.noindex/Build/Products/Debug-${PLATFORM_NAME}" mkdir -p "$INDEXER_DATA_DIR" -cp -R "shared/umbrella/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/"* "${INDEXER_DATA_DIR}" \ No newline at end of file +cp -R "shared/umbrella/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/"* "${INDEXER_DATA_DIR}" diff --git a/ios/scripts/generate-strings.sh b/ios/scripts/generate-strings.sh index f9ba33d0..951cd5ca 100755 --- a/ios/scripts/generate-strings.sh +++ b/ios/scripts/generate-strings.sh @@ -5,5 +5,10 @@ cd "$(dirname "$0")" cd ../.. +if [[ "$ACTION" == "clean" ]]; then + echo "Skipping string generation during Xcode clean" + exit 0 +fi + echo "Generating MR resources from .xml files" ./gradlew :shared:base:generateMRcommonMain < /dev/null diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeIconButton.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeIconButton.kt new file mode 100644 index 00000000..ec4948ee --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeIconButton.kt @@ -0,0 +1,50 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials + +private val ButtonSize = 36.dp + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +internal fun HazeIconButton( + painter: Painter, + tint: Color?, + onClick: () -> Unit, + hazeState: HazeState, + modifier: Modifier = Modifier, +) { + IconButton(onClick = onClick) { + Box( + modifier = modifier + .size(ButtonSize) + .clip(CircleShape) + .hazeEffect( + hazeState, + style = HazeMaterials.thin(), + ), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painter, + contentDescription = null, + colorFilter = tint?.let { ColorFilter.tint(it) }, + ) + } + } +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeTextButton.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeTextButton.kt new file mode 100644 index 00000000..45cc11d5 --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeTextButton.kt @@ -0,0 +1,51 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials + +private val ButtonHeight = 36.dp +private val ButtonShape = RoundedCornerShape(50) +private val OuterPadding = 6.dp + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +internal fun HazeTextButton( + text: String, + tint: Color, + onClick: () -> Unit, + hazeState: HazeState, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .padding(horizontal = OuterPadding) + .height(ButtonHeight) + .clip(ButtonShape) + .hazeEffect( + hazeState, + style = HazeMaterials.thin(), + ) + .clickable(onClick = onClick) + .padding(horizontal = Values.Space.medium), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = tint, + ) + } +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.android.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.android.kt new file mode 100644 index 00000000..9336a02c --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.android.kt @@ -0,0 +1,32 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.imeNestedScroll +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester + +@Composable +actual fun Modifier.dismissKeyboardOnTap(): Modifier = this + +@OptIn(ExperimentalLayoutApi::class) +@Composable +actual fun Modifier.dismissKeyboardOnScroll(): Modifier = imeNestedScroll() + +@OptIn(ExperimentalLayoutApi::class) +@Composable +actual fun Modifier.focusOnKeyboardShow( + focusRequester: FocusRequester, +): Modifier { + val isImeVisible = WindowInsets.isImeVisible + LaunchedEffect(isImeVisible) { + if (isImeVisible) { + focusRequester.requestFocus() + } + } + return this then Modifier.focusRequester(focusRequester) +} diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt new file mode 100644 index 00000000..389fafcf --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt @@ -0,0 +1,234 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +actual fun NativeScaffold( + modifier: Modifier, + toolbar: Toolbar?, + snackbarHost: @Composable (() -> Unit), + contentWindowInsets: WindowInsets, + content: @Composable (contentPadding: PaddingValues) -> Unit, +) { + val hazeState = if (toolbar?.isTransparent == true) rememberHazeState() else null + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val hasToolbarBackground = toolbar?.title != null || toolbar?.headerLogo != null + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + toolbar?.let { + CenterAlignedTopAppBar( + title = { + toolbar.title?.let { title -> + Text( + text = stringResource(title).uppercase(), + color = toolbar.titleColor?.composeColor ?: MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 32.sp, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = toolbar.backgroundColor?.composeColor + ?: if (hasToolbarBackground) { + if (toolbar.isTransparent) { + MaterialTheme.colorScheme.surface.copy(alpha = 0f) + } else { + MaterialTheme.colorScheme.surface + } + } else { + Color.Transparent + }, + scrolledContainerColor = toolbar.backgroundColor?.composeColor + ?: if (hasToolbarBackground) { + MaterialTheme.colorScheme.surface + } else { + Color.Transparent + }, + navigationIconContentColor = Color.Unspecified, + titleContentColor = Color.Unspecified, + actionIconContentColor = Color.Unspecified, + ), + navigationIcon = { + toolbar.headerLogo?.let { logo -> + Image( + painter = painterResource(logo), + contentDescription = null, + modifier = Modifier + .padding(start = Values.Space.mediumLarge) + .height(HEADER_IMAGE_HEIGHT), + ) + } + toolbar.buttons + .filter { it.position == ToolbarButtonPosition.Leading } + .forEach { button -> + when (button) { + is ToolbarButtonData.Button -> ToolbarButton(button, hazeState) + is ToolbarButtonData.Menu -> ToolbarMenuButton(button, hazeState) + } + } + }, + actions = { + toolbar.buttons + .filter { it.position == ToolbarButtonPosition.Trailing } + .forEach { button -> + when (button) { + is ToolbarButtonData.Button -> ToolbarButton(button, hazeState) + is ToolbarButtonData.Menu -> ToolbarMenuButton(button, hazeState) + } + } + }, + scrollBehavior = scrollBehavior, + ) + } + }, + snackbarHost = snackbarHost, + contentWindowInsets = contentWindowInsets, + ) { contentPadding -> + if (hazeState != null) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + ) { + content(contentPadding) + } + } else { + content(contentPadding) + } + } +} + +@Composable +private fun ToolbarButton( + button: ToolbarButtonData.Button, + hazeState: HazeState?, +) { + val tint = button.tint?.composeColor + + when { + button.icon != null && button.label == null -> { + if (hazeState != null) { + HazeIconButton( + painter = painterResource(button.icon), + tint = tint, + onClick = button.onClick, + hazeState = hazeState, + ) + } else { + IconButton(onClick = button.onClick) { + Image( + painter = painterResource(button.icon), + contentDescription = null, + colorFilter = tint?.let { ColorFilter.tint(it) }, + ) + } + } + } + + button.label != null && button.icon == null -> { + if (hazeState != null) { + HazeTextButton( + text = stringResource(button.label), + tint = tint ?: Color.Unspecified, + onClick = button.onClick, + hazeState = hazeState, + ) + } else { + TextButton(onClick = button.onClick) { + Text( + text = stringResource(button.label), + color = tint ?: Color.Unspecified, + ) + } + } + } + + else -> { + TextButton(onClick = button.onClick) { + if (button.icon != null) { + Image( + painter = painterResource(button.icon), + contentDescription = null, + colorFilter = tint?.let { ColorFilter.tint(it) }, + ) + } + + if (button.label != null) { + Text( + text = stringResource(button.label), + color = tint ?: LocalContentColor.current, + ) + } + } + } + } +} + +@Composable +private fun ToolbarMenuButton( + menu: ToolbarButtonData.Menu, + hazeState: HazeState?, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + ToolbarButton( + button = ToolbarButtonData.Button( + icon = menu.icon, + label = menu.label, + onClick = { expanded = true }, + ), + hazeState = hazeState, + ) + DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = modifier) { + menu.options.forEach { option -> + DropdownMenuItem( + text = { Text(text = stringResource(option.label)) }, + onClick = { option.onClick(); expanded = false }, + leadingIcon = option.icon?.let { icon -> { Image(painter = painterResource(icon), contentDescription = null) } }, + ) + } + } +} + +private val HEADER_IMAGE_HEIGHT = 32.dp diff --git a/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt index 0c09a6bb..23575775 100644 --- a/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/vm/BaseScopedViewModel.android.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import app.cash.molecule.AndroidUiDispatcher import app.cash.molecule.RecompositionMode import app.cash.molecule.launchMolecule +import kmp.shared.base.presentation.ui.Toolbar import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -17,6 +18,9 @@ actual abstract class BaseScopedViewModel() actual override val events = _events.asSharedFlow() @@ -27,6 +31,13 @@ actual abstract class BaseScopedViewModel by lazy(LazyThreadSafetyMode.NONE) { + viewModelScope.launchMolecule( + mode = RecompositionMode.ContextClock, + context = AndroidUiDispatcher.Main, + ) { getToolbar() } + } } @Stable @@ -34,6 +45,8 @@ actual interface BaseIntentViewModel { actual val state: StateFlow actual val events: SharedFlow + actual val toolbar: StateFlow + actual fun onIntent(intent: I) actual fun onViewAppeared() diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.kt new file mode 100644 index 00000000..88d8eb2b --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Keyboard.kt @@ -0,0 +1,17 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester + +@Composable +expect fun Modifier.dismissKeyboardOnTap(): Modifier + +@Composable +expect fun Modifier.dismissKeyboardOnScroll(): Modifier + +@Composable +expect fun Modifier.focusOnKeyboardShow( + focusRequester: FocusRequester = remember { FocusRequester() }, +): Modifier diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt new file mode 100644 index 00000000..06f8963d --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt @@ -0,0 +1,16 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun NativeScaffold( + modifier: Modifier = Modifier, + toolbar: Toolbar?, + snackbarHost: @Composable (() -> Unit) = {}, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (contentPadding: PaddingValues) -> Unit, +) diff --git a/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Toolbar.kt b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Toolbar.kt new file mode 100644 index 00000000..5db70d36 --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Toolbar.kt @@ -0,0 +1,73 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.desc.StringDesc + +data class Toolbar( + val title: StringResource? = null, + val buttons: List = emptyList(), + val headerLogo: ImageResource? = null, + val isTransparent: Boolean = false, + val titleColor: NativeColor? = null, + val backgroundColor: NativeColor? = null, +) + +sealed class ToolbarButtonData( + open val label: StringResource?, + open val icon: ImageResource?, + open val position: ToolbarButtonPosition, + open val tint: NativeColor?, +) { + + data class Button( + override val label: StringResource?, + override val icon: ImageResource?, + override val position: ToolbarButtonPosition = ToolbarButtonPosition.Trailing, + override val tint: NativeColor? = null, + val onClick: () -> Unit, + val isBackButton: Boolean = false, + ) : ToolbarButtonData( + label = label, + icon = icon, + position = position, + tint = tint, + ) { + companion object { + fun backButton(onClick: () -> Unit) = Button( + label = null, + icon = null, + position = ToolbarButtonPosition.Leading, + onClick = onClick, + isBackButton = true, + ) + } + } + + data class Menu( + override val label: StringResource?, + override val icon: ImageResource?, + override val position: ToolbarButtonPosition = ToolbarButtonPosition.Trailing, + override val tint: NativeColor? = null, + val options: List