diff --git a/android/app/src/main/kotlin/kmp/android/ui/Root.kt b/android/app/src/main/kotlin/kmp/android/ui/Root.kt index 84ee2c91..7b36725e 100644 --- a/android/app/src/main/kotlin/kmp/android/ui/Root.kt +++ b/android/app/src/main/kotlin/kmp/android/ui/Root.kt @@ -7,13 +7,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.navigation3.ui.NavDisplay -import kmp.android.shared.navigation.LocalNavigator import kmp.android.samplefeature.navigation.SampleFeatureNavKey import kmp.android.samplefeature.navigation.sampleFeatureEntries import kmp.android.shared.navigation.mateePopTransitionSpec import kmp.android.shared.navigation.mateePredictivePopTransitionSpec import kmp.android.shared.navigation.mateeTransitionSpec -import kmp.android.shared.navigation.rememberNavigator +import kmp.shared.base.presentation.navigation.LocalNavigator +import kmp.shared.base.presentation.navigation.rememberNavigator @Composable fun Root(modifier: Modifier = Modifier) { diff --git a/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt index 6ed0cade..9cc2f6fe 100644 --- a/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt +++ b/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/navigation/SampleFeatureNavigation.kt @@ -1,15 +1,21 @@ package kmp.android.samplefeature.navigation +import android.widget.Toast +import androidx.compose.ui.platform.LocalContext import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import kmp.android.samplefeature.ui.SampleFeatureMainRoute -import kmp.android.shared.navigation.Navigator +import kmp.shared.base.presentation.navigation.Navigator +import kmp.shared.samplefeature.presentation.ui.SampleFeatureRoute fun EntryProviderScope.sampleFeatureEntries( navigator: Navigator, ) { entry { - SampleFeatureMainRoute( + val context = LocalContext.current + SampleFeatureRoute( + onShowMessage = { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } // Use provided `navigator` to navigate to other screens ) } 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 deleted file mode 100644 index afbc580f..00000000 --- a/android/samplefeature/src/main/kotlin/kmp/android/samplefeature/ui/SampleFeatureMain.kt +++ /dev/null @@ -1,64 +0,0 @@ -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.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() - } - - 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, - ) - }, - ) { 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..8257fa74 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,8 +33,15 @@ 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) } + androidMain.dependencies { + implementation(libs.navigation3.runtime) + implementation(libs.lifecycle.viewModel.navigation3) + } } } @@ -45,4 +51,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..524706ba --- /dev/null +++ b/ios/PresentationLayer/UIToolkit/Sources/UIToolkit/Extensions/View+Toolbar.swift @@ -0,0 +1,150 @@ +// +// 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() ?? "") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(!toolbar.buttons.contains { $0.isBackButton }) + .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.isBackButton } + let trailing = toolbar.buttons.filter { $0.position == .trailing && !$0.isBackButton } + + 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 + } + .tint(menu.tint.map { Color(kmpColor: $0) } ?? .primary) + } +} + +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 { + var isBackButton: Bool { + self is ToolbarButtonData.BackButton + } + + 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/android/shared/src/main/kotlin/kmp/android/shared/navigation/Navigator.kt b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/navigation/Navigator.kt similarity index 99% rename from android/shared/src/main/kotlin/kmp/android/shared/navigation/Navigator.kt rename to shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/navigation/Navigator.kt index e812dde1..c0edcd0b 100644 --- a/android/shared/src/main/kotlin/kmp/android/shared/navigation/Navigator.kt +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/navigation/Navigator.kt @@ -1,4 +1,4 @@ -package kmp.android.shared.navigation +package kmp.shared.base.presentation.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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..ebaa0ec5 --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/HazeIconButton.kt @@ -0,0 +1,51 @@ +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?, + contentDescription: String? = null, + 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 = contentDescription, + 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..ef77fe83 --- /dev/null +++ b/shared/base/src/androidMain/kotlin/kmp/shared/base/presentation/ui/NativeScaffold.kt @@ -0,0 +1,253 @@ +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.Icon +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.style.TextOverflow +import androidx.compose.ui.unit.dp +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 +import kmp.shared.base.MR +import kmp.shared.base.R +import kmp.shared.base.presentation.navigation.LocalNavigator +import androidx.compose.ui.res.painterResource as androidPainterResource + +@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 != null && toolbar.anBackgroundColor == null) rememberHazeState() else null + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val toolbarButtons = toolbar?.buttons.orEmpty() + val (leadingButtons, trailingButtons) = remember(toolbarButtons) { + toolbarButtons.partition { it.position == ToolbarButtonPosition.Leading } + } + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + toolbar?.let { + CenterAlignedTopAppBar( + title = { + toolbar.title?.let { title -> + Text( + text = stringResource(title), + color = toolbar.titleColor?.composeColor ?: MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = toolbar.anBackgroundColor?.composeColor ?: Color.Transparent, + scrolledContainerColor = toolbar.anBackgroundColor?.composeColor ?: 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), + ) + } + leadingButtons.forEach { button -> + when (button) { + is ToolbarButtonData.BackButton -> ToolbarBackButton(button, hazeState) + is ToolbarButtonData.Button -> ToolbarButton(button, hazeState) + is ToolbarButtonData.Menu -> ToolbarMenuButton(button, hazeState) + } + } + }, + actions = { + trailingButtons.forEach { button -> + when (button) { + is ToolbarButtonData.BackButton -> ToolbarBackButton(button, hazeState) + 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 ToolbarBackButton( + button: ToolbarButtonData.BackButton, + hazeState: HazeState?, +) { + val backContentDescription = stringResource(MR.strings.back) + val backTint = button.tint?.composeColor ?: LocalContentColor.current + val navigator = LocalNavigator.current + + if (hazeState != null) { + HazeIconButton( + painter = androidPainterResource(R.drawable.ic_back_arrow), + tint = backTint, + contentDescription = backContentDescription, + onClick = navigator::navigateUp, + hazeState = hazeState, + ) + } else { + IconButton(onClick = navigator::navigateUp) { + Icon( + painter = androidPainterResource(R.drawable.ic_back_arrow), + contentDescription = backContentDescription, + tint = backTint, + ) + } + } +} + +@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, + position = menu.position, + tint = menu.tint, + 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/androidMain/res/drawable/ic_back_arrow.xml b/shared/base/src/androidMain/res/drawable/ic_back_arrow.xml new file mode 100644 index 00000000..afc58241 --- /dev/null +++ b/shared/base/src/androidMain/res/drawable/ic_back_arrow.xml @@ -0,0 +1,10 @@ + + + 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..6cc18d51 --- /dev/null +++ b/shared/base/src/commonMain/kotlin/kmp/shared/base/presentation/ui/Toolbar.kt @@ -0,0 +1,91 @@ +package kmp.shared.base.presentation.ui + +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.ImageResource +import dev.icerock.moko.resources.StringResource + +/** + * Shared toolbar model consumed by platform-specific scaffold implementations. + * + * @property title Localized title shown in the navigation bar. + * @property buttons Interactive items rendered on the leading or trailing side. + * @property headerLogo Optional brand image rendered in the toolbar header. + * @property titleColor Optional title tint used by platform renderers. + * @property anBackgroundColor Optional Android-only toolbar background color. + */ +data class Toolbar( + val title: StringResource? = null, + val buttons: List = emptyList(), + val headerLogo: ImageResource? = null, + val titleColor: NativeColor? = null, + val anBackgroundColor: NativeColor? = null, +) + +/** + * Base type for actions rendered inside a [Toolbar]. + * + * @property label Optional localized text shown for the action. + * @property icon Optional image shown for the action. + * @property position Target side of the toolbar. + * @property tint Optional platform-specific tint for the rendered content. + */ +sealed class ToolbarButtonData( + open val label: StringResource?, + open val icon: ImageResource?, + open val position: ToolbarButtonPosition, + open val tint: NativeColor?, +) { + /** Standard navigation back affordance handled by the hosting platform. */ + data class BackButton( + override val tint: NativeColor? = null, + ) : ToolbarButtonData( + label = null, + icon = null, + position = ToolbarButtonPosition.Leading, + tint = tint, + ) + + /** Simple tappable toolbar action. */ + 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, + ) : ToolbarButtonData( + label = label, + icon = icon, + position = position, + tint = tint, + ) + + /** Toolbar action that expands into a list of selectable options. */ + 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